mirror of
https://github.com/projectdiscovery/nuclei.git
synced 2026-11-09 12:21:25 -10:00
Compare commits
71
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43ed8eb8fc | ||
|
|
54662dc4f4 | ||
|
|
ec6794c2b4 | ||
|
|
0505091a26 | ||
|
|
74904a8e62 | ||
|
|
72e5254c09 | ||
|
|
947bf26d1e | ||
|
|
e819f26a57 | ||
|
|
5e3854e0f9 | ||
|
|
4505f0dcf0 | ||
|
|
c2673595c8 | ||
|
|
82fd082d4e | ||
|
|
46859600b8 | ||
|
|
eb70852e7e | ||
|
|
afb307a5b1 | ||
|
|
aaef31a5ac | ||
|
|
7d93360e94 | ||
|
|
533a4001d7 | ||
|
|
58ca1b55bd | ||
|
|
1c77b4a52c | ||
|
|
dd71efcffc | ||
|
|
2f71e68f9a | ||
|
|
0c6cb5ca3d | ||
|
|
40d08ee493 | ||
|
|
27ff6b19b7 | ||
|
|
361f3b1383 | ||
|
|
c9e5aef28f | ||
|
|
19daa84e8a | ||
|
|
8cda906559 | ||
|
|
a2e403a1d8 | ||
|
|
bbebeba8b2 | ||
|
|
578d00829a | ||
|
|
3be47b098f | ||
|
|
b29ecc6907 | ||
|
|
29559cbd2a | ||
|
|
94f7148b7e | ||
|
|
1829fca65c | ||
|
|
accfe3551b | ||
|
|
d6ec8407fb | ||
|
|
bb2da9bbea | ||
|
|
6e2b59827c | ||
|
|
875b229313 | ||
|
|
91f8bd1e4f | ||
|
|
fb0eed5122 | ||
|
|
29833da25a | ||
|
|
9b519d2264 | ||
|
|
4fed40f75a | ||
|
|
2f4ac3d9e5 | ||
|
|
27ac1e8726 | ||
|
|
5b499e0650 | ||
|
|
7748411200 | ||
|
|
0642f05241 | ||
|
|
df707d924f | ||
|
|
5f5a40c2d4 | ||
|
|
78a0362bac | ||
|
|
82d5a8acc2 | ||
|
|
9d3be8b22e | ||
|
|
b7e7da9155 | ||
|
|
76dd059821 | ||
|
|
b7b4fb97e0 | ||
|
|
6863ab16e5 | ||
|
|
f5c033162f | ||
|
|
71c268d59e | ||
|
|
1d2a913241 | ||
|
|
128ec95adb | ||
|
|
f766893685 | ||
|
|
a4422d8962 | ||
|
|
efed3f2d8a | ||
|
|
58e49df9a8 | ||
|
|
c4315d3eba | ||
|
|
6b1676736c |
No files matched your search
@@ -17,4 +17,4 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7
|
- uses: actions/checkout@v7
|
||||||
- uses: crate-ci/[email protected]1.1
|
- uses: crate-ci/[email protected]2.0
|
||||||
@@ -166,6 +166,7 @@ TEMPLATES:
|
|||||||
-w, -workflows string[] list of workflow or workflow directory to run (comma-separated, file)
|
-w, -workflows string[] list of workflow or workflow directory to run (comma-separated, file)
|
||||||
-wurl, -workflow-url string[] workflow url or list containing workflow urls to run (comma-separated, file)
|
-wurl, -workflow-url string[] workflow url or list containing workflow urls to run (comma-separated, file)
|
||||||
-validate validate the passed templates to nuclei
|
-validate validate the passed templates to nuclei
|
||||||
|
-plan string write the work a scan would do with its estimated cost to a jsonl file, then exit without sending requests to any target
|
||||||
-nss, -no-strict-syntax disable strict syntax check on templates
|
-nss, -no-strict-syntax disable strict syntax check on templates
|
||||||
-td, -template-display displays the templates content
|
-td, -template-display displays the templates content
|
||||||
-tl list all templates matching current filters
|
-tl list all templates matching current filters
|
||||||
@@ -219,6 +220,7 @@ OUTPUT:
|
|||||||
-se, -sarif-export string file to export results in SARIF format
|
-se, -sarif-export string file to export results in SARIF format
|
||||||
-je, -json-export string file to export results in JSON format
|
-je, -json-export string file to export results in JSON format
|
||||||
-jle, -jsonl-export string file to export results in JSONL(ine) 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
|
-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
|
-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
|
-nrd, -no-redact disable the built-in redaction of credential headers (Authorization, Cookie, Set-Cookie, ...) in output; keys given with -redact are still redacted
|
||||||
@@ -256,6 +258,7 @@ CONFIGURATIONS:
|
|||||||
-rsr, -response-size-read int max response size to read in bytes
|
-rsr, -response-size-read int max response size to read in bytes
|
||||||
-rss, -response-size-save int max response size to read in bytes (default 1048576)
|
-rss, -response-size-save int max response size to read in bytes (default 1048576)
|
||||||
-rqss, -request-size-save int max request body size to save in bytes (0 to save the whole body) (default 32768)
|
-rqss, -request-size-save int max request body size to save in bytes (0 to save the whole body) (default 32768)
|
||||||
|
-mrbi, -max-request-body-inflight int max request body bytes held by in-flight http requests; requests with bodies of 1MB or more queue past it (0 disables)
|
||||||
-reset reset removes all nuclei configuration and data files (including nuclei-templates)
|
-reset reset removes all nuclei configuration and data files (including nuclei-templates)
|
||||||
-tlsi, -tls-impersonate enable experimental client hello (ja3) tls randomization
|
-tlsi, -tls-impersonate enable experimental client hello (ja3) tls randomization
|
||||||
-hae, -http-api-endpoint string experimental http api endpoint
|
-hae, -http-api-endpoint string experimental http api endpoint
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ TEMPLATES:
|
|||||||
-w, -workflows string[] list of workflow or workflow directory to run (comma-separated, file)
|
-w, -workflows string[] list of workflow or workflow directory to run (comma-separated, file)
|
||||||
-wurl, -workflow-url string[] workflow url or list containing workflow urls to run (comma-separated, file)
|
-wurl, -workflow-url string[] workflow url or list containing workflow urls to run (comma-separated, file)
|
||||||
-validate validate the passed templates to nuclei
|
-validate validate the passed templates to nuclei
|
||||||
|
-plan string write the work a scan would do with its estimated cost to a jsonl file, then exit without sending requests to any target
|
||||||
-nss, -no-strict-syntax disable strict syntax check on templates
|
-nss, -no-strict-syntax disable strict syntax check on templates
|
||||||
-td, -template-display displays the templates content
|
-td, -template-display displays the templates content
|
||||||
-tl list all templates matching current filters
|
-tl list all templates matching current filters
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ TEMPLATES:
|
|||||||
-w, -workflows string[] list of workflow or workflow directory to run (comma-separated, file)
|
-w, -workflows string[] list of workflow or workflow directory to run (comma-separated, file)
|
||||||
-wurl, -workflow-url string[] workflow url or list containing workflow urls to run (comma-separated, file)
|
-wurl, -workflow-url string[] workflow url or list containing workflow urls to run (comma-separated, file)
|
||||||
-validate validate the passed templates to nuclei
|
-validate validate the passed templates to nuclei
|
||||||
|
-plan string write the work a scan would do with its estimated cost to a jsonl file, then exit without sending requests to any target
|
||||||
-nss, -no-strict-syntax disable strict syntax check on templates
|
-nss, -no-strict-syntax disable strict syntax check on templates
|
||||||
-td, -template-display displays the templates content
|
-td, -template-display displays the templates content
|
||||||
-tl list all templates matching current filters
|
-tl list all templates matching current filters
|
||||||
|
|||||||
@@ -181,6 +181,7 @@ TEMPLATES:
|
|||||||
-w, -workflows string[] list of workflow or workflow directory to run (comma-separated, file)
|
-w, -workflows string[] list of workflow or workflow directory to run (comma-separated, file)
|
||||||
-wurl, -workflow-url string[] workflow url or list containing workflow urls to run (comma-separated, file)
|
-wurl, -workflow-url string[] workflow url or list containing workflow urls to run (comma-separated, file)
|
||||||
-validate validate the passed templates to nuclei
|
-validate validate the passed templates to nuclei
|
||||||
|
-plan string write the work a scan would do with its estimated cost to a jsonl file, then exit without sending requests to any target
|
||||||
-nss, -no-strict-syntax disable strict syntax check on templates
|
-nss, -no-strict-syntax disable strict syntax check on templates
|
||||||
-td, -template-display displays the templates content
|
-td, -template-display displays the templates content
|
||||||
-tl list all templates matching current filters
|
-tl list all templates matching current filters
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ TEMPLATES:
|
|||||||
-w, -workflows string[] list of workflow or workflow directory to run (comma-separated, file)
|
-w, -workflows string[] list of workflow or workflow directory to run (comma-separated, file)
|
||||||
-wurl, -workflow-url string[] workflow url or list containing workflow urls to run (comma-separated, file)
|
-wurl, -workflow-url string[] workflow url or list containing workflow urls to run (comma-separated, file)
|
||||||
-validate validate the passed templates to nuclei
|
-validate validate the passed templates to nuclei
|
||||||
|
-plan string write the work a scan would do with its estimated cost to a jsonl file, then exit without sending requests to any target
|
||||||
-nss, -no-strict-syntax disable strict syntax check on templates
|
-nss, -no-strict-syntax disable strict syntax check on templates
|
||||||
-td, -template-display displays the templates content
|
-td, -template-display displays the templates content
|
||||||
-tl list all templates matching current filters
|
-tl list all templates matching current filters
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ TEMPLATES:
|
|||||||
-w, -workflows string[] list of workflow or workflow directory to run (comma-separated, file)
|
-w, -workflows string[] list of workflow or workflow directory to run (comma-separated, file)
|
||||||
-wurl, -workflow-url string[] workflow url or list containing workflow urls to run (comma-separated, file)
|
-wurl, -workflow-url string[] workflow url or list containing workflow urls to run (comma-separated, file)
|
||||||
-validate validate the passed templates to nuclei
|
-validate validate the passed templates to nuclei
|
||||||
|
-plan string write the work a scan would do with its estimated cost to a jsonl file, then exit without sending requests to any target
|
||||||
-nss, -no-strict-syntax disable strict syntax check on templates
|
-nss, -no-strict-syntax disable strict syntax check on templates
|
||||||
-td, -template-display displays the templates content
|
-td, -template-display displays the templates content
|
||||||
-tl list all templates matching current filters
|
-tl list all templates matching current filters
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ TEMPLATES:
|
|||||||
-w, -workflows string[] list of workflow or workflow directory to run (comma-separated, file)
|
-w, -workflows string[] list of workflow or workflow directory to run (comma-separated, file)
|
||||||
-wurl, -workflow-url string[] workflow url or list containing workflow urls to run (comma-separated, file)
|
-wurl, -workflow-url string[] workflow url or list containing workflow urls to run (comma-separated, file)
|
||||||
-validate validate the passed templates to nuclei
|
-validate validate the passed templates to nuclei
|
||||||
|
-plan string write the work a scan would do with its estimated cost to a jsonl file, then exit without sending requests to any target
|
||||||
-nss, -no-strict-syntax disable strict syntax check on templates
|
-nss, -no-strict-syntax disable strict syntax check on templates
|
||||||
-td, -template-display displays the templates content
|
-td, -template-display displays the templates content
|
||||||
-tl list all templates matching current filters
|
-tl list all templates matching current filters
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ TEMPLATES:
|
|||||||
-w, -workflows string[] list of workflow or workflow directory to run (comma-separated, file)
|
-w, -workflows string[] list of workflow or workflow directory to run (comma-separated, file)
|
||||||
-wurl, -workflow-url string[] workflow url or list containing workflow urls to run (comma-separated, file)
|
-wurl, -workflow-url string[] workflow url or list containing workflow urls to run (comma-separated, file)
|
||||||
-validate validate the passed templates to nuclei
|
-validate validate the passed templates to nuclei
|
||||||
|
-plan string write the work a scan would do with its estimated cost to a jsonl file, then exit without sending requests to any target
|
||||||
-nss, -no-strict-syntax disable strict syntax check on templates
|
-nss, -no-strict-syntax disable strict syntax check on templates
|
||||||
-td, -template-display displays the templates content
|
-td, -template-display displays the templates content
|
||||||
-tl list all templates matching current filters
|
-tl list all templates matching current filters
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ TEMPLATES:
|
|||||||
-w, -workflows string[] list of workflow or workflow directory to run (comma-separated, file)
|
-w, -workflows string[] list of workflow or workflow directory to run (comma-separated, file)
|
||||||
-wurl, -workflow-url string[] workflow url or list containing workflow urls to run (comma-separated, file)
|
-wurl, -workflow-url string[] workflow url or list containing workflow urls to run (comma-separated, file)
|
||||||
-validate validate the passed templates to nuclei
|
-validate validate the passed templates to nuclei
|
||||||
|
-plan string write the work a scan would do with its estimated cost to a jsonl file, then exit without sending requests to any target
|
||||||
-nss, -no-strict-syntax disable strict syntax check on templates
|
-nss, -no-strict-syntax disable strict syntax check on templates
|
||||||
-td, -template-display displays the templates content
|
-td, -template-display displays the templates content
|
||||||
-tl list all templates matching current filters
|
-tl list all templates matching current filters
|
||||||
|
|||||||
+21
-13
@@ -1,13 +1,13 @@
|
|||||||
<!-- Generated by securitycontext.dev. Source HEAD: df57ae6d2955864fd92a1bd2565fe9e4e9a7592b. -->
|
<!-- Generated by securitycontext.dev. Source HEAD: 40d08ee493f6f84c9a6b3c7f15f22da83853130c. -->
|
||||||
# Security Context: projectdiscovery/nuclei @ dev
|
# 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.
|
> 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 [df57ae6d29][c-df57ae6d29] on `dev` · 5174 commits, 548 security-relevant examined · generated 2026-11-02_
|
_Analyzed [40d08ee493][c-40d08ee493] on `dev` · 5242 commits, 586 security-relevant examined · generated 2026-11-09_
|
||||||
|
|
||||||
## Recurring Security Patterns
|
## Recurring Security Patterns
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|
||||||
## Top Risks
|
## Top Risks
|
||||||
|
|
||||||
@@ -15,12 +15,12 @@ Components with the strongest concentration of past fixes for one class.
|
|||||||
|
|
||||||
| # | Class | Component | Severity | Fixes | Evidence |
|
| # | Class | Component | Severity | Fixes | Evidence |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| 1 | SSRF | `pkg/protocols/common/protocolstate/headless.go` | high | 2 | [d43c1f547f][c-d43c1f547f], [d9dabbb4a1][c-d9dabbb4a1] |
|
| 1 | Code Execution | `pkg/fuzz/execute.go` | high | 2 | [4fed40f75a][c-4fed40f75a], [ccbfb12bd0][c-ccbfb12bd0] |
|
||||||
| 2 | Arbitrary File Access | `v2/pkg/protocols/common/generators/load.go` | high | 0 (1 component gone, not counted) | [e5154d362a][c-e5154d362a] |
|
| 2 | SSRF | `pkg/protocols/common/protocolstate/headless.go` | high | 2 | [d43c1f547f][c-d43c1f547f], [d9dabbb4a1][c-d9dabbb4a1] |
|
||||||
| 3 | Arbitrary File Access | `pkg/js/libs/mysql/mysql.go` | high | 1 | [634cecbb28][c-634cecbb28] |
|
| 3 | Arbitrary File Access | `v2/pkg/protocols/common/generators/load.go` | high | 0 (1 component gone, not counted) | [e5154d362a][c-e5154d362a] |
|
||||||
| 4 | Code Execution | `pkg/fuzz/execute.go` | high | 1 | [ccbfb12bd0][c-ccbfb12bd0] |
|
| 4 | Arbitrary File Access | `pkg/js/libs/mysql/mysql.go` | high | 1 | [634cecbb28][c-634cecbb28] |
|
||||||
| 5 | Code Execution | `pkg/protocols/common/expressions/expressions.go` | high | 1 | [d221732016][c-d221732016] |
|
| 5 | Certificate Validation Bypass | `pkg/reporting/exporters/es/elasticsearch.go` | high | 1 | [82d5a8acc2][c-82d5a8acc2] |
|
||||||
| 6 | Information Disclosure | `internal/httpapi/apiendpoint.go` | high | 1 | [a20aa26024][c-a20aa26024] |
|
| 6 | Code Execution | `pkg/protocols/common/expressions/expressions.go` | high | 1 | [d221732016][c-d221732016] |
|
||||||
|
|
||||||
## Fixed-Issue Fingerprints
|
## Fixed-Issue Fingerprints
|
||||||
|
|
||||||
@@ -28,8 +28,13 @@ Real security fixes mined from git history; use them as regression checks. `veri
|
|||||||
|
|
||||||
**By component** (directories with the most fixes):
|
**By component** (directories with the most fixes):
|
||||||
|
|
||||||
|
- `pkg/fuzz` — 2 fixes: Code Execution (2)
|
||||||
- `pkg/protocols/common/protocolstate` — 2 fixes: SSRF (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
|
- [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
|
`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
|
- [d43c1f547f][c-d43c1f547f] **fix(headless): canonicalize IPv4-mapped IPv6 literals and re-validate dial targets** — SSRF, high [CWE-918] · inferred · 2026-10-13
|
||||||
@@ -63,11 +68,12 @@ Real security fixes mined from git history; use them as regression checks. `veri
|
|||||||
|
|
||||||
| Class | Fixes | Verified | Most recent fix |
|
| 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 |
|
| 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 |
|
| 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 |
|
| Sandbox Escape | 2 | 0 | [2026-06-24][c-3a8b65cb45] 3a8b65cb45 |
|
||||||
| Signature Verification Bypass | 2 | 2 | [2026-07-03][c-fac0b7196d] fac0b7196d |
|
| 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 |
|
| Information Disclosure | 1 | 0 | [2026-08-03][c-a20aa26024] a20aa26024 |
|
||||||
| Path Traversal | 1 | 0 | [2026-06-04][c-20cbf0bd73] 20cbf0bd73 |
|
| Path Traversal | 1 | 0 | [2026-06-04][c-20cbf0bd73] 20cbf0bd73 |
|
||||||
| SQL Injection | 1 | 0 | [2026-06-23][c-68daef44a7] 68daef44a7 |
|
| SQL Injection | 1 | 0 | [2026-06-23][c-68daef44a7] 68daef44a7 |
|
||||||
@@ -100,15 +106,17 @@ 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-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... |
|
| 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-df57ae6d29]: https://github.com/projectdiscovery/nuclei/commit/df57ae6d2955864fd92a1bd2565fe9e4e9a7592b
|
[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-d43c1f547f]: https://github.com/projectdiscovery/nuclei/commit/d43c1f547f98ac56654f4335ad20af0cca4c229c
|
[c-d43c1f547f]: https://github.com/projectdiscovery/nuclei/commit/d43c1f547f98ac56654f4335ad20af0cca4c229c
|
||||||
[c-d9dabbb4a1]: https://github.com/projectdiscovery/nuclei/commit/d9dabbb4a19e897a6bc12334a030a43035b27b73
|
[c-d9dabbb4a1]: https://github.com/projectdiscovery/nuclei/commit/d9dabbb4a19e897a6bc12334a030a43035b27b73
|
||||||
[c-e5154d362a]: https://github.com/projectdiscovery/nuclei/commit/e5154d362af900e83b30fb79028bfab1a7f0c648
|
[c-e5154d362a]: https://github.com/projectdiscovery/nuclei/commit/e5154d362af900e83b30fb79028bfab1a7f0c648
|
||||||
[c-634cecbb28]: https://github.com/projectdiscovery/nuclei/commit/634cecbb289db494247c04a9c4ec6a113aef3cc0
|
[c-634cecbb28]: https://github.com/projectdiscovery/nuclei/commit/634cecbb289db494247c04a9c4ec6a113aef3cc0
|
||||||
[c-ccbfb12bd0]: https://github.com/projectdiscovery/nuclei/commit/ccbfb12bd01447ba25f6e755dfb2bee677736da6
|
[c-82d5a8acc2]: https://github.com/projectdiscovery/nuclei/commit/82d5a8acc2c756394c7fa92eae08b41330e89d69
|
||||||
[c-d221732016]: https://github.com/projectdiscovery/nuclei/commit/d2217320162d5782ca7cb95bef9dda17063818f3
|
[c-d221732016]: https://github.com/projectdiscovery/nuclei/commit/d2217320162d5782ca7cb95bef9dda17063818f3
|
||||||
[c-a20aa26024]: https://github.com/projectdiscovery/nuclei/commit/a20aa260245e5db889c5a421d4a65aeb2a64bf46
|
|
||||||
[c-1c0f77d28e]: https://github.com/projectdiscovery/nuclei/commit/1c0f77d28ed639a34c2036fa23c7503ffac0bf27
|
[c-1c0f77d28e]: https://github.com/projectdiscovery/nuclei/commit/1c0f77d28ed639a34c2036fa23c7503ffac0bf27
|
||||||
|
[c-a20aa26024]: https://github.com/projectdiscovery/nuclei/commit/a20aa260245e5db889c5a421d4a65aeb2a64bf46
|
||||||
[c-fac0b7196d]: https://github.com/projectdiscovery/nuclei/commit/fac0b7196d2fbca56c8af8ff8e1899c111041a37
|
[c-fac0b7196d]: https://github.com/projectdiscovery/nuclei/commit/fac0b7196d2fbca56c8af8ff8e1899c111041a37
|
||||||
[c-3a8b65cb45]: https://github.com/projectdiscovery/nuclei/commit/3a8b65cb45f121fcdf418f60e5177ed9f250c122
|
[c-3a8b65cb45]: https://github.com/projectdiscovery/nuclei/commit/3a8b65cb45f121fcdf418f60e5177ed9f250c122
|
||||||
[c-68daef44a7]: https://github.com/projectdiscovery/nuclei/commit/68daef44a7eafd41c39690924620e418762909ef
|
[c-68daef44a7]: https://github.com/projectdiscovery/nuclei/commit/68daef44a7eafd41c39690924620e418762909ef
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/projectdiscovery/gologger"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
const headerChildEnv = "NUCLEI_HEADER_CHILD_ARGS"
|
||||||
|
|
||||||
|
// -H values can contain "#", on the command line, in a header file and in the
|
||||||
|
// config file, while lines of a header file that start with "#" are skipped as
|
||||||
|
// comments.
|
||||||
|
func TestHeaderValuesKeepHash(t *testing.T) {
|
||||||
|
if args := os.Getenv(headerChildEnv); args != "" {
|
||||||
|
options = &types.Options{Logger: gologger.DefaultLogger}
|
||||||
|
os.Args = append([]string{os.Args[0]}, strings.Split(args, "\n")...)
|
||||||
|
readConfig()
|
||||||
|
fmt.Printf("headers=%s\n", strings.Join(options.CustomHeaders, "|"))
|
||||||
|
os.Exit(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
headerFile := filepath.Join(root, "headers.txt")
|
||||||
|
require.NoError(t, os.WriteFile(headerFile, []byte("# auth headers\nX-Token: abc#def\nReferer: https://app.example.com/#/dashboard\n"), 0o600))
|
||||||
|
configFile := filepath.Join(root, "config.yaml")
|
||||||
|
require.NoError(t, os.WriteFile(configFile, []byte("header:\n - \"X-Config: cfg#value\"\n"), 0o600))
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
args []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"cli and header file", []string{"-H", "Cookie: sid=1; color=#ff0000", "-H", headerFile}, "Cookie: sid=1; color=#ff0000|X-Token: abc#def|Referer: https://app.example.com/#/dashboard"},
|
||||||
|
{"config file", []string{"-config", configFile}, "X-Config: cfg#value"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
tempRoot := filepath.Join(t.TempDir(), "tmp")
|
||||||
|
require.NoError(t, os.Mkdir(tempRoot, 0o700))
|
||||||
|
|
||||||
|
args := append(tt.args, "-disable-update-check")
|
||||||
|
cmd := exec.Command(os.Args[0], "-test.run=^TestHeaderValuesKeepHash$", "-test.count=1")
|
||||||
|
cmd.Env = isolatedNucleiStorageEnv(root, tempRoot, headerChildEnv+"="+strings.Join(args, "\n"))
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
require.NoError(t, err, string(output))
|
||||||
|
require.Contains(t, string(output), "headers="+tt.want+"\n")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-1
@@ -55,6 +55,17 @@ var (
|
|||||||
inlineSecretsTempMu sync.Mutex
|
inlineSecretsTempMu sync.Mutex
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// normalizeHeaderValue trims a -H value and drops it when the whole line is a
|
||||||
|
// "#" comment. The goflags default strips inline comments, which cut values
|
||||||
|
// such as "Cookie: color=#fff" at the "#".
|
||||||
|
func normalizeHeaderValue(s string) string {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
if strings.HasPrefix(s, "#") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
options.Logger = gologger.DefaultLogger
|
options.Logger = gologger.DefaultLogger
|
||||||
|
|
||||||
@@ -255,6 +266,11 @@ func readConfig() *goflags.FlagSet {
|
|||||||
var pdcpAuth string
|
var pdcpAuth string
|
||||||
var fuzzFlag bool
|
var fuzzFlag bool
|
||||||
|
|
||||||
|
// FileStringSliceOptions as set up by runner.ConfigureOptions, but keeping
|
||||||
|
// "#" inside -H values
|
||||||
|
headerSliceOptions := goflags.FileStringSliceOptions
|
||||||
|
headerSliceOptions.Normalize = normalizeHeaderValue
|
||||||
|
|
||||||
flagSet := goflags.NewFlagSet()
|
flagSet := goflags.NewFlagSet()
|
||||||
flagSet.CaseSensitive = true
|
flagSet.CaseSensitive = true
|
||||||
flagSet.SetDescription(`Nuclei is a fast, template based vulnerability scanner focusing
|
flagSet.SetDescription(`Nuclei is a fast, template based vulnerability scanner focusing
|
||||||
@@ -297,6 +313,7 @@ on extensive configurability, massive extensibility and ease of use.`)
|
|||||||
flagSet.StringSliceVarP(&options.Workflows, "workflows", "w", nil, "list of workflow or workflow directory to run (comma-separated, file)", goflags.FileCommaSeparatedStringSliceOptions),
|
flagSet.StringSliceVarP(&options.Workflows, "workflows", "w", nil, "list of workflow or workflow directory to run (comma-separated, file)", goflags.FileCommaSeparatedStringSliceOptions),
|
||||||
flagSet.StringSliceVarP(&options.WorkflowURLs, "workflow-url", "wurl", nil, "workflow url or list containing workflow urls to run (comma-separated, file)", goflags.FileCommaSeparatedStringSliceOptions),
|
flagSet.StringSliceVarP(&options.WorkflowURLs, "workflow-url", "wurl", nil, "workflow url or list containing workflow urls to run (comma-separated, file)", goflags.FileCommaSeparatedStringSliceOptions),
|
||||||
flagSet.BoolVar(&options.Validate, "validate", false, "validate the passed templates to nuclei"),
|
flagSet.BoolVar(&options.Validate, "validate", false, "validate the passed templates to nuclei"),
|
||||||
|
flagSet.StringVar(&options.PlanFile, "plan", "", "write the work a scan would do with its estimated cost to a jsonl file, then exit without sending requests to any target"),
|
||||||
flagSet.BoolVarP(&options.NoStrictSyntax, "no-strict-syntax", "nss", false, "disable strict syntax check on templates"),
|
flagSet.BoolVarP(&options.NoStrictSyntax, "no-strict-syntax", "nss", false, "disable strict syntax check on templates"),
|
||||||
flagSet.BoolVarP(&options.TemplateDisplay, "template-display", "td", false, "displays the templates content"),
|
flagSet.BoolVarP(&options.TemplateDisplay, "template-display", "td", false, "displays the templates content"),
|
||||||
flagSet.BoolVar(&options.TemplateList, "tl", false, "list all templates matching current filters"),
|
flagSet.BoolVar(&options.TemplateList, "tl", false, "list all templates matching current filters"),
|
||||||
@@ -353,6 +370,7 @@ 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.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.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.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.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.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"),
|
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"),
|
||||||
@@ -367,7 +385,7 @@ on extensive configurability, massive extensibility and ease of use.`)
|
|||||||
flagSet.IntVarP(&options.MaxRedirects, "max-redirects", "mr", 10, "max number of redirects to follow for http templates"),
|
flagSet.IntVarP(&options.MaxRedirects, "max-redirects", "mr", 10, "max number of redirects to follow for http templates"),
|
||||||
flagSet.BoolVarP(&options.DisableRedirects, "disable-redirects", "dr", false, "disable redirects for http templates"),
|
flagSet.BoolVarP(&options.DisableRedirects, "disable-redirects", "dr", false, "disable redirects for http templates"),
|
||||||
flagSet.StringVarP(&options.ReportingConfig, "report-config", "rc", "", "nuclei reporting module configuration file"), // TODO merge into the config file or rename to issue-tracking
|
flagSet.StringVarP(&options.ReportingConfig, "report-config", "rc", "", "nuclei reporting module configuration file"), // TODO merge into the config file or rename to issue-tracking
|
||||||
flagSet.StringSliceVarP(&options.CustomHeaders, "header", "H", nil, "custom header/cookie to include in all http request in header:value format (cli, file)", goflags.FileStringSliceOptions),
|
flagSet.StringSliceVarP(&options.CustomHeaders, "header", "H", nil, "custom header/cookie to include in all http request in header:value format (cli, file)", headerSliceOptions),
|
||||||
flagSet.RuntimeMapVarP(&options.Vars, "var", "V", nil, "custom vars in key=value format"),
|
flagSet.RuntimeMapVarP(&options.Vars, "var", "V", nil, "custom vars in key=value format"),
|
||||||
flagSet.StringVarP(&options.ResolversFile, "resolvers", "r", "", "file containing resolver list for nuclei"),
|
flagSet.StringVarP(&options.ResolversFile, "resolvers", "r", "", "file containing resolver list for nuclei"),
|
||||||
flagSet.BoolVarP(&options.SystemResolvers, "system-resolvers", "sr", false, "use system DNS resolving as error fallback"),
|
flagSet.BoolVarP(&options.SystemResolvers, "system-resolvers", "sr", false, "use system DNS resolving as error fallback"),
|
||||||
@@ -391,6 +409,7 @@ on extensive configurability, massive extensibility and ease of use.`)
|
|||||||
flagSet.IntVarP(&options.ResponseReadSize, "response-size-read", "rsr", 0, "max response size to read in bytes"),
|
flagSet.IntVarP(&options.ResponseReadSize, "response-size-read", "rsr", 0, "max response size to read in bytes"),
|
||||||
flagSet.IntVarP(&options.ResponseSaveSize, "response-size-save", "rss", unitutils.Mega, "max response size to read in bytes"),
|
flagSet.IntVarP(&options.ResponseSaveSize, "response-size-save", "rss", unitutils.Mega, "max response size to read in bytes"),
|
||||||
flagSet.IntVarP(&options.RequestSaveSize, "request-size-save", "rqss", 32*unitutils.Kilo, "max request body size to save in bytes (0 to save the whole body)"),
|
flagSet.IntVarP(&options.RequestSaveSize, "request-size-save", "rqss", 32*unitutils.Kilo, "max request body size to save in bytes (0 to save the whole body)"),
|
||||||
|
flagSet.IntVarP(&options.MaxRequestBodyInFlight, "max-request-body-inflight", "mrbi", 0, "max request body bytes held by in-flight http requests; requests with bodies of 1MB or more queue past it (0 disables)"),
|
||||||
flagSet.CallbackVar(func() { resetRequested = true }, "reset", "reset removes all nuclei configuration and data files (including nuclei-templates)"),
|
flagSet.CallbackVar(func() { resetRequested = true }, "reset", "reset removes all nuclei configuration and data files (including nuclei-templates)"),
|
||||||
flagSet.BoolVarP(&options.TlsImpersonate, "tls-impersonate", "tlsi", false, "enable experimental client hello (ja3) tls randomization"),
|
flagSet.BoolVarP(&options.TlsImpersonate, "tls-impersonate", "tlsi", false, "enable experimental client hello (ja3) tls randomization"),
|
||||||
flagSet.StringVarP(&options.HttpApiEndpoint, "http-api-endpoint", "hae", "", "experimental http api endpoint"),
|
flagSet.StringVarP(&options.HttpApiEndpoint, "http-api-endpoint", "hae", "", "experimental http api endpoint"),
|
||||||
|
|||||||
@@ -15,10 +15,10 @@ require (
|
|||||||
github.com/Mzack9999/go-rsync v0.0.0-20250821180103-81ffa574ef4d
|
github.com/Mzack9999/go-rsync v0.0.0-20250821180103-81ffa574ef4d
|
||||||
github.com/RedTeamPentesting/adauth v0.5.4-0.20260511073005-3d18e8a5a687
|
github.com/RedTeamPentesting/adauth v0.5.4-0.20260511073005-3d18e8a5a687
|
||||||
github.com/adrg/xdg v0.5.3
|
github.com/adrg/xdg v0.5.3
|
||||||
github.com/alecthomas/chroma v0.10.0
|
github.com/alecthomas/chroma/v2 v2.27.0
|
||||||
github.com/alexsnet/go-vnc v0.1.0
|
github.com/alexsnet/go-vnc v0.1.0
|
||||||
github.com/alitto/pond v1.9.2
|
github.com/alitto/pond v1.9.2
|
||||||
github.com/andybalholm/brotli v1.2.0
|
github.com/andybalholm/brotli v1.2.7
|
||||||
github.com/andygrunwald/go-jira v1.16.1
|
github.com/andygrunwald/go-jira v1.16.1
|
||||||
github.com/antchfx/htmlquery v1.3.7
|
github.com/antchfx/htmlquery v1.3.7
|
||||||
github.com/antchfx/xmlquery v1.4.4
|
github.com/antchfx/xmlquery v1.4.4
|
||||||
@@ -63,7 +63,7 @@ require (
|
|||||||
github.com/jhump/protoreflect v1.17.0
|
github.com/jhump/protoreflect v1.17.0
|
||||||
github.com/julienschmidt/httprouter v1.3.0
|
github.com/julienschmidt/httprouter v1.3.0
|
||||||
github.com/kitabisa/go-ci v1.0.3
|
github.com/kitabisa/go-ci v1.0.3
|
||||||
github.com/klauspost/compress v1.18.7
|
github.com/klauspost/compress v1.20.2
|
||||||
github.com/leslie-qiwa/flat v0.0.0-20230424180412-f9d1cf014baa
|
github.com/leslie-qiwa/flat v0.0.0-20230424180412-f9d1cf014baa
|
||||||
github.com/lib/pq v1.12.3
|
github.com/lib/pq v1.12.3
|
||||||
github.com/logrusorgru/aurora/v4 v4.0.0
|
github.com/logrusorgru/aurora/v4 v4.0.0
|
||||||
@@ -74,6 +74,7 @@ require (
|
|||||||
github.com/miekg/dns v1.1.73
|
github.com/miekg/dns v1.1.73
|
||||||
github.com/oiweiwei/go-msrpc v1.2.12
|
github.com/oiweiwei/go-msrpc v1.2.12
|
||||||
github.com/olekukonko/tablewriter v1.0.8
|
github.com/olekukonko/tablewriter v1.0.8
|
||||||
|
github.com/openrdap/rdap v0.9.1
|
||||||
github.com/ory/dockertest/v3 v3.12.0
|
github.com/ory/dockertest/v3 v3.12.0
|
||||||
github.com/pkg/errors v0.9.1
|
github.com/pkg/errors v0.9.1
|
||||||
github.com/praetorian-inc/fingerprintx v1.1.15
|
github.com/praetorian-inc/fingerprintx v1.1.15
|
||||||
@@ -128,14 +129,14 @@ require (
|
|||||||
go.mongodb.org/mongo-driver v1.17.9
|
go.mongodb.org/mongo-driver v1.17.9
|
||||||
go.uber.org/multierr v1.11.0
|
go.uber.org/multierr v1.11.0
|
||||||
golang.org/x/exp v0.0.0-20260527015227-08cc5374adb3
|
golang.org/x/exp v0.0.0-20260527015227-08cc5374adb3
|
||||||
golang.org/x/net v0.59.0
|
golang.org/x/net v0.62.0
|
||||||
golang.org/x/oauth2 v0.36.0
|
golang.org/x/oauth2 v0.36.0
|
||||||
golang.org/x/sync v0.23.0
|
golang.org/x/sync v0.24.0
|
||||||
golang.org/x/sys v0.48.0
|
golang.org/x/sys v0.49.0
|
||||||
golang.org/x/term v0.46.0
|
golang.org/x/term v0.47.0
|
||||||
golang.org/x/text v0.42.0
|
golang.org/x/text v0.43.0
|
||||||
golang.org/x/time v0.15.0
|
golang.org/x/time v0.15.0
|
||||||
golang.org/x/tools v0.49.0
|
golang.org/x/tools v0.51.0
|
||||||
google.golang.org/grpc v1.83.2
|
google.golang.org/grpc v1.83.2
|
||||||
google.golang.org/protobuf v1.36.11
|
google.golang.org/protobuf v1.36.11
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
@@ -175,7 +176,6 @@ require (
|
|||||||
github.com/STARRY-S/zip v0.2.3 // indirect
|
github.com/STARRY-S/zip v0.2.3 // indirect
|
||||||
github.com/VividCortex/ewma v1.2.0 // indirect
|
github.com/VividCortex/ewma v1.2.0 // indirect
|
||||||
github.com/akrylysov/pogreb v0.10.2 // indirect
|
github.com/akrylysov/pogreb v0.10.2 // indirect
|
||||||
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
|
|
||||||
github.com/alecthomas/kingpin/v2 v2.4.0 // indirect
|
github.com/alecthomas/kingpin/v2 v2.4.0 // indirect
|
||||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect
|
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect
|
||||||
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect
|
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect
|
||||||
@@ -237,7 +237,6 @@ require (
|
|||||||
github.com/dimchansky/utfbom v1.1.1 // indirect
|
github.com/dimchansky/utfbom v1.1.1 // indirect
|
||||||
github.com/distribution/reference v0.6.0 // indirect
|
github.com/distribution/reference v0.6.0 // indirect
|
||||||
github.com/djherbis/times v1.6.0 // indirect
|
github.com/djherbis/times v1.6.0 // indirect
|
||||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
|
||||||
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
|
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
|
||||||
github.com/docker/cli v29.2.0+incompatible // indirect
|
github.com/docker/cli v29.2.0+incompatible // indirect
|
||||||
github.com/docker/go-connections v0.7.0 // indirect
|
github.com/docker/go-connections v0.7.0 // indirect
|
||||||
@@ -264,7 +263,7 @@ require (
|
|||||||
github.com/go-fed/httpsig v1.1.0 // indirect
|
github.com/go-fed/httpsig v1.1.0 // indirect
|
||||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||||
github.com/go-git/go-billy/v5 v5.9.0 // indirect
|
github.com/go-git/go-billy/v5 v5.9.0 // indirect
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
github.com/go-jose/go-jose/v4 v4.1.6 // indirect
|
||||||
github.com/go-logr/logr v1.4.4 // indirect
|
github.com/go-logr/logr v1.4.4 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
@@ -367,7 +366,6 @@ require (
|
|||||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||||
github.com/opencontainers/runc v1.3.6 // indirect
|
github.com/opencontainers/runc v1.3.6 // indirect
|
||||||
github.com/openrdap/rdap v0.9.1 // indirect
|
|
||||||
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
|
||||||
github.com/pierrec/lz4/v4 v4.1.23 // indirect
|
github.com/pierrec/lz4/v4 v4.1.23 // indirect
|
||||||
github.com/pjbgf/sha1cd v0.6.0 // indirect
|
github.com/pjbgf/sha1cd v0.6.0 // indirect
|
||||||
@@ -457,7 +455,7 @@ require (
|
|||||||
go4.org v0.0.0-20230225012048-214862532bf5 // indirect
|
go4.org v0.0.0-20230225012048-214862532bf5 // indirect
|
||||||
goftp.io/server/v2 v2.0.1 // indirect
|
goftp.io/server/v2 v2.0.1 // indirect
|
||||||
golang.org/x/arch v0.3.0 // indirect
|
golang.org/x/arch v0.3.0 // indirect
|
||||||
golang.org/x/crypto v0.57.0 // indirect
|
golang.org/x/crypto v0.58.0 // indirect
|
||||||
golang.org/x/mod v0.41.0 // indirect
|
golang.org/x/mod v0.41.0 // indirect
|
||||||
google.golang.org/api v0.264.0 // indirect
|
google.golang.org/api v0.264.0 // indirect
|
||||||
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect
|
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect
|
||||||
|
|||||||
@@ -147,15 +147,13 @@ github.com/akrylysov/pogreb v0.10.2 h1:e6PxmeyEhWyi2AKOBIJzAEi4HkiC+lKyCocRGlnDi
|
|||||||
github.com/akrylysov/pogreb v0.10.2/go.mod h1:pNs6QmpQ1UlTJKDezuRWmaqkgUE2TuU0YTWyqJZ7+lI=
|
github.com/akrylysov/pogreb v0.10.2/go.mod h1:pNs6QmpQ1UlTJKDezuRWmaqkgUE2TuU0YTWyqJZ7+lI=
|
||||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||||
github.com/alecthomas/chroma v0.10.0 h1:7XDcGkCQopCNKjZHfYrNLraA+M7e0fMiJ/Mfikbfjek=
|
github.com/alecthomas/chroma/v2 v2.27.0 h1:FodwmyOBgJULFYmDqibcp9pvfDLWdtPRh9v/r5BXYZs=
|
||||||
github.com/alecthomas/chroma v0.10.0/go.mod h1:jtJATyUxlIORhUOFNA9NZDWGAQ8wpxQQqNSB4rjA/1s=
|
github.com/alecthomas/chroma/v2 v2.27.0/go.mod h1:NjJ3ciIgrqBNeIkWZ4e46nseoLDslxU1LmfCoL+wcY8=
|
||||||
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
|
|
||||||
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
|
|
||||||
github.com/alecthomas/kingpin/v2 v2.3.1/go.mod h1:oYL5vtsvEHZGHxU7DMp32Dvx+qL+ptGn6lWaot2vCNE=
|
github.com/alecthomas/kingpin/v2 v2.3.1/go.mod h1:oYL5vtsvEHZGHxU7DMp32Dvx+qL+ptGn6lWaot2vCNE=
|
||||||
github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY=
|
github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY=
|
||||||
github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE=
|
github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE=
|
||||||
github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg=
|
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||||
github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||||
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM=
|
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM=
|
||||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||||
@@ -172,8 +170,8 @@ github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e
|
|||||||
github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||||
github.com/alitto/pond v1.9.2 h1:9Qb75z/scEZVCoSU+osVmQ0I0JOeLfdTDafrbcJ8CLs=
|
github.com/alitto/pond v1.9.2 h1:9Qb75z/scEZVCoSU+osVmQ0I0JOeLfdTDafrbcJ8CLs=
|
||||||
github.com/alitto/pond v1.9.2/go.mod h1:xQn3P/sHTYcU/1BR3i86IGIrilcrGC2LiS+E2+CJWsI=
|
github.com/alitto/pond v1.9.2/go.mod h1:xQn3P/sHTYcU/1BR3i86IGIrilcrGC2LiS+E2+CJWsI=
|
||||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
github.com/andybalholm/brotli v1.2.7 h1:UgafRPGMam5ySHfRkhthMqOFA6IFoeF3D7mWIDLjG5A=
|
||||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
github.com/andybalholm/brotli v1.2.7/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
|
github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
|
||||||
github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
|
github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
|
||||||
github.com/andygrunwald/go-jira v1.16.1 h1:WoQEar5XoDRAibOgKzTFELlPNlKAtnfWr296R9zdFLA=
|
github.com/andygrunwald/go-jira v1.16.1 h1:WoQEar5XoDRAibOgKzTFELlPNlKAtnfWr296R9zdFLA=
|
||||||
@@ -358,7 +356,6 @@ github.com/ditashi/jsbeautifier-go v0.0.0-20141206144643-2520a8026a9c h1:+Zo5Ca9
|
|||||||
github.com/ditashi/jsbeautifier-go v0.0.0-20141206144643-2520a8026a9c/go.mod h1:HJGU9ULdREjOcVGZVPB5s6zYmHi1RxzT71l2wQyLmnE=
|
github.com/ditashi/jsbeautifier-go v0.0.0-20141206144643-2520a8026a9c/go.mod h1:HJGU9ULdREjOcVGZVPB5s6zYmHi1RxzT71l2wQyLmnE=
|
||||||
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
|
github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c=
|
||||||
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
|
github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0=
|
||||||
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
|
|
||||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
|
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
|
||||||
@@ -455,8 +452,8 @@ github.com/go-git/go-git/v5 v5.19.2/go.mod h1:QqCBE1EFN5ddFmrliLQ3/ntRCUjZU3EJuw
|
|||||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
|
||||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
github.com/go-jose/go-jose/v4 v4.1.6 h1:sNRkftxFJxwsILiQukqp5z83QBSmntVwmgRY7AqBDO4=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
github.com/go-jose/go-jose/v4 v4.1.6/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||||
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||||
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
|
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
|
||||||
@@ -752,8 +749,8 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o
|
|||||||
github.com/kitabisa/go-ci v1.0.3 h1:JmIUIvcercRQc/9x/v02ydCCqU4MadSHaNaOF8T2pGA=
|
github.com/kitabisa/go-ci v1.0.3 h1:JmIUIvcercRQc/9x/v02ydCCqU4MadSHaNaOF8T2pGA=
|
||||||
github.com/kitabisa/go-ci v1.0.3/go.mod h1:e3wBSzaJbcifXrr/Gw2ZBLn44MmeqP5WySwXyHlCK/U=
|
github.com/kitabisa/go-ci v1.0.3/go.mod h1:e3wBSzaJbcifXrr/Gw2ZBLn44MmeqP5WySwXyHlCK/U=
|
||||||
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
||||||
github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw=
|
github.com/klauspost/compress v1.20.2 h1:v5tiZvm1KGLyg5d5oXdXe3lsSPrbEUiKoXGia//I7Qo=
|
||||||
github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
github.com/klauspost/compress v1.20.2/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
|
||||||
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
@@ -1338,8 +1335,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
|||||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
|
||||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||||
@@ -1427,8 +1424,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
|||||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||||
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
|
golang.org/x/net v0.62.0 h1:18ELalfM5+XNRwzM2nYqXsZQ9ByS1uhj2O/U7+5jqRY=
|
||||||
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
golang.org/x/net v0.62.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||||
@@ -1455,8 +1452,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
|||||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
|
||||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
@@ -1521,8 +1518,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
|
||||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
@@ -1535,8 +1532,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
|||||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||||
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
|
||||||
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
|
||||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
@@ -1553,8 +1550,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
|||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
golang.org/x/text v0.43.0 h1:1QivrlhwAsnMOcqgOdl9my7s9OyZl8O5/UGUTJVXoWc=
|
||||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
golang.org/x/text v0.43.0/go.mod h1:hbSIYA/amXcRXTFSZqh8tWHEoqRvvbKPJ2uz/KU+a+s=
|
||||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||||
@@ -1606,8 +1603,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.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
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.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||||
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
|
golang.org/x/tools v0.51.0 h1:k4Xc/1Om9jwkBJBo4NVLMSARBoWtK10mx+W5BnXCeAI=
|
||||||
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
|
golang.org/x/tools v0.51.0/go.mod h1:9eEncMayCV6zRMGhR5eZEC2iBx98qWcF1HZ9Z7wJOoA=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
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-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
|||||||
+29
-5
@@ -4,6 +4,8 @@ import (
|
|||||||
"bufio"
|
"bufio"
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -87,11 +89,7 @@ func NewUploadWriter(ctx context.Context, logger *gologger.Logger, creds *pdcpau
|
|||||||
tmp.Update()
|
tmp.Update()
|
||||||
u.uploadURL = tmp.URL
|
u.uploadURL = tmp.URL
|
||||||
|
|
||||||
// create http client
|
u.client = newUploadClient()
|
||||||
opts := retryablehttp.DefaultOptionsSingle
|
|
||||||
opts.NoAdjustTimeout = true
|
|
||||||
opts.Timeout = time.Duration(3) * time.Minute
|
|
||||||
u.client = retryablehttp.NewClient(opts)
|
|
||||||
|
|
||||||
// create context
|
// create context
|
||||||
ctx, u.cancel = context.WithCancel(ctx)
|
ctx, u.cancel = context.WithCancel(ctx)
|
||||||
@@ -101,6 +99,32 @@ func NewUploadWriter(ctx context.Context, logger *gologger.Logger, creds *pdcpau
|
|||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newUploadClient returns the client for cloud uploads. The upload sends the
|
||||||
|
// API key and the scan results, so the client verifies the server certificate.
|
||||||
|
// The retryablehttp default clients skip verification.
|
||||||
|
func newUploadClient() *retryablehttp.Client {
|
||||||
|
transport := retryablehttp.DefaultReusePooledTransport()
|
||||||
|
// fastdialer DialTLS does not verify certificates, so net/http does the
|
||||||
|
// TLS handshake over the fastdialer connection.
|
||||||
|
transport.DialTLSContext = nil
|
||||||
|
transport.TLSClientConfig = &tls.Config{MinVersion: tls.VersionTLS12}
|
||||||
|
|
||||||
|
opts := retryablehttp.DefaultOptionsSingle
|
||||||
|
opts.NoAdjustTimeout = true
|
||||||
|
opts.Timeout = time.Duration(3) * time.Minute
|
||||||
|
opts.HttpClient = &http.Client{Transport: transport}
|
||||||
|
// a certificate error does not go away on retry
|
||||||
|
defaultRetry := retryablehttp.DefaultRetryPolicy()
|
||||||
|
opts.CheckRetry = func(ctx context.Context, resp *http.Response, err error) (bool, error) {
|
||||||
|
var certErr *tls.CertificateVerificationError
|
||||||
|
if errors.As(err, &certErr) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return defaultRetry(ctx, resp, err)
|
||||||
|
}
|
||||||
|
return retryablehttp.NewClient(opts)
|
||||||
|
}
|
||||||
|
|
||||||
// SetScanID sets the scan id for the upload writer
|
// SetScanID sets the scan id for the upload writer
|
||||||
func (u *UploadWriter) SetScanID(id string) error {
|
func (u *UploadWriter) SetScanID(id string) error {
|
||||||
if !xidRegex.MatchString(id) {
|
if !xidRegex.MatchString(id) {
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package pdcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/projectdiscovery/gologger"
|
||||||
|
pdcpauth "github.com/projectdiscovery/utils/auth/pdcp"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestUploadWriter(t *testing.T, gotKey *atomic.Value) (*UploadWriter, *httptest.Server) {
|
||||||
|
t.Helper()
|
||||||
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotKey.Store(r.Header.Get(pdcpauth.ApiKeyHeaderName))
|
||||||
|
_, _ = w.Write([]byte(`{"id":"abcdefghij0123456789"}`))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
creds := &pdcpauth.PDCPCredentials{APIKey: "test-key", Server: srv.URL}
|
||||||
|
u, err := NewUploadWriter(context.Background(), gologger.DefaultLogger, creds)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(u.Close)
|
||||||
|
return u, srv
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadRejectsUntrustedCertificate(t *testing.T) {
|
||||||
|
var gotKey atomic.Value
|
||||||
|
u, _ := newTestUploadWriter(t, &gotKey)
|
||||||
|
|
||||||
|
err := u.upload([]byte("{}\n"))
|
||||||
|
require.Error(t, err)
|
||||||
|
var unknownAuthority x509.UnknownAuthorityError
|
||||||
|
require.ErrorAs(t, err, &unknownAuthority)
|
||||||
|
require.Nil(t, gotKey.Load(), "API key must not reach a server with an untrusted certificate")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUploadTrustedCertificate(t *testing.T) {
|
||||||
|
var gotKey atomic.Value
|
||||||
|
u, srv := newTestUploadWriter(t, &gotKey)
|
||||||
|
|
||||||
|
pool := x509.NewCertPool()
|
||||||
|
pool.AddCert(srv.Certificate())
|
||||||
|
transport, ok := u.client.HTTPClient.Transport.(*http.Transport)
|
||||||
|
require.True(t, ok)
|
||||||
|
transport.TLSClientConfig = &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}
|
||||||
|
|
||||||
|
require.NoError(t, u.upload([]byte("{}\n")))
|
||||||
|
require.Equal(t, "test-key", gotKey.Load())
|
||||||
|
require.Equal(t, "abcdefghij0123456789", u.scanID)
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import (
|
|||||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/utils/vardump"
|
"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/protocols/headless/engine"
|
||||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting"
|
"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/jsonexporter"
|
||||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/jsonl"
|
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/jsonl"
|
||||||
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/markdown"
|
"github.com/projectdiscovery/nuclei/v3/pkg/reporting/exporters/markdown"
|
||||||
@@ -119,6 +120,14 @@ func ParseOptions(options *types.Options) {
|
|||||||
options.Logger.Debug().Msgf("Store response directory specified, enabling \"store-resp\" flag automatically\n")
|
options.Logger.Debug().Msgf("Store response directory specified, enabling \"store-resp\" flag automatically\n")
|
||||||
options.StoreResponse = true
|
options.StoreResponse = true
|
||||||
}
|
}
|
||||||
|
// Derived before validation: -uncover-query turns uncover on, and -plan
|
||||||
|
// has to reject that before any target lookup starts.
|
||||||
|
if options.UncoverQuery != nil {
|
||||||
|
options.Uncover = true
|
||||||
|
if len(options.UncoverEngine) == 0 {
|
||||||
|
options.UncoverEngine = append(options.UncoverEngine, "shodan")
|
||||||
|
}
|
||||||
|
}
|
||||||
// Validate the options passed by the user and if any
|
// Validate the options passed by the user and if any
|
||||||
// invalid options have been used, exit.
|
// invalid options have been used, exit.
|
||||||
if err := ValidateOptions(options); err != nil {
|
if err := ValidateOptions(options); err != nil {
|
||||||
@@ -138,13 +147,6 @@ func ParseOptions(options *types.Options) {
|
|||||||
_ = os.Setenv("GITHUB_TOKEN", options.GitHubToken)
|
_ = os.Setenv("GITHUB_TOKEN", options.GitHubToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
if options.UncoverQuery != nil {
|
|
||||||
options.Uncover = true
|
|
||||||
if len(options.UncoverEngine) == 0 {
|
|
||||||
options.UncoverEngine = append(options.UncoverEngine, "shodan")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if options.OfflineHTTP {
|
if options.OfflineHTTP {
|
||||||
options.DisableHTTPProbe = true
|
options.DisableHTTPProbe = true
|
||||||
}
|
}
|
||||||
@@ -162,6 +164,9 @@ func ValidateOptions(options *types.Options) error {
|
|||||||
}
|
}
|
||||||
return errors.Wrap(errors.New(strings.Join(errs, ", ")), "validation failed for these fields")
|
return errors.Wrap(errors.New(strings.Join(errs, ", ")), "validation failed for these fields")
|
||||||
}
|
}
|
||||||
|
if err := validatePlanOptions(options); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if options.Verbose && options.Silent {
|
if options.Verbose && options.Silent {
|
||||||
return errors.New("both verbose and silent mode specified")
|
return errors.New("both verbose and silent mode specified")
|
||||||
}
|
}
|
||||||
@@ -367,6 +372,18 @@ 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 options.PDFExport != "" {
|
||||||
if reportingOptions.PDFExporter != nil {
|
if reportingOptions.PDFExporter != nil {
|
||||||
reportingOptions.PDFExporter.File = options.PDFExport
|
reportingOptions.PDFExporter.File = options.PDFExport
|
||||||
@@ -536,3 +553,22 @@ func getBoolEnvValue(key string) bool {
|
|||||||
value := os.Getenv(key)
|
value := os.Getenv(key)
|
||||||
return strings.EqualFold(value, "true")
|
return strings.EqualFold(value, "true")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validatePlanOptions rejects options that change the templates or targets
|
||||||
|
// only once scanning starts, which a plan written beforehand cannot show.
|
||||||
|
func validatePlanOptions(options *types.Options) error {
|
||||||
|
if options.PlanFile == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case options.AutomaticScan:
|
||||||
|
return errors.New("-plan cannot be used with -automatic-scan, which picks templates while scanning")
|
||||||
|
case options.Uncover || len(options.UncoverQuery) > 0:
|
||||||
|
return errors.New("-plan cannot be used with -uncover, which finds targets over the network")
|
||||||
|
case options.PreflightPortScan:
|
||||||
|
return errors.New("-plan cannot be used with -preflight-portscan, which drops targets after scanning their ports")
|
||||||
|
case options.DASTServer || options.DASTProxy:
|
||||||
|
return errors.New("-plan cannot be used with -dast-server or -dast-proxy, which start a server instead of writing a plan")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/loader"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/core"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/templates/types"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
// httpRequestSeconds is the time of one http request the plan costs are
|
||||||
|
// weighted against (see core.PlanEntry.Cost).
|
||||||
|
const httpRequestSeconds = 0.015
|
||||||
|
|
||||||
|
// planSummary is the last line of a plan file, written once every entry is
|
||||||
|
// known.
|
||||||
|
type planSummary struct {
|
||||||
|
Entries int `json:"entries"`
|
||||||
|
// Alternatives are entries that run only when another of their origin
|
||||||
|
// fails, so they are left out of every other total.
|
||||||
|
Alternatives int `json:"alternatives,omitempty"`
|
||||||
|
Requests int `json:"requests"`
|
||||||
|
Cost int `json:"cost"`
|
||||||
|
// EstimatedSeconds is the largest of the time the rate limit allows, the
|
||||||
|
// cost spread over every concurrent slot, and the longest entry, whose
|
||||||
|
// requests run one after another. Hosts slower than the measured request
|
||||||
|
// time make the scan take longer.
|
||||||
|
EstimatedSeconds int `json:"estimated-seconds"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// writePlan writes one JSON line per unit of work the scan would run,
|
||||||
|
// followed by the summary. Targets are listed as given: inputs that the scan
|
||||||
|
// would probe to an http url first are not probed here, since probing sends
|
||||||
|
// requests.
|
||||||
|
func (r *Runner) writePlan(store *loader.Store, engine *core.Engine) error {
|
||||||
|
if !r.options.DisableHTTPProbe && loader.IsHTTPBasedProtocolUsed(store) && r.isInputNonHTTP() {
|
||||||
|
r.Logger.Info().Msgf("Targets without a scheme are planned as given; the scan probes them for http(s) first")
|
||||||
|
}
|
||||||
|
file, err := os.Create(r.options.PlanFile)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = file.Close() }()
|
||||||
|
writer := bufio.NewWriter(file)
|
||||||
|
encoder := json.NewEncoder(writer)
|
||||||
|
|
||||||
|
var summary planSummary
|
||||||
|
var headlessCost, longest int
|
||||||
|
var encodeErr error
|
||||||
|
engine.Plan(slices.Concat(store.Templates(), store.Workflows()), r.inputProvider, r.options.DisableClustering, func(entry core.PlanEntry) {
|
||||||
|
if encodeErr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
encodeErr = encoder.Encode(entry)
|
||||||
|
if entry.AlternativeTo != "" {
|
||||||
|
summary.Alternatives++
|
||||||
|
return
|
||||||
|
}
|
||||||
|
summary.Entries++
|
||||||
|
summary.Requests += entry.Requests
|
||||||
|
summary.Cost += entry.Cost
|
||||||
|
longest = max(longest, entry.Cost)
|
||||||
|
if entry.Protocol == types.HeadlessProtocol.String() {
|
||||||
|
headlessCost += entry.Cost
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if encodeErr != nil {
|
||||||
|
return encodeErr
|
||||||
|
}
|
||||||
|
summary.EstimatedSeconds = r.estimateSeconds(summary.Requests, summary.Cost-headlessCost, headlessCost, longest)
|
||||||
|
if err := encoder.Encode(map[string]planSummary{"summary": summary}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writer.Flush(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.Logger.Info().Msgf("Plan written to %s: %d template runs, up to %d requests, about %ds", r.options.PlanFile, summary.Entries, summary.Requests, summary.EstimatedSeconds)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// estimateSeconds estimates the scan duration. Headless templates run in
|
||||||
|
// their own, smaller pool, so their cost is spread over it separately.
|
||||||
|
func (r *Runner) estimateSeconds(requests, cost, headlessCost, longest int) int {
|
||||||
|
seconds := float64(longest) * httpRequestSeconds
|
||||||
|
if r.options.RateLimit > 0 && r.options.RateLimitDuration > 0 {
|
||||||
|
seconds = max(seconds, float64(requests)/(float64(r.options.RateLimit)/r.options.RateLimitDuration.Seconds()))
|
||||||
|
}
|
||||||
|
if slots := r.options.TemplateThreads * r.options.BulkSize; slots > 0 {
|
||||||
|
seconds = max(seconds, float64(cost)*httpRequestSeconds/float64(slots))
|
||||||
|
}
|
||||||
|
if slots := r.options.HeadlessTemplateThreads * r.options.HeadlessBulkSize; slots > 0 {
|
||||||
|
seconds = max(seconds, float64(headlessCost)*httpRequestSeconds/float64(slots))
|
||||||
|
}
|
||||||
|
return int(seconds + 0.5)
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/projectdiscovery/goflags"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPlanRejectsOptionsThatChangeTargetsLater(t *testing.T) {
|
||||||
|
for name, options := range map[string]*types.Options{
|
||||||
|
"automatic scan": {PlanFile: "p", AutomaticScan: true},
|
||||||
|
"uncover": {PlanFile: "p", Uncover: true},
|
||||||
|
"uncover query": {PlanFile: "p", UncoverQuery: goflags.StringSlice{"example"}},
|
||||||
|
"preflight": {PlanFile: "p", PreflightPortScan: true},
|
||||||
|
"dast server": {PlanFile: "p", DASTServer: true},
|
||||||
|
"dast proxy": {PlanFile: "p", DASTProxy: true},
|
||||||
|
} {
|
||||||
|
require.Error(t, validatePlanOptions(options), name)
|
||||||
|
}
|
||||||
|
require.NoError(t, validatePlanOptions(&types.Options{PlanFile: "p"}))
|
||||||
|
require.NoError(t, validatePlanOptions(&types.Options{Uncover: true}), "only -plan restricts them")
|
||||||
|
require.NoError(t, validatePlanOptions(&types.Options{UncoverQuery: goflags.StringSlice{"example"}}))
|
||||||
|
require.NoError(t, validatePlanOptions(&types.Options{DASTServer: true}))
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package runner
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEstimateSecondsTakesTheSlowestBound(t *testing.T) {
|
||||||
|
r := &Runner{options: &types.Options{
|
||||||
|
RateLimit: 150, RateLimitDuration: time.Second,
|
||||||
|
TemplateThreads: 25, BulkSize: 25,
|
||||||
|
HeadlessTemplateThreads: 10, HeadlessBulkSize: 10,
|
||||||
|
}}
|
||||||
|
|
||||||
|
require.Equal(t, 20, r.estimateSeconds(3000, 3000, 0, 12), "rate limit bound")
|
||||||
|
require.Equal(t, 10, r.estimateSeconds(52, 41, 660, 660), "one long entry runs its requests in sequence")
|
||||||
|
require.Equal(t, 9, r.estimateSeconds(100, 0, 60000, 60), "headless cost spreads over the headless pool")
|
||||||
|
|
||||||
|
r.options.RateLimit = 0
|
||||||
|
require.Equal(t, 0, r.estimateSeconds(3000, 3000, 0, 1), "no rate limit, cost spread over 625 slots")
|
||||||
|
}
|
||||||
@@ -73,6 +73,7 @@ import (
|
|||||||
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
|
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
|
||||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||||
"github.com/projectdiscovery/nuclei/v3/pkg/utils"
|
"github.com/projectdiscovery/nuclei/v3/pkg/utils"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/utils/httpbody"
|
||||||
"github.com/projectdiscovery/nuclei/v3/pkg/utils/stats"
|
"github.com/projectdiscovery/nuclei/v3/pkg/utils/stats"
|
||||||
"github.com/projectdiscovery/nuclei/v3/pkg/utils/yaml"
|
"github.com/projectdiscovery/nuclei/v3/pkg/utils/yaml"
|
||||||
"github.com/projectdiscovery/retryablehttp-go"
|
"github.com/projectdiscovery/retryablehttp-go"
|
||||||
@@ -146,10 +147,18 @@ func New(options *types.Options) (*Runner, error) {
|
|||||||
|
|
||||||
// Version check by default
|
// Version check by default
|
||||||
if config.DefaultConfig.CanCheckForUpdates() {
|
if config.DefaultConfig.CanCheckForUpdates() {
|
||||||
latestIgnoreHash, versionCheckErr := installer.NucleiVersionCheck()
|
// -plan sends no request it can avoid, so it skips the version and
|
||||||
if versionCheckErr != nil {
|
// update checks; it still installs templates on a fresh setup, which
|
||||||
if options.Verbose || options.Debug {
|
// would otherwise plan nothing
|
||||||
runner.Logger.Error().Msgf("nuclei version check failed got: %s\n", versionCheckErr)
|
checkUpdates := options.PlanFile == ""
|
||||||
|
var latestIgnoreHash string
|
||||||
|
if checkUpdates {
|
||||||
|
var versionCheckErr error
|
||||||
|
latestIgnoreHash, versionCheckErr = installer.NucleiVersionCheck()
|
||||||
|
if versionCheckErr != nil {
|
||||||
|
if options.Verbose || options.Debug {
|
||||||
|
runner.Logger.Error().Msgf("nuclei version check failed got: %s\n", versionCheckErr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,17 +179,19 @@ func New(options *types.Options) (*Runner, error) {
|
|||||||
if err := tm.FreshInstallIfNotExists(); err != nil {
|
if err := tm.FreshInstallIfNotExists(); err != nil {
|
||||||
runner.Logger.Warning().Msgf("Failed to install nuclei templates: %s\n", err)
|
runner.Logger.Warning().Msgf("Failed to install nuclei templates: %s\n", err)
|
||||||
}
|
}
|
||||||
if err := tm.UpdateIfOutdated(); err != nil {
|
if checkUpdates {
|
||||||
runner.Logger.Warning().Msgf("Failed to update nuclei templates: %s\n", err)
|
if err := tm.UpdateIfOutdated(); err != nil {
|
||||||
|
runner.Logger.Warning().Msgf("Failed to update nuclei templates: %s\n", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.DefaultConfig.IgnoreFileNeedsUpdate(latestIgnoreHash) {
|
if checkUpdates && config.DefaultConfig.IgnoreFileNeedsUpdate(latestIgnoreHash) {
|
||||||
if err := installer.UpdateIgnoreFile(); err != nil {
|
if err := installer.UpdateIgnoreFile(); err != nil {
|
||||||
runner.Logger.Warning().Msgf("Failed to update .nuclei-ignore file: %s\n", err)
|
runner.Logger.Warning().Msgf("Failed to update .nuclei-ignore file: %s\n", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if options.UpdateTemplates {
|
if checkUpdates && options.UpdateTemplates {
|
||||||
// we automatically check for updates unless explicitly disabled
|
// we automatically check for updates unless explicitly disabled
|
||||||
// this print statement is only to inform the user that there are no updates
|
// this print statement is only to inform the user that there are no updates
|
||||||
noUpdates := !config.DefaultConfig.NeedsTemplateUpdate()
|
noUpdates := !config.DefaultConfig.NeedsTemplateUpdate()
|
||||||
@@ -591,8 +602,13 @@ func (r *Runner) RunEnumeration() error {
|
|||||||
httpclientpool.ResetConnectionStats()
|
httpclientpool.ResetConnectionStats()
|
||||||
|
|
||||||
// If the user has asked for DAST server mode, run the live
|
// If the user has asked for DAST server mode, run the live
|
||||||
// DAST fuzzing server.
|
// DAST fuzzing server. -plan never reaches this with those flags set:
|
||||||
|
// validation rejects the combination, and this check covers a caller
|
||||||
|
// that skipped it.
|
||||||
if r.options.DASTServer || r.options.DASTProxy {
|
if r.options.DASTServer || r.options.DASTProxy {
|
||||||
|
if err := validatePlanOptions(r.options); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
execurOpts := &server.NucleiExecutorOptions{
|
execurOpts := &server.NucleiExecutorOptions{
|
||||||
Options: r.options,
|
Options: r.options,
|
||||||
Output: r.output,
|
Output: r.output,
|
||||||
@@ -676,6 +692,7 @@ func (r *Runner) RunEnumeration() error {
|
|||||||
Catalog: r.catalog,
|
Catalog: r.catalog,
|
||||||
IssuesClient: r.issuesClient,
|
IssuesClient: r.issuesClient,
|
||||||
RateLimiter: r.rateLimiter,
|
RateLimiter: r.rateLimiter,
|
||||||
|
RequestBodyBudget: httpbody.NewBudget(int64(r.options.MaxRequestBodyInFlight)),
|
||||||
Interactsh: r.interactsh,
|
Interactsh: r.interactsh,
|
||||||
ProjectFile: r.projectFile,
|
ProjectFile: r.projectFile,
|
||||||
Browser: r.browser,
|
Browser: r.browser,
|
||||||
@@ -815,6 +832,11 @@ func (r *Runner) RunEnumeration() error {
|
|||||||
profiles.Prepare(slices.Concat(store.Templates(), store.Workflows()))
|
profiles.Prepare(slices.Concat(store.Templates(), store.Workflows()))
|
||||||
executorOpts.TargetScope = profiles
|
executorOpts.TargetScope = profiles
|
||||||
}
|
}
|
||||||
|
// before uncover, preflight, auth prefetch and probing below, all of which
|
||||||
|
// reach the network
|
||||||
|
if r.options.PlanFile != "" {
|
||||||
|
return r.writePlan(store, executorEngine)
|
||||||
|
}
|
||||||
// TODO: remove below functions after v3 or update warning messages
|
// TODO: remove below functions after v3 or update warning messages
|
||||||
templates.PrintDeprecatedProtocolNameMsgIfApplicable(r.options.Silent, r.options.Verbose)
|
templates.PrintDeprecatedProtocolNameMsgIfApplicable(r.options.Silent, r.options.Verbose)
|
||||||
|
|
||||||
|
|||||||
@@ -38,6 +38,35 @@ func TestCreateReportingOptions(t *testing.T) {
|
|||||||
require.Equal(t, resultOptions2.DenyList.Severities, resultOptions.DenyList.Severities)
|
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 {
|
type TestStruct1 struct {
|
||||||
A string `yaml:"a"`
|
A string `yaml:"a"`
|
||||||
Struct *TestStruct2 `yaml:"b"`
|
Struct *TestStruct2 `yaml:"b"`
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/alecthomas/chroma/quick"
|
"github.com/alecthomas/chroma/v2/quick"
|
||||||
"github.com/logrusorgru/aurora/v4"
|
"github.com/logrusorgru/aurora/v4"
|
||||||
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
|
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
|
||||||
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/loader"
|
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/loader"
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
//go:build integration
|
||||||
|
// +build integration
|
||||||
|
|
||||||
|
package integration_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/ory/dockertest/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ipmiSimSetup runs OpenIPMI's BMC simulator with an anonymous user,
|
||||||
|
// ADMIN/ADMIN and root/calvin, and cipher suite 0 enabled.
|
||||||
|
const ipmiSimSetup = `apt-get update -qq && apt-get install -y -qq --no-install-recommends openipmi >/dev/null && mkdir -p /etc/ipmi && cat > /etc/ipmi/lan.conf <<'EOF'
|
||||||
|
name "bmc"
|
||||||
|
set_working_mc 0x20
|
||||||
|
startlan 1
|
||||||
|
addr 0.0.0.0 623
|
||||||
|
priv_limit admin
|
||||||
|
allowed_auths_callback none md2 md5 straight
|
||||||
|
allowed_auths_user none md2 md5 straight
|
||||||
|
allowed_auths_operator none md2 md5 straight
|
||||||
|
allowed_auths_admin none md2 md5 straight
|
||||||
|
guid a123456789abcdefa123456789abcdef
|
||||||
|
endlan
|
||||||
|
user 1 true "" "" user 10 none md2 md5 straight
|
||||||
|
user 2 true "ADMIN" "ADMIN" admin 10 none md2 md5 straight
|
||||||
|
user 3 true "root" "calvin" admin 10 none md2 md5 straight
|
||||||
|
EOF
|
||||||
|
printf 'mc_setbmc 0x20\nmc_add 0x20 0 no-device-sdrs 0x23 9 8 0x9f 0x1291 0xf02 persist_sdr\nmc_enable 0x20\n' > /etc/ipmi/emu.cmd
|
||||||
|
exec ipmi_sim -c /etc/ipmi/lan.conf -f /etc/ipmi/emu.cmd -n`
|
||||||
|
|
||||||
|
type javascriptIPMI struct{}
|
||||||
|
|
||||||
|
func (j *javascriptIPMI) Execute(filePath string) error {
|
||||||
|
pool, err := dockertest.NewPool("")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not create docker pool: %w", err)
|
||||||
|
}
|
||||||
|
if err := pool.Client.Ping(); err != nil {
|
||||||
|
return fmt.Errorf("could not connect to Docker: %w", err)
|
||||||
|
}
|
||||||
|
resource, err := pool.RunWithOptions(&dockertest.RunOptions{
|
||||||
|
Repository: "debian",
|
||||||
|
Tag: "bookworm-slim",
|
||||||
|
Platform: "linux/amd64",
|
||||||
|
Cmd: []string{"sh", "-c", ipmiSimSetup},
|
||||||
|
ExposedPorts: []string{"623/udp"},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not start ipmi simulator: %w", err)
|
||||||
|
}
|
||||||
|
defer purge(pool, resource)
|
||||||
|
|
||||||
|
address := net.JoinHostPort("127.0.0.1", resource.GetPort("623/udp"))
|
||||||
|
if err := waitForServiceCheck(address, 3*time.Minute, rmcpPingCheck); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
results, err := runSignedNucleiTemplateAndGetResults(filePath, address, debug)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return expectResultsCount(results, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// rmcpPingCheck waits for an answer to an RMCP presence ping, since the
|
||||||
|
// port is udp and cannot be probed with a connect.
|
||||||
|
func rmcpPingCheck(address string) error {
|
||||||
|
conn, err := net.Dial("udp", address)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = conn.Close() }()
|
||||||
|
if _, err := conn.Write([]byte{0x06, 0x00, 0xff, 0x06, 0x00, 0x00, 0x11, 0xbe, 0x80, 0x00, 0x00, 0x00}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = conn.SetReadDeadline(time.Now().Add(time.Second))
|
||||||
|
reply := make([]byte, 64)
|
||||||
|
n, err := conn.Read(reply)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if n < 9 || reply[8] != 0x40 {
|
||||||
|
return fmt.Errorf("unexpected rmcp reply")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@ var jsTestcases = []integrationCase{
|
|||||||
{Path: "protocols/javascript/http-get.yaml", TestCase: &javascriptHTTPGet{}},
|
{Path: "protocols/javascript/http-get.yaml", TestCase: &javascriptHTTPGet{}},
|
||||||
{Path: "protocols/javascript/http-client-flow.yaml", TestCase: &javascriptHTTPClientFlow{}},
|
{Path: "protocols/javascript/http-client-flow.yaml", TestCase: &javascriptHTTPClientFlow{}},
|
||||||
{Path: "protocols/javascript/http-denied.yaml", TestCase: &javascriptHTTPDenied{}},
|
{Path: "protocols/javascript/http-denied.yaml", TestCase: &javascriptHTTPDenied{}},
|
||||||
|
{Path: "protocols/javascript/ipmi.yaml", TestCase: &javascriptIPMI{}, DisableOn: javascriptDockerDisabled, Serial: true},
|
||||||
{Path: "protocols/javascript/rsync-test.yaml", TestCase: &javascriptRsyncTest{}, DisableOn: javascriptDockerDisabled, Serial: true},
|
{Path: "protocols/javascript/rsync-test.yaml", TestCase: &javascriptRsyncTest{}, DisableOn: javascriptDockerDisabled, Serial: true},
|
||||||
{Path: "protocols/javascript/vnc-pass-brute.yaml", TestCase: &javascriptVncPassBrute{}, DisableOn: javascriptDockerDisabled, Serial: true},
|
{Path: "protocols/javascript/vnc-pass-brute.yaml", TestCase: &javascriptVncPassBrute{}, DisableOn: javascriptDockerDisabled, Serial: true},
|
||||||
{Path: "protocols/javascript/postgres-pass-brute.yaml", TestCase: &javascriptPostgresPassBrute{}, DisableOn: javascriptDockerDisabled, Serial: true},
|
{Path: "protocols/javascript/postgres-pass-brute.yaml", TestCase: &javascriptPostgresPassBrute{}, DisableOn: javascriptDockerDisabled, Serial: true},
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
id: ipmi-checks
|
||||||
|
|
||||||
|
info:
|
||||||
|
name: IPMI Checks
|
||||||
|
author: pdteam
|
||||||
|
severity: info
|
||||||
|
|
||||||
|
javascript:
|
||||||
|
- code: |
|
||||||
|
const ipmi = require('nuclei/ipmi');
|
||||||
|
const caps = ipmi.GetAuthCapabilities(Host, Port);
|
||||||
|
const root = ipmi.GetRAKPHash(Host, Port, 'root');
|
||||||
|
|
||||||
|
// cipher suite 0 lets a wrong password in
|
||||||
|
const anyPassword = new ipmi.Client();
|
||||||
|
const cipherZero = anyPassword.Login(Host, Port, 'ADMIN', 'wrong', 0);
|
||||||
|
anyPassword.Close();
|
||||||
|
|
||||||
|
// an encrypted, signed session, then Get Device ID and admin privilege
|
||||||
|
const client = new ipmi.Client();
|
||||||
|
client.Login(Host, Port, 'ADMIN', 'ADMIN', 3);
|
||||||
|
const device = client.Command(0x06, 0x01, []);
|
||||||
|
const admin = client.Command(0x06, 0x3b, [0x04]);
|
||||||
|
client.Close();
|
||||||
|
const manufacturer = device.Data[6] | device.Data[7] << 8 | device.Data[8] << 16;
|
||||||
|
|
||||||
|
let wrongRejected = false;
|
||||||
|
try { new ipmi.Client().Login(Host, Port, 'ADMIN', 'wrong', 3); } catch (e) { wrongRejected = true; }
|
||||||
|
|
||||||
|
ipmi.Ping(Host, Port) && caps.IPMI20 && caps.AnonymousLogin &&
|
||||||
|
ipmi.GetCipherSuites(Host, Port).includes(0) &&
|
||||||
|
ipmi.GetSystemGUID(Host, Port).length == 32 &&
|
||||||
|
ipmi.FindRAKPPassword(root, ['admin', 'calvin']) == 'calvin' &&
|
||||||
|
!ipmi.CheckRAKPPassword(root, 'admin') &&
|
||||||
|
cipherZero && wrongRejected &&
|
||||||
|
device.CompletionCode == 0 && manufacturer == 4753 &&
|
||||||
|
admin.CompletionCode == 0;
|
||||||
|
|
||||||
|
args:
|
||||||
|
Host: "{{Host}}"
|
||||||
|
Port: "623"
|
||||||
|
|
||||||
|
matchers:
|
||||||
|
- type: dsl
|
||||||
|
dsl:
|
||||||
|
- "success == true"
|
||||||
+8
-6
@@ -39,12 +39,14 @@ func createEphemeralObjects(ctx context.Context, base *NucleiEngine, opts *types
|
|||||||
Catalog: base.catalog,
|
Catalog: base.catalog,
|
||||||
IssuesClient: base.rc,
|
IssuesClient: base.rc,
|
||||||
RateLimiter: base.rateLimiter,
|
RateLimiter: base.rateLimiter,
|
||||||
Interactsh: base.interactshClient,
|
|
||||||
Colorizer: aurora.New(aurora.WithColors(true)),
|
RequestBodyBudget: base.executerOpts.RequestBodyBudget,
|
||||||
ResumeCfg: types.NewResumeCfg(),
|
Interactsh: base.interactshClient,
|
||||||
Parser: base.parser,
|
Colorizer: aurora.New(aurora.WithColors(true)),
|
||||||
Browser: base.browserInstance,
|
ResumeCfg: types.NewResumeCfg(),
|
||||||
DoNotCache: opts.DoNotCacheTemplates,
|
Parser: base.parser,
|
||||||
|
Browser: base.browserInstance,
|
||||||
|
DoNotCache: opts.DoNotCacheTemplates,
|
||||||
}
|
}
|
||||||
if base.interactshClient != nil {
|
if base.interactshClient != nil {
|
||||||
u.executerOpts.InteractshScope = base.interactshClient.NewRequestScope()
|
u.executerOpts.InteractshScope = base.interactshClient.NewRequestScope()
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import (
|
|||||||
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
|
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
|
||||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||||
nucleiUtils "github.com/projectdiscovery/nuclei/v3/pkg/utils"
|
nucleiUtils "github.com/projectdiscovery/nuclei/v3/pkg/utils"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/utils/httpbody"
|
||||||
"github.com/projectdiscovery/ratelimit"
|
"github.com/projectdiscovery/ratelimit"
|
||||||
pdcpauth "github.com/projectdiscovery/utils/auth/pdcp"
|
pdcpauth "github.com/projectdiscovery/utils/auth/pdcp"
|
||||||
)
|
)
|
||||||
@@ -279,6 +280,7 @@ func (e *NucleiEngine) init(ctx context.Context) error {
|
|||||||
Catalog: e.catalog,
|
Catalog: e.catalog,
|
||||||
IssuesClient: e.rc,
|
IssuesClient: e.rc,
|
||||||
RateLimiter: e.rateLimiter,
|
RateLimiter: e.rateLimiter,
|
||||||
|
RequestBodyBudget: httpbody.NewBudget(int64(e.opts.MaxRequestBodyInFlight)),
|
||||||
Interactsh: e.interactshClient,
|
Interactsh: e.interactshClient,
|
||||||
Colorizer: aurora.New(aurora.WithColors(true)),
|
Colorizer: aurora.New(aurora.WithColors(true)),
|
||||||
ResumeCfg: types.NewResumeCfg(),
|
ResumeCfg: types.NewResumeCfg(),
|
||||||
|
|||||||
@@ -7,18 +7,22 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/alecthomas/chroma/quick"
|
"github.com/alecthomas/chroma/v2/quick"
|
||||||
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
|
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
|
||||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||||
"github.com/projectdiscovery/retryablehttp-go"
|
|
||||||
pdcpauth "github.com/projectdiscovery/utils/auth/pdcp"
|
pdcpauth "github.com/projectdiscovery/utils/auth/pdcp"
|
||||||
"github.com/projectdiscovery/utils/errkit"
|
"github.com/projectdiscovery/utils/errkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
var (
|
||||||
aiTemplateGeneratorAPIEndpoint = "https://api.projectdiscovery.io/v1/template/ai"
|
aiTemplateGeneratorAPIEndpoint = "https://api.projectdiscovery.io/v1/template/ai"
|
||||||
|
// aiTemplateHTTPClient verifies TLS, since the request carries the PDCP API key.
|
||||||
|
aiTemplateHTTPClient = &http.Client{}
|
||||||
|
// aiTemplateIDRegex keeps the template file name inside the pdcp directory.
|
||||||
|
aiTemplateIDRegex = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
|
||||||
)
|
)
|
||||||
|
|
||||||
type AITemplateResponse struct {
|
type AITemplateResponse struct {
|
||||||
@@ -112,7 +116,7 @@ func generateAITemplate(prompt string) (string, string, error) {
|
|||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
req.Header.Set(pdcpauth.ApiKeyHeaderName, creds.APIKey)
|
req.Header.Set(pdcpauth.ApiKeyHeaderName, creds.APIKey)
|
||||||
|
|
||||||
resp, err := retryablehttp.DefaultClient().Do(req)
|
resp, err := aiTemplateHTTPClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", errkit.Newf("Failed to send HTTP request: %v", err)
|
return "", "", errkit.Newf("Failed to send HTTP request: %v", err)
|
||||||
}
|
}
|
||||||
@@ -138,5 +142,9 @@ func generateAITemplate(prompt string) (string, string, error) {
|
|||||||
return "", "", errkit.Newf("Failed to generate template")
|
return "", "", errkit.Newf("Failed to generate template")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !aiTemplateIDRegex.MatchString(result.TemplateID) {
|
||||||
|
return "", "", errkit.Newf("Invalid template ID in API response: %q", result.TemplateID)
|
||||||
|
}
|
||||||
|
|
||||||
return result.Completion, result.TemplateID, nil
|
return result.Completion, result.TemplateID, nil
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package loader
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newAITemplateServer(t *testing.T, templateID string, gotKey *atomic.Value) {
|
||||||
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotKey.Store(r.Header.Get("X-Api-Key"))
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||||
|
"completion": "id: test\n",
|
||||||
|
"template_id": templateID,
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
|
||||||
|
oldEndpoint, oldClient := aiTemplateGeneratorAPIEndpoint, aiTemplateHTTPClient
|
||||||
|
t.Cleanup(func() { aiTemplateGeneratorAPIEndpoint, aiTemplateHTTPClient = oldEndpoint, oldClient })
|
||||||
|
aiTemplateGeneratorAPIEndpoint = srv.URL
|
||||||
|
t.Setenv("PDCP_API_KEY", "test-key")
|
||||||
|
if templateID != "" {
|
||||||
|
// Trust the test certificate, to check the template_id only.
|
||||||
|
aiTemplateHTTPClient = srv.Client()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAITemplateVerifiesTLS(t *testing.T) {
|
||||||
|
var gotKey atomic.Value
|
||||||
|
newAITemplateServer(t, "", &gotKey)
|
||||||
|
|
||||||
|
_, _, err := generateAITemplate("check for exposed admin panels")
|
||||||
|
require.ErrorContains(t, err, "certificate")
|
||||||
|
require.Nil(t, gotKey.Load(), "the API key went to an untrusted server")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAITemplateRejectsUnsafeID(t *testing.T) {
|
||||||
|
for _, id := range []string{"../../.config/nuclei/config", "a/b", `a\b`, "a.b", "a b"} {
|
||||||
|
var gotKey atomic.Value
|
||||||
|
newAITemplateServer(t, id, &gotKey)
|
||||||
|
_, _, err := generateAITemplate("check for exposed admin panels")
|
||||||
|
require.Error(t, err, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
var gotKey atomic.Value
|
||||||
|
newAITemplateServer(t, "Abc-123_x", &gotKey)
|
||||||
|
_, templateID, err := generateAITemplate("check for exposed admin panels")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "Abc-123_x", templateID)
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package loader
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"reflect"
|
"reflect"
|
||||||
@@ -19,6 +20,7 @@ import (
|
|||||||
"github.com/projectdiscovery/nuclei/v3/pkg/loader/workflow"
|
"github.com/projectdiscovery/nuclei/v3/pkg/loader/workflow"
|
||||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols"
|
"github.com/projectdiscovery/nuclei/v3/pkg/protocols"
|
||||||
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
|
"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"
|
||||||
"github.com/projectdiscovery/nuclei/v3/pkg/utils/stats"
|
"github.com/projectdiscovery/nuclei/v3/pkg/utils/stats"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -685,9 +687,135 @@ http:
|
|||||||
loaded := loadSingleTemplateForTest(t, templatePath, "loader-unsigned-flow-template")
|
loaded := loadSingleTemplateForTest(t, templatePath, "loader-unsigned-flow-template")
|
||||||
require.Len(t, loaded, 1)
|
require.Len(t, loaded, 1)
|
||||||
require.Equal(t, "unsigned-flow-template", loaded[0].ID)
|
require.Equal(t, "unsigned-flow-template", loaded[0].ID)
|
||||||
|
require.False(t, loaded[0].Verified)
|
||||||
require.Equal(t, initialUnverifiedJavascript, stats.GetValue(templates.SkippedUnverifiedJavascriptTemplateStats))
|
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) {
|
func TestLoadTemplatesDoesNotRequireGlobalMatchersFlagToLoadTemplate(t *testing.T) {
|
||||||
templatePath := filepath.Join(t.TempDir(), "global-matchers.yaml")
|
templatePath := filepath.Join(t.TempDir(), "global-matchers.yaml")
|
||||||
err := os.WriteFile(templatePath, []byte(`id: global-matchers-template
|
err := os.WriteFile(templatePath, []byte(`id: global-matchers-template
|
||||||
|
|||||||
+15
-13
@@ -38,19 +38,7 @@ func (e *Engine) ExecuteScanWithOpts(ctx context.Context, templatesList []*templ
|
|||||||
|
|
||||||
totalReqBeforeCluster := getRequestCount(templatesList) * int(target.Count())
|
totalReqBeforeCluster := getRequestCount(templatesList) * int(target.Count())
|
||||||
|
|
||||||
// attempt to cluster templates if noCluster is false
|
finalTemplates, clusterCount := e.clusterTemplates(templatesList, noCluster)
|
||||||
var finalTemplates []*templates.Template
|
|
||||||
clusterCount := 0
|
|
||||||
if !noCluster {
|
|
||||||
var clusterMappings map[string][]string
|
|
||||||
finalTemplates, clusterCount, clusterMappings = templates.ClusterTemplates(templatesList, e.executerOpts)
|
|
||||||
// Store cluster mappings in executerOpts for SDK access (thread-safe)
|
|
||||||
if clusterMappings != nil {
|
|
||||||
e.executerOpts.ClusterMappings = types.NewClusterMappingsMap(clusterMappings)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
finalTemplates = templatesList
|
|
||||||
}
|
|
||||||
|
|
||||||
totalReqAfterClustering := getRequestCount(finalTemplates) * int(target.Count())
|
totalReqAfterClustering := getRequestCount(finalTemplates) * int(target.Count())
|
||||||
|
|
||||||
@@ -186,6 +174,20 @@ func (e *Engine) executeHostSpray(ctx context.Context, templatesList []*template
|
|||||||
return results
|
return results
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// clusterTemplates merges templates that send identical requests unless
|
||||||
|
// noCluster is set, and returns the templates to run with the number merged.
|
||||||
|
func (e *Engine) clusterTemplates(templatesList []*templates.Template, noCluster bool) ([]*templates.Template, int) {
|
||||||
|
if noCluster {
|
||||||
|
return templatesList, 0
|
||||||
|
}
|
||||||
|
finalTemplates, clusterCount, clusterMappings := templates.ClusterTemplates(templatesList, e.executerOpts)
|
||||||
|
// Store cluster mappings in executerOpts for SDK access (thread-safe)
|
||||||
|
if clusterMappings != nil {
|
||||||
|
e.executerOpts.ClusterMappings = types.NewClusterMappingsMap(clusterMappings)
|
||||||
|
}
|
||||||
|
return finalTemplates, clusterCount
|
||||||
|
}
|
||||||
|
|
||||||
// expectedWork returns the requests and the tasks the target-bound templates
|
// expectedWork returns the requests and the tasks the target-bound templates
|
||||||
// will run. It counts only the template and target pairs that per-target
|
// will run. It counts only the template and target pairs that per-target
|
||||||
// profiles select, so progress reflects the work that will be done. A task is
|
// profiles select, so progress reflects the work that will be done. A task is
|
||||||
|
|||||||
+15
-6
@@ -382,6 +382,19 @@ func newOriginAttempt() *originAttempt {
|
|||||||
return attempt
|
return attempt
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// originScopeKey returns the key an origin-scoped template runs once per, and
|
||||||
|
// false when the template runs on every target.
|
||||||
|
func originScopeKey(template *templates.Template, value *contextargs.MetaInput) (string, bool) {
|
||||||
|
if template == nil || value == nil || !template.IsOriginScoped() {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
origin := normalize.Origin(value.Input)
|
||||||
|
if origin == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return template.ID + "\x00" + origin + "\x00" + value.CustomIP, true
|
||||||
|
}
|
||||||
|
|
||||||
// claimOriginScoped reports whether this caller should execute an origin-scoped
|
// claimOriginScoped reports whether this caller should execute an origin-scoped
|
||||||
// template. finish(true) records a successful run. finish(false) releases the
|
// template. finish(true) records a successful run. finish(false) releases the
|
||||||
// claim so a later target, including one that waited, can try again. Templates
|
// claim so a later target, including one that waited, can try again. Templates
|
||||||
@@ -389,14 +402,10 @@ func newOriginAttempt() *originAttempt {
|
|||||||
// key so each address selected for one URL is tested on its own.
|
// key so each address selected for one URL is tested on its own.
|
||||||
func (e *Engine) claimOriginScoped(template *templates.Template, value *contextargs.MetaInput) (finish func(bool), run bool) {
|
func (e *Engine) claimOriginScoped(template *templates.Template, value *contextargs.MetaInput) (finish func(bool), run bool) {
|
||||||
noop := func(bool) {}
|
noop := func(bool) {}
|
||||||
if template == nil || value == nil || !template.IsOriginScoped() {
|
key, scoped := originScopeKey(template, value)
|
||||||
|
if !scoped {
|
||||||
return noop, true
|
return noop, true
|
||||||
}
|
}
|
||||||
origin := normalize.Origin(value.Input)
|
|
||||||
if origin == "" {
|
|
||||||
return noop, true
|
|
||||||
}
|
|
||||||
key := template.ID + "\x00" + origin + "\x00" + value.CustomIP
|
|
||||||
actual, _ := e.originScoped.LoadOrStore(key, newOriginAttempt())
|
actual, _ := e.originScoped.LoadOrStore(key, newOriginAttempt())
|
||||||
slot := actual.(*originAttempt)
|
slot := actual.(*originAttempt)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/input/provider"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/protocols"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/templates/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PlanEntry is one unit of work a scan runs: a template, a cluster of
|
||||||
|
// templates sharing one request, or a workflow, on one target.
|
||||||
|
type PlanEntry struct {
|
||||||
|
// ID identifies the entry across runs of the same plan.
|
||||||
|
ID string `json:"id"`
|
||||||
|
// Target is empty for self-contained templates, which run once.
|
||||||
|
Target string `json:"target,omitempty"`
|
||||||
|
// IP is the address the target is pinned to, when it is.
|
||||||
|
IP string `json:"ip,omitempty"`
|
||||||
|
Templates []string `json:"templates"`
|
||||||
|
Protocol string `json:"protocol"`
|
||||||
|
// Requests is what the templates declare, the count -stats starts from.
|
||||||
|
// The scan sends fewer when a request needs a value the target lacks,
|
||||||
|
// such as {{DN}} on an ip.
|
||||||
|
Requests int `json:"requests"`
|
||||||
|
// Cost is Requests weighted by how long one request of the protocol
|
||||||
|
// takes, in http requests.
|
||||||
|
Cost int `json:"cost"`
|
||||||
|
// Dynamic marks entries whose requests depend on responses: workflows
|
||||||
|
// (only the root is counted), flows and fuzzing.
|
||||||
|
Dynamic bool `json:"dynamic,omitempty"`
|
||||||
|
// AlternativeTo is the id of an entry running the same origin-scoped
|
||||||
|
// template on the same origin. Workers race for an origin, so the scan
|
||||||
|
// runs whichever of the group claims it first, and another only when
|
||||||
|
// that run fails. Totals count the group once.
|
||||||
|
AlternativeTo string `json:"alternative-to,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plan reports the work ExecuteScanWithOpts would do with the same arguments,
|
||||||
|
// without sending anything. It applies the same clustering, per-target
|
||||||
|
// profiles and once-per-origin rule, so a pair is listed only if the scan
|
||||||
|
// would run it. Later targets of an origin-scoped template are listed as
|
||||||
|
// alternatives to the first, since the scan retries them when a run fails.
|
||||||
|
func (e *Engine) Plan(templatesList []*templates.Template, target provider.InputProvider, noCluster bool, emit func(PlanEntry)) {
|
||||||
|
finalTemplates, _ := e.clusterTemplates(templatesList, noCluster)
|
||||||
|
// clustering does not keep an order; a plan is diffed and edited, so it does
|
||||||
|
finalTemplates = slices.SortedFunc(slices.Values(finalTemplates), func(a, b *templates.Template) int {
|
||||||
|
return strings.Compare(a.ID, b.ID)
|
||||||
|
})
|
||||||
|
|
||||||
|
var targetBound []*templates.Template
|
||||||
|
for _, template := range finalTemplates {
|
||||||
|
if template.SelfContained {
|
||||||
|
emit(withPlanID(planEntry(template, &contextargs.MetaInput{}, nil)))
|
||||||
|
} else {
|
||||||
|
targetBound = append(targetBound, template)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// entries are emitted as they are planned, so memory does not grow with
|
||||||
|
// the plan; only the first id per origin is kept
|
||||||
|
originIDs := make(map[string]string)
|
||||||
|
target.Iterate(func(value *contextargs.MetaInput) bool {
|
||||||
|
selection := e.selectionFor(value)
|
||||||
|
for _, template := range targetBound {
|
||||||
|
if selection != nil && !template.SelectedBy(selection) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entry := withPlanID(planEntry(template, value, selection))
|
||||||
|
if key, scoped := originScopeKey(template, value); scoped {
|
||||||
|
if id, seen := originIDs[key]; seen {
|
||||||
|
entry.AlternativeTo = id
|
||||||
|
} else {
|
||||||
|
originIDs[key] = entry.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
emit(entry)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func withPlanID(entry PlanEntry) PlanEntry {
|
||||||
|
entry.ID = planEntryID(entry)
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// protocolCost is how many http requests one request of a protocol is worth
|
||||||
|
// in time. Measured per call against a CDN-fronted site, where an http
|
||||||
|
// request took about 15ms: a javascript call adds about 150ms of runtime, a
|
||||||
|
// headless page load with waitload took about 1s. Unlisted protocols cost
|
||||||
|
// about one round trip, like http.
|
||||||
|
var protocolCost = map[types.ProtocolType]int{
|
||||||
|
types.JavascriptProtocol: 10,
|
||||||
|
types.HeadlessProtocol: 60,
|
||||||
|
}
|
||||||
|
|
||||||
|
func planEntry(template *templates.Template, value *contextargs.MetaInput, selection protocols.TemplateSelection) PlanEntry {
|
||||||
|
entry := PlanEntry{
|
||||||
|
Target: value.Input,
|
||||||
|
IP: value.CustomIP,
|
||||||
|
Templates: []string{template.ID},
|
||||||
|
Protocol: template.Type().String(),
|
||||||
|
Requests: template.TotalRequests,
|
||||||
|
Dynamic: template.Flow != "" || template.IsFuzzableRequest(),
|
||||||
|
}
|
||||||
|
weight := func(protocol types.ProtocolType) int { return max(protocolCost[protocol], 1) }
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case template.CompiledWorkflow != nil:
|
||||||
|
// only the root of a workflow is certain to run; subtemplates depend
|
||||||
|
// on what it matches
|
||||||
|
entry.Requests = 0
|
||||||
|
for _, workflow := range template.CompiledWorkflow.Workflows {
|
||||||
|
for _, executer := range workflow.Executers {
|
||||||
|
requests := executer.Executer.Requests()
|
||||||
|
entry.Requests += requests
|
||||||
|
entry.Cost += requests * weight(executer.TemplateType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entry.Dynamic = true
|
||||||
|
case isCluster(template):
|
||||||
|
cluster := template.Executer.(*templates.ClusterExecuter)
|
||||||
|
entry.Templates = slices.Sorted(slices.Values(cluster.TemplateIDs(selection)))
|
||||||
|
// TotalRequests of a cluster counts only its http and dns requests,
|
||||||
|
// so an ssl cluster would show none
|
||||||
|
entry.Requests = cluster.Requests()
|
||||||
|
entry.Cost = entry.Requests * weight(template.Type())
|
||||||
|
case len(template.RequestsQueue) > 0:
|
||||||
|
// each block is weighted by its own protocol, so a template mixing
|
||||||
|
// http with javascript is not priced as all http
|
||||||
|
for _, request := range template.RequestsQueue {
|
||||||
|
entry.Cost += request.Requests() * weight(request.Type())
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
entry.Cost = entry.Requests * weight(template.Type())
|
||||||
|
}
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
|
||||||
|
func isCluster(template *templates.Template) bool {
|
||||||
|
_, ok := template.Executer.(*templates.ClusterExecuter)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// planEntryID derives the id from what the entry runs, so planning the same
|
||||||
|
// scan again yields the same ids.
|
||||||
|
func planEntryID(entry PlanEntry) string {
|
||||||
|
sum := sha256.Sum256([]byte(entry.Target + "\x00" + entry.IP + "\x00" + strings.Join(entry.Templates, "\x00")))
|
||||||
|
return hex.EncodeToString(sum[:8])
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/protocols"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/headless"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/http"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
|
||||||
|
tmpltypes "github.com/projectdiscovery/nuclei/v3/pkg/templates/types"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPlanEntryCostWeightsByProtocol(t *testing.T) {
|
||||||
|
headless := &templates.Template{ID: "h", RequestsHeadless: []*headless.Request{{}}, TotalRequests: 2}
|
||||||
|
require.Equal(t, 120, planEntry(headless, &contextargs.MetaInput{Input: "t"}, nil).Cost)
|
||||||
|
|
||||||
|
plain := &templates.Template{ID: "p", TotalRequests: 2}
|
||||||
|
require.Equal(t, 2, planEntry(plain, &contextargs.MetaInput{Input: "t"}, nil).Cost, "unlisted protocols cost one request each")
|
||||||
|
}
|
||||||
|
|
||||||
|
// countedRequest is a request block with a fixed protocol and count.
|
||||||
|
type countedRequest struct {
|
||||||
|
protocols.Request
|
||||||
|
protocol tmpltypes.ProtocolType
|
||||||
|
requests int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r countedRequest) Type() tmpltypes.ProtocolType { return r.protocol }
|
||||||
|
func (r countedRequest) Requests() int { return r.requests }
|
||||||
|
|
||||||
|
func TestPlanEntryWeightsEachProtocolBlock(t *testing.T) {
|
||||||
|
// one http request and ten javascript calls: Type() reports http for the
|
||||||
|
// whole template, which would price it as 11
|
||||||
|
mixed := &templates.Template{
|
||||||
|
ID: "mixed",
|
||||||
|
RequestsHTTP: []*http.Request{{}},
|
||||||
|
TotalRequests: 11,
|
||||||
|
RequestsQueue: []protocols.Request{
|
||||||
|
countedRequest{protocol: tmpltypes.HTTPProtocol, requests: 1},
|
||||||
|
countedRequest{protocol: tmpltypes.JavascriptProtocol, requests: 10},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
entry := planEntry(mixed, &contextargs.MetaInput{Input: "t"}, nil)
|
||||||
|
require.Equal(t, 11, entry.Requests)
|
||||||
|
require.Equal(t, 101, entry.Cost)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanListsThePairsTheScanRuns(t *testing.T) {
|
||||||
|
options := &types.Options{BulkSize: 2, TemplateThreads: 2, HeadlessBulkSize: 1, HeadlessTemplateThreads: 1}
|
||||||
|
engine := New(options)
|
||||||
|
engine.SetExecuterOptions(&protocols.ExecutorOptions{
|
||||||
|
Logger: engine.Logger,
|
||||||
|
Options: options,
|
||||||
|
ResumeCfg: types.NewResumeCfg(),
|
||||||
|
ProtocolType: tmpltypes.HTTPProtocol,
|
||||||
|
Progress: &progressRecorder{},
|
||||||
|
TargetScope: scopeByInput{
|
||||||
|
"php": {paths: []string{"thinkphp.yaml"}},
|
||||||
|
"none": {},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
runs []string
|
||||||
|
)
|
||||||
|
var templatesList []*templates.Template
|
||||||
|
for _, path := range []string{"thinkphp.yaml", "generic.yaml"} {
|
||||||
|
templatesList = append(templatesList, &templates.Template{
|
||||||
|
ID: path,
|
||||||
|
Path: path,
|
||||||
|
TotalRequests: 3,
|
||||||
|
Executer: &recordingExecuter{path: path, mu: &mu, runs: &runs},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
selfContained := &templates.Template{ID: "self.yaml", Path: "self.yaml", SelfContained: true, TotalRequests: 1}
|
||||||
|
targets := &fakeTargetProvider{values: []*contextargs.MetaInput{{Input: "php"}, {Input: "none"}, {Input: "plain"}}}
|
||||||
|
|
||||||
|
var plan []PlanEntry
|
||||||
|
engine.Plan(append(templatesList, selfContained), targets, true, func(entry PlanEntry) { plan = append(plan, entry) })
|
||||||
|
|
||||||
|
require.Empty(t, plan[0].Target, "a self-contained template runs once, with no target")
|
||||||
|
require.Equal(t, []string{"self.yaml"}, plan[0].Templates)
|
||||||
|
var planned []string
|
||||||
|
ids := make(map[string]struct{})
|
||||||
|
for _, entry := range plan[1:] {
|
||||||
|
require.Equal(t, 3, entry.Requests)
|
||||||
|
planned = append(planned, entry.Templates[0]+"@"+entry.Target)
|
||||||
|
ids[entry.ID] = struct{}{}
|
||||||
|
}
|
||||||
|
require.Len(t, ids, len(plan)-1, "every entry has its own id")
|
||||||
|
|
||||||
|
var again []PlanEntry
|
||||||
|
engine.Plan(append(templatesList, selfContained), targets, true, func(entry PlanEntry) { again = append(again, entry) })
|
||||||
|
require.Equal(t, plan, again, "planning the same scan again yields the same entries and ids")
|
||||||
|
|
||||||
|
engine.ExecuteScanWithOpts(context.Background(), templatesList, targets, true)
|
||||||
|
require.ElementsMatch(t, runs, planned, "the plan lists exactly the pairs the scan runs")
|
||||||
|
require.ElementsMatch(t, []string{"thinkphp.yaml@php", "thinkphp.yaml@plain", "generic.yaml@plain"}, planned)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlanListsOriginAlternatives(t *testing.T) {
|
||||||
|
options := &types.Options{BulkSize: 1, TemplateThreads: 1, HeadlessBulkSize: 1, HeadlessTemplateThreads: 1}
|
||||||
|
engine := New(options)
|
||||||
|
engine.SetExecuterOptions(&protocols.ExecutorOptions{
|
||||||
|
Logger: engine.Logger,
|
||||||
|
Options: options,
|
||||||
|
ResumeCfg: types.NewResumeCfg(),
|
||||||
|
ProtocolType: tmpltypes.HTTPProtocol,
|
||||||
|
Progress: &progressRecorder{},
|
||||||
|
})
|
||||||
|
|
||||||
|
root := &templates.Template{
|
||||||
|
ID: "root.yaml",
|
||||||
|
RequestsHTTP: []*http.Request{{Path: []string{"{{RootURL}}/health"}}},
|
||||||
|
TotalRequests: 1,
|
||||||
|
}
|
||||||
|
require.True(t, root.IsOriginScoped())
|
||||||
|
generic := &templates.Template{ID: "generic.yaml", TotalRequests: 1}
|
||||||
|
targets := &fakeTargetProvider{values: []*contextargs.MetaInput{
|
||||||
|
{Input: "https://example.com/a"},
|
||||||
|
{Input: "https://example.com/b"},
|
||||||
|
{Input: "https://other.example/a"},
|
||||||
|
{Input: "https://example.com/a", CustomIP: "203.0.113.5"},
|
||||||
|
}}
|
||||||
|
|
||||||
|
var plan []PlanEntry
|
||||||
|
engine.Plan([]*templates.Template{generic, root}, targets, true, func(entry PlanEntry) {
|
||||||
|
plan = append(plan, entry)
|
||||||
|
})
|
||||||
|
|
||||||
|
var roots []PlanEntry
|
||||||
|
for _, entry := range plan {
|
||||||
|
if entry.Templates[0] == "root.yaml" {
|
||||||
|
roots = append(roots, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.Equal(t, []PlanEntry{
|
||||||
|
{ID: roots[0].ID, Target: "https://example.com/a", Templates: []string{"root.yaml"}, Protocol: "http", Requests: 1, Cost: 1},
|
||||||
|
{ID: roots[1].ID, Target: "https://example.com/b", Templates: []string{"root.yaml"}, Protocol: "http", Requests: 1, Cost: 1, AlternativeTo: roots[0].ID},
|
||||||
|
{ID: roots[2].ID, Target: "https://other.example/a", Templates: []string{"root.yaml"}, Protocol: "http", Requests: 1, Cost: 1},
|
||||||
|
{ID: roots[3].ID, Target: "https://example.com/a", IP: "203.0.113.5", Templates: []string{"root.yaml"}, Protocol: "http", Requests: 1, Cost: 1},
|
||||||
|
}, roots)
|
||||||
|
require.NotEqual(t, roots[0].ID, roots[3].ID, "a pinned address is its own attempt")
|
||||||
|
|
||||||
|
var again []PlanEntry
|
||||||
|
engine.Plan([]*templates.Template{generic, root}, targets, true, func(entry PlanEntry) {
|
||||||
|
again = append(again, entry)
|
||||||
|
})
|
||||||
|
require.Equal(t, plan, again)
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package core
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http/cookiejar"
|
"net/http/cookiejar"
|
||||||
|
"sync"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
"github.com/projectdiscovery/gologger"
|
"github.com/projectdiscovery/gologger"
|
||||||
@@ -32,22 +33,45 @@ func (e *Engine) executeWorkflow(ctx *scan.ScanContext, w *workflows.Workflow) b
|
|||||||
templateThreads++
|
templateThreads++
|
||||||
}
|
}
|
||||||
swg, _ := syncutil.New(syncutil.WithSize(templateThreads))
|
swg, _ := syncutil.New(syncutil.WithSize(templateThreads))
|
||||||
|
// pending tracks every started step until it returns. swg only limits
|
||||||
|
// concurrency and its slots can be released early, so completion is
|
||||||
|
// tracked here instead.
|
||||||
|
var pending sync.WaitGroup
|
||||||
|
|
||||||
for _, template := range w.Workflows {
|
for _, template := range w.Workflows {
|
||||||
newCtx := scan.NewScanContext(ctx.Context(), ctx.Input.Clone())
|
newCtx := scan.NewScanContext(ctx.Context(), ctx.Input.Clone())
|
||||||
if err := e.runWorkflowStep(template, newCtx, results, swg, w); err != nil {
|
// Root steps do not own a waitgroup slot.
|
||||||
|
if err := e.runWorkflowStep(template, newCtx, results, swg, &pending, w, nil); err != nil {
|
||||||
gologger.Warning().Msgf(workflowStepExecutionError, template.Template, err)
|
gologger.Warning().Msgf(workflowStepExecutionError, template.Template, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
swg.Wait()
|
pending.Wait()
|
||||||
|
|
||||||
return results.Load()
|
return results.Load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// slotRelease returns a func that gives back one swg slot. It is safe to call
|
||||||
|
// from several goroutines and several times; only the first call releases.
|
||||||
|
func slotRelease(swg *syncutil.AdaptiveWaitGroup) func() {
|
||||||
|
var once sync.Once
|
||||||
|
return func() {
|
||||||
|
once.Do(swg.Done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// runWorkflowStep runs a workflow step for the workflow. It executes the workflow
|
// runWorkflowStep runs a workflow step for the workflow. It executes the workflow
|
||||||
// in a recursive manner running all subtemplates and matchers.
|
// in a recursive manner running all subtemplates and matchers.
|
||||||
func (e *Engine) runWorkflowStep(template *workflows.WorkflowTemplate, ctx *scan.ScanContext, results *atomic.Bool, swg *syncutil.AdaptiveWaitGroup, w *workflows.Workflow) error {
|
//
|
||||||
|
// releaseSlot, when non-nil, releases the AdaptiveWaitGroup slot held by the
|
||||||
|
// goroutine that is running this step. It must be called before this step
|
||||||
|
// blocks on swg.AddWithContext for child steps; otherwise every in-flight
|
||||||
|
// parent can hold a slot while waiting for another, which deadlocks when the
|
||||||
|
// number of matching nested steps reaches TemplateThreads (see #8074).
|
||||||
|
//
|
||||||
|
// pending is incremented for every child step before it starts, and each child
|
||||||
|
// calls Done when it returns, so the root can wait for the whole tree.
|
||||||
|
func (e *Engine) runWorkflowStep(template *workflows.WorkflowTemplate, ctx *scan.ScanContext, results *atomic.Bool, swg *syncutil.AdaptiveWaitGroup, pending *sync.WaitGroup, w *workflows.Workflow, releaseSlot func()) error {
|
||||||
var firstMatched bool
|
var firstMatched bool
|
||||||
var err error
|
var err error
|
||||||
var mainErr error
|
var mainErr error
|
||||||
@@ -131,18 +155,25 @@ func (e *Engine) runWorkflowStep(template *workflows.WorkflowTemplate, ctx *scan
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Free this step's slot before blocking on child acquires.
|
||||||
|
if releaseSlot != nil {
|
||||||
|
releaseSlot()
|
||||||
|
}
|
||||||
for _, subtemplate := range matcher.Subtemplates {
|
for _, subtemplate := range matcher.Subtemplates {
|
||||||
if err := swg.AddWithContext(ctx.Context()); err != nil {
|
if err := swg.AddWithContext(ctx.Context()); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
pending.Add(1)
|
||||||
|
|
||||||
go func(subtemplate *workflows.WorkflowTemplate) {
|
go func(subtemplate *workflows.WorkflowTemplate) {
|
||||||
defer swg.Done()
|
defer pending.Done()
|
||||||
|
release := slotRelease(swg)
|
||||||
|
defer release()
|
||||||
|
|
||||||
// create a new context with the same input but with unset callbacks
|
// create a new context with the same input but with unset callbacks
|
||||||
// clone the Input so that other parallel executions won't overwrite the shared variables when subsequent templates are running
|
// clone the Input so that other parallel executions won't overwrite the shared variables when subsequent templates are running
|
||||||
subCtx := scan.NewScanContext(ctx.Context(), ctx.Input.Clone())
|
subCtx := scan.NewScanContext(ctx.Context(), ctx.Input.Clone())
|
||||||
if err := e.runWorkflowStep(subtemplate, subCtx, results, swg, w); err != nil {
|
if err := e.runWorkflowStep(subtemplate, subCtx, results, swg, pending, w, release); err != nil {
|
||||||
gologger.Warning().Msgf(workflowStepExecutionError, subtemplate.Template, err)
|
gologger.Warning().Msgf(workflowStepExecutionError, subtemplate.Template, err)
|
||||||
}
|
}
|
||||||
}(subtemplate)
|
}(subtemplate)
|
||||||
@@ -162,18 +193,27 @@ func (e *Engine) runWorkflowStep(template *workflows.WorkflowTemplate, ctx *scan
|
|||||||
return mainErr
|
return mainErr
|
||||||
}
|
}
|
||||||
if len(template.Subtemplates) > 0 && firstMatched {
|
if len(template.Subtemplates) > 0 && firstMatched {
|
||||||
|
// Free this step's slot before blocking on child acquires so nested
|
||||||
|
// matching steps cannot exhaust TemplateThreads and deadlock (#8074).
|
||||||
|
if releaseSlot != nil {
|
||||||
|
releaseSlot()
|
||||||
|
}
|
||||||
for _, subtemplate := range template.Subtemplates {
|
for _, subtemplate := range template.Subtemplates {
|
||||||
if err := swg.AddWithContext(ctx.Context()); err != nil {
|
if err := swg.AddWithContext(ctx.Context()); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
pending.Add(1)
|
||||||
|
|
||||||
go func(template *workflows.WorkflowTemplate) {
|
go func(template *workflows.WorkflowTemplate) {
|
||||||
|
defer pending.Done()
|
||||||
|
release := slotRelease(swg)
|
||||||
|
defer release()
|
||||||
|
|
||||||
// create a new context with the same input but with unset callbacks
|
// create a new context with the same input but with unset callbacks
|
||||||
subCtx := scan.NewScanContext(ctx.Context(), ctx.Input.Clone())
|
subCtx := scan.NewScanContext(ctx.Context(), ctx.Input.Clone())
|
||||||
if err := e.runWorkflowStep(template, subCtx, results, swg, w); err != nil {
|
if err := e.runWorkflowStep(template, subCtx, results, swg, pending, w, release); err != nil {
|
||||||
gologger.Warning().Msgf(workflowStepExecutionError, template.Template, err)
|
gologger.Warning().Msgf(workflowStepExecutionError, template.Template, err)
|
||||||
}
|
}
|
||||||
swg.Done()
|
|
||||||
}(subtemplate)
|
}(subtemplate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -303,6 +303,89 @@ func TestWorkflowsSameStepExecutersRefreshTemplateContext(t *testing.T) {
|
|||||||
require.Equal(t, "foo", secondWhoami, "could not refresh workflow context into later executers in the same step")
|
require.Equal(t, "foo", secondWhoami, "could not refresh workflow context into later executers in the same step")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWorkflowNestedSubtemplatesDoNotDeadlockAtConcurrencyLimit(t *testing.T) {
|
||||||
|
// Repro for #8074: with TemplateThreads equal to the number of matching
|
||||||
|
// nested parents, each parent used to hold a waitgroup slot while calling
|
||||||
|
// AddWithContext for its children, so every slot was occupied by a parent
|
||||||
|
// waiting for another slot and the workflow never finished.
|
||||||
|
progressBar, _ := progress.NewStatsTicker(0, false, false, false, 0)
|
||||||
|
matchOutputs := []*output.InternalWrappedEvent{{
|
||||||
|
OperatorsResult: &operators.Result{},
|
||||||
|
Results: []*output.ResultEvent{{}},
|
||||||
|
}}
|
||||||
|
|
||||||
|
const parents = 3
|
||||||
|
var entered atomic.Int32
|
||||||
|
allHolding := make(chan struct{})
|
||||||
|
releaseParents := make(chan struct{})
|
||||||
|
leafRuns := &atomic.Int32{}
|
||||||
|
|
||||||
|
leaf := &mockExecuter{result: true, executeScanHook: func(ctx *scan.ScanContext) {
|
||||||
|
leafRuns.Add(1)
|
||||||
|
}}
|
||||||
|
parentExec := &mockExecuter{
|
||||||
|
result: true,
|
||||||
|
outputs: matchOutputs,
|
||||||
|
executeScanHook: func(ctx *scan.ScanContext) {
|
||||||
|
if entered.Add(1) == parents {
|
||||||
|
close(allHolding)
|
||||||
|
}
|
||||||
|
// Stay inside ExecuteWithResults so the parent still owns its
|
||||||
|
// waitgroup slot while every peer also holds one.
|
||||||
|
<-releaseParents
|
||||||
|
},
|
||||||
|
}
|
||||||
|
mkParent := func() *workflows.WorkflowTemplate {
|
||||||
|
return &workflows.WorkflowTemplate{
|
||||||
|
Executers: []*workflows.ProtocolExecuterPair{{
|
||||||
|
Executer: parentExec,
|
||||||
|
Options: &protocols.ExecutorOptions{Progress: progressBar},
|
||||||
|
}},
|
||||||
|
Subtemplates: []*workflows.WorkflowTemplate{{
|
||||||
|
Executers: []*workflows.ProtocolExecuterPair{{
|
||||||
|
Executer: leaf,
|
||||||
|
Options: &protocols.ExecutorOptions{Progress: progressBar},
|
||||||
|
}},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
workflow := &workflows.Workflow{
|
||||||
|
Options: &protocols.ExecutorOptions{Options: &types.Options{TemplateThreads: parents}},
|
||||||
|
Workflows: []*workflows.WorkflowTemplate{{
|
||||||
|
Executers: []*workflows.ProtocolExecuterPair{{
|
||||||
|
Executer: &mockExecuter{result: true, outputs: matchOutputs},
|
||||||
|
Options: &protocols.ExecutorOptions{Progress: progressBar},
|
||||||
|
}},
|
||||||
|
Subtemplates: []*workflows.WorkflowTemplate{mkParent(), mkParent(), mkParent()},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
engine := &Engine{}
|
||||||
|
input := contextargs.NewWithInput(context.Background(), "https://test.com")
|
||||||
|
ctx := scan.NewScanContext(context.Background(), input)
|
||||||
|
|
||||||
|
done := make(chan bool, 1)
|
||||||
|
go func() {
|
||||||
|
done <- engine.executeWorkflow(ctx, workflow)
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-allHolding:
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
t.Fatal("nested parents did not all acquire TemplateThreads slots")
|
||||||
|
}
|
||||||
|
close(releaseParents)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case matched := <-done:
|
||||||
|
require.True(t, matched, "nested workflow should report a match")
|
||||||
|
require.EqualValues(t, parents, leafRuns.Load(), "each nested parent should run its leaf")
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("nested workflow deadlocked at TemplateThreads == matching nested parents")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestWorkflowSubtemplatesUnblockCancelledAcquire(t *testing.T) {
|
func TestWorkflowSubtemplatesUnblockCancelledAcquire(t *testing.T) {
|
||||||
progressBar, _ := progress.NewStatsTicker(0, false, false, false, 0)
|
progressBar, _ := progress.NewStatsTicker(0, false, false, false, 0)
|
||||||
var started atomic.Int32
|
var started atomic.Int32
|
||||||
|
|||||||
+14
-18
@@ -155,23 +155,14 @@ func (rule *Rule) Execute(input *ExecuteRuleInput) (err error) {
|
|||||||
baseInteractURLs := append([]string(nil), input.InteractURLs...)
|
baseInteractURLs := append([]string(nil), input.InteractURLs...)
|
||||||
if rule.generator == nil {
|
if rule.generator == nil {
|
||||||
for _, component := range finalComponentList {
|
for _, component := range finalComponentList {
|
||||||
var dataKeys map[string]struct{}
|
|
||||||
|
|
||||||
optionVars := rule.options.Options.Vars.AsMap()
|
optionVars := rule.options.Options.Vars.AsMap()
|
||||||
constants := rule.options.Constants
|
constants := rule.options.Constants
|
||||||
dataValues := rule.options.NewVariablesScope(baseValues, optionVars, constants)
|
dataValues := rule.options.NewVariablesScope(baseValues, optionVars, constants)
|
||||||
// get vars from variables while replacing interactsh urls
|
// Render variables once, allocating their interactsh URLs. The
|
||||||
|
// output may contain runtime data, so it is never rendered again.
|
||||||
evaluation := rule.options.Variables.EvaluateWithInteractshScope(dataValues, rule.options.Interactsh)
|
evaluation := rule.options.Variables.EvaluateWithInteractshScope(dataValues, rule.options.Interactsh)
|
||||||
evaluatedValues, interactURLs := evaluation.Values, evaluation.InteractURLs
|
input.Values = generators.MergeMaps(evaluation.Values, baseValues, optionVars, constants)
|
||||||
input.Values, dataKeys = mergeFuzzValueLayers(
|
input.InteractURLs = mergeInteractURLs(baseInteractURLs, evaluation.InteractURLs)
|
||||||
fuzzValueLayer{values: evaluatedValues, kind: fuzzValueTemplate},
|
|
||||||
fuzzValueLayer{values: baseValues, kind: fuzzValueData},
|
|
||||||
fuzzValueLayer{values: optionVars, kind: fuzzValueData},
|
|
||||||
fuzzValueLayer{values: constants, kind: fuzzValueData},
|
|
||||||
)
|
|
||||||
// Replace template-text interactsh markers without evaluating runtime values.
|
|
||||||
input.Values, interactURLs = rule.evaluateVarsWithInteractsh(input.Values, interactURLs, dataKeys)
|
|
||||||
input.InteractURLs = mergeInteractURLs(baseInteractURLs, interactURLs)
|
|
||||||
|
|
||||||
err := rule.executeRuleValues(input, component)
|
err := rule.executeRuleValues(input, component)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -240,15 +231,17 @@ func (rule *Rule) prepareGeneratorValues(values, baseValues map[string]interface
|
|||||||
evaluation := rule.options.Variables.EvaluateWithInteractshScope(evaluationValues, rule.options.Interactsh)
|
evaluation := rule.options.Variables.EvaluateWithInteractshScope(evaluationValues, rule.options.Interactsh)
|
||||||
evaluatedValues, interactURLs := evaluation.Values, evaluation.InteractURLs
|
evaluatedValues, interactURLs := evaluation.Values, evaluation.InteractURLs
|
||||||
inputValues, dataKeys := mergeFuzzValueLayers(
|
inputValues, dataKeys := mergeFuzzValueLayers(
|
||||||
|
fuzzValueLayer{values: evaluatedValues, kind: fuzzValueData},
|
||||||
fuzzValueLayer{values: values, kind: fuzzValueTemplate},
|
fuzzValueLayer{values: values, kind: fuzzValueTemplate},
|
||||||
fuzzValueLayer{values: evaluatedValues, kind: fuzzValueTemplate},
|
|
||||||
fuzzValueLayer{values: baseValues, kind: fuzzValueData},
|
fuzzValueLayer{values: baseValues, kind: fuzzValueData},
|
||||||
fuzzValueLayer{values: optionVars, kind: fuzzValueData},
|
fuzzValueLayer{values: optionVars, kind: fuzzValueData},
|
||||||
fuzzValueLayer{values: constants, kind: fuzzValueData},
|
fuzzValueLayer{values: constants, kind: fuzzValueData},
|
||||||
)
|
)
|
||||||
|
|
||||||
// Generator values are template-authored payload definitions until this
|
// Generator values are template-authored payload definitions until this
|
||||||
// render. Runtime/base, CLI option, and constant values remain data.
|
// render. Rendered variables, runtime/base, CLI option, and constant
|
||||||
|
// values remain data. Payloads layer over variables so a payload that
|
||||||
|
// shares a variable's name is still rendered.
|
||||||
return rule.evaluateVarsWithInteractsh(inputValues, interactURLs, dataKeys)
|
return rule.evaluateVarsWithInteractsh(inputValues, interactURLs, dataKeys)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -326,7 +319,7 @@ func (rule *Rule) evaluateVars(input string) (string, error) {
|
|||||||
func (rule *Rule) evaluateVarsWithInteractsh(data map[string]interface{}, interactshUrls []string, dataKeys map[string]struct{}) (map[string]interface{}, []string) {
|
func (rule *Rule) evaluateVarsWithInteractsh(data map[string]interface{}, interactshUrls []string, dataKeys map[string]struct{}) (map[string]interface{}, []string) {
|
||||||
// Check if Interactsh options are configured
|
// Check if Interactsh options are configured
|
||||||
if rule.options.Interactsh != nil {
|
if rule.options.Interactsh != nil {
|
||||||
data = maps.Clone(data)
|
rendered := maps.Clone(data)
|
||||||
|
|
||||||
interactshUrlsMap := make(map[string]struct{})
|
interactshUrlsMap := make(map[string]struct{})
|
||||||
for _, url := range interactshUrls {
|
for _, url := range interactshUrls {
|
||||||
@@ -335,7 +328,8 @@ func (rule *Rule) evaluateVarsWithInteractsh(data map[string]interface{}, intera
|
|||||||
|
|
||||||
interactshUrls = mapsutil.GetKeys(interactshUrlsMap)
|
interactshUrls = mapsutil.GetKeys(interactshUrlsMap)
|
||||||
|
|
||||||
// Iterate through template-text data to replace Interactsh URL markers.
|
// Render each template-text value against the unrendered input, so
|
||||||
|
// the result does not depend on map iteration order.
|
||||||
for k, v := range data {
|
for k, v := range data {
|
||||||
if _, ok := dataKeys[k]; ok {
|
if _, ok := dataKeys[k]; ok {
|
||||||
continue
|
continue
|
||||||
@@ -349,7 +343,7 @@ func (rule *Rule) evaluateVarsWithInteractsh(data map[string]interface{}, intera
|
|||||||
InteractURLs: interactshUrls,
|
InteractURLs: interactshUrls,
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
data[k] = got.Text
|
rendered[k] = got.Text
|
||||||
for _, url := range got.InteractURLs {
|
for _, url := range got.InteractURLs {
|
||||||
if _, ok := interactshUrlsMap[url]; ok {
|
if _, ok := interactshUrlsMap[url]; ok {
|
||||||
continue
|
continue
|
||||||
@@ -360,6 +354,8 @@ func (rule *Rule) evaluateVarsWithInteractsh(data map[string]interface{}, intera
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data = rendered
|
||||||
}
|
}
|
||||||
|
|
||||||
return data, interactshUrls
|
return data, interactshUrls
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
package fuzz
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/disk"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz/component"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/protocols"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/generators"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/interactsh"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/variables"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||||
|
"github.com/projectdiscovery/retryablehttp-go"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// requestBodyWithDSL is request data that looks like a DSL expression.
|
||||||
|
requestBodyWithDSL = `PLAIN-{{to_upper("abc")}}-END`
|
||||||
|
queryRuleTarget = "http://127.0.0.1/echo?p=SEED"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeInteractshClient hands out numbered URLs without contacting a server.
|
||||||
|
// Fuzz rules only allocate URLs through NewURLWithData, so the embedded nil
|
||||||
|
// IClient is never called.
|
||||||
|
type fakeInteractshClient struct {
|
||||||
|
interactsh.IClient
|
||||||
|
|
||||||
|
issued int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *fakeInteractshClient) NewURLWithData(string) (string, error) {
|
||||||
|
c.issued++
|
||||||
|
|
||||||
|
return fmt.Sprintf("oast%d.example", c.issued), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newQueryRule compiles a single-mode rule that replaces the p query value
|
||||||
|
// with fuzzValue.
|
||||||
|
func newQueryRule(
|
||||||
|
t *testing.T,
|
||||||
|
fuzzValue string,
|
||||||
|
generator *generators.PayloadGenerator,
|
||||||
|
templateVars variables.Variable,
|
||||||
|
client interactsh.IClient,
|
||||||
|
) *Rule {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
rule := &Rule{
|
||||||
|
Part: component.RequestQueryComponent,
|
||||||
|
Type: "replace",
|
||||||
|
Mode: "single",
|
||||||
|
Fuzz: SliceOrMapSlice{Value: []string{fuzzValue}},
|
||||||
|
}
|
||||||
|
err := rule.Compile(generator, &protocols.ExecutorOptions{
|
||||||
|
Variables: templateVars,
|
||||||
|
Options: &types.Options{},
|
||||||
|
Interactsh: client,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return rule
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeQueryRule runs rule against queryRuleTarget and returns the generated
|
||||||
|
// requests.
|
||||||
|
func executeQueryRule(
|
||||||
|
t *testing.T,
|
||||||
|
rule *Rule,
|
||||||
|
method string,
|
||||||
|
body io.Reader,
|
||||||
|
values map[string]any,
|
||||||
|
) []GeneratedRequest {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
req, err := retryablehttp.NewRequest(method, queryRuleTarget, body)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var got []GeneratedRequest
|
||||||
|
err = rule.Execute(&ExecuteRuleInput{
|
||||||
|
Input: contextargs.NewWithInput(context.Background(), queryRuleTarget),
|
||||||
|
BaseRequest: req,
|
||||||
|
Values: values,
|
||||||
|
Callback: func(gr GeneratedRequest) bool {
|
||||||
|
got = append(got, gr)
|
||||||
|
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
|
||||||
|
// newRequestDataVariableRule returns a query rule whose template declares
|
||||||
|
// b: "{{body}}", so b captures request data.
|
||||||
|
func newRequestDataVariableRule(t *testing.T, fuzzValue string, payloads map[string]any) *Rule {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
templateVars := newFuzzTestVariables()
|
||||||
|
templateVars.Set("b", "{{body}}")
|
||||||
|
|
||||||
|
var generator *generators.PayloadGenerator
|
||||||
|
|
||||||
|
if payloads != nil {
|
||||||
|
var err error
|
||||||
|
|
||||||
|
generator, err = generators.New(
|
||||||
|
payloads, generators.BatteringRamAttack, "", disk.NewCatalog(""), "", types.DefaultOptions(),
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The CLI creates an Interactsh client even with -no-interactsh.
|
||||||
|
return newQueryRule(t, fuzzValue, generator, templateVars, &interactsh.Client{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// fuzzedQueryValues runs rule against a POST request whose body is
|
||||||
|
// requestBodyWithDSL and returns the fuzzed p query value of each generated
|
||||||
|
// request.
|
||||||
|
func fuzzedQueryValues(t *testing.T, rule *Rule) []string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
generated := executeQueryRule(t, rule, http.MethodPost, strings.NewReader(requestBodyWithDSL), map[string]any{
|
||||||
|
"body": requestBodyWithDSL,
|
||||||
|
})
|
||||||
|
|
||||||
|
got := make([]string, 0, len(generated))
|
||||||
|
for _, gr := range generated {
|
||||||
|
got = append(got, gr.Request.URL.Query().Get("p"))
|
||||||
|
}
|
||||||
|
|
||||||
|
return got
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteKeepsRequestDataInTemplateVariableLiteral(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
t.Run("fuzz value", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rule := newRequestDataVariableRule(t, "{{b}}", nil)
|
||||||
|
|
||||||
|
require.Equal(t, []string{requestBodyWithDSL}, fuzzedQueryValues(t, rule))
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("payload value", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rule := newRequestDataVariableRule(t, "{{inj}}", map[string]any{
|
||||||
|
"inj": []any{"{{b}}"},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Each run iterates a fresh value map. Repeat so the result
|
||||||
|
// cannot depend on map iteration order.
|
||||||
|
for range 256 {
|
||||||
|
require.Equal(t, []string{requestBodyWithDSL}, fuzzedQueryValues(t, rule))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecuteRendersTemplateVariableInteractshMarker(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
templateVars := newFuzzTestVariables()
|
||||||
|
templateVars.Set("marker", "{{interactsh-url}}")
|
||||||
|
rule := newQueryRule(t, "{{marker}}", nil, templateVars, &fakeInteractshClient{})
|
||||||
|
|
||||||
|
got := executeQueryRule(t, rule, http.MethodGet, nil, nil)
|
||||||
|
|
||||||
|
require.Len(t, got, 1)
|
||||||
|
require.Equal(t, []string{"oast1.example"}, got[0].InteractURLs)
|
||||||
|
require.Equal(t, "oast1.example", got[0].Request.URL.Query().Get("p"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepareGeneratorValuesRendersPayloadSharingVariableName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
templateVars := newFuzzTestVariables()
|
||||||
|
templateVars.Set("inj", "variable")
|
||||||
|
|
||||||
|
rule := &Rule{
|
||||||
|
options: &protocols.ExecutorOptions{
|
||||||
|
Variables: templateVars,
|
||||||
|
Options: &types.Options{},
|
||||||
|
Interactsh: &fakeInteractshClient{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
values, _ := rule.prepareGeneratorValues(map[string]any{
|
||||||
|
"inj": `{{to_upper("payload")}}`,
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
require.Equal(t, "PAYLOAD", values["inj"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrepareGeneratorValuesRendersPayloadsAgainstUnrenderedInput(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rule := &Rule{
|
||||||
|
options: &protocols.ExecutorOptions{
|
||||||
|
Variables: newFuzzTestVariables(),
|
||||||
|
Options: &types.Options{},
|
||||||
|
Interactsh: &interactsh.Client{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for range 256 {
|
||||||
|
values, _ := rule.prepareGeneratorValues(map[string]any{
|
||||||
|
"upper": `{{to_upper("x")}}`,
|
||||||
|
"ref": "{{upper}}",
|
||||||
|
}, nil)
|
||||||
|
|
||||||
|
require.Equal(t, "X", values["upper"])
|
||||||
|
// ref sees the unrendered definition of upper as data.
|
||||||
|
require.Equal(t, `{{to_upper("x")}}`, values["ref"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package compiler_test
|
||||||
|
|
||||||
|
//nolint:depguard // No depguard rules are configured, so its default list allows only the standard library.
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/js/compiler"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestCloseWithoutConnection calls Close on clients that never connected.
|
||||||
|
// The script must keep running past the call.
|
||||||
|
func TestCloseWithoutConnection(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
scripts := map[string]string{
|
||||||
|
"ssh": `
|
||||||
|
const ssh = require('nuclei/ssh');
|
||||||
|
ExportAs('closed', new ssh.SSHClient().Close());
|
||||||
|
true;
|
||||||
|
`,
|
||||||
|
"net": `
|
||||||
|
const net = require('nuclei/net');
|
||||||
|
new net.NetConn().Close();
|
||||||
|
ExportAs('closed', true);
|
||||||
|
true;
|
||||||
|
`,
|
||||||
|
}
|
||||||
|
for name, script := range scripts {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
program, err := compiler.SourceAutoMode(script, false)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := new(compiler.ExecuteOptions)
|
||||||
|
opts.ExecutionId = t.Name()
|
||||||
|
opts.Source = &script // ExportAs is defined only for sources that use it.
|
||||||
|
opts.TimeoutVariants = types.NewTimeoutVariant(5)
|
||||||
|
|
||||||
|
result, err := compiler.New().ExecuteWithOptions(t.Context(), program, compiler.NewExecuteArgs(), opts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := result["closed"]; got != "true" {
|
||||||
|
t.Errorf(`result["closed"] = %v, want "true"`, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,12 +24,14 @@ import (
|
|||||||
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libhash"
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libhash"
|
||||||
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libhttp"
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libhttp"
|
||||||
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libikev2"
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libikev2"
|
||||||
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libipmi"
|
||||||
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libkerberos"
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libkerberos"
|
||||||
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libkrbforge"
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libkrbforge"
|
||||||
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libkrbroast"
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libkrbroast"
|
||||||
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libldap"
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libldap"
|
||||||
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libmssql"
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libmssql"
|
||||||
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libmysql"
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libmysql"
|
||||||
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libmqtt"
|
||||||
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libnet"
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libnet"
|
||||||
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/liboracle"
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/liboracle"
|
||||||
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libpop3"
|
_ "github.com/projectdiscovery/nuclei/v3/pkg/js/generated/go/libpop3"
|
||||||
|
|||||||
@@ -38,14 +38,11 @@ func NewEntityParser(dir string) (*EntityParser, error) {
|
|||||||
return parser.ParseFile(fset, filename, src, parser.ParseComments)
|
return parser.ParseFile(fset, filename, src, parser.ParseComments)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
pkgs, err := packages.Load(cfg, ".")
|
|
||||||
|
pkg, err := loadPackage(cfg, ".")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(pkgs) == 0 {
|
|
||||||
return nil, errors.New("no packages found")
|
|
||||||
}
|
|
||||||
pkg := pkgs[0]
|
|
||||||
|
|
||||||
return &EntityParser{
|
return &EntityParser{
|
||||||
syntax: pkg.Syntax,
|
syntax: pkg.Syntax,
|
||||||
@@ -513,6 +510,7 @@ func (p *EntityParser) extractVarsNConstants() {
|
|||||||
|
|
||||||
// loadImportedPackages loads all imported packages
|
// loadImportedPackages loads all imported packages
|
||||||
func (p *EntityParser) loadImportedPackages() error {
|
func (p *EntityParser) loadImportedPackages() error {
|
||||||
|
cfg := &packages.Config{Mode: packages.NeedTypes | packages.NeedSyntax | packages.NeedTypesInfo}
|
||||||
// get all import statements
|
// get all import statements
|
||||||
// iterate over all imports
|
// iterate over all imports
|
||||||
for _, file := range p.syntax {
|
for _, file := range p.syntax {
|
||||||
@@ -522,7 +520,7 @@ func (p *EntityParser) loadImportedPackages() error {
|
|||||||
// remove the quotes from the path
|
// remove the quotes from the path
|
||||||
path = path[1 : len(path)-1]
|
path = path[1 : len(path)-1]
|
||||||
// load the package
|
// load the package
|
||||||
pkg, err := loadPackage(path)
|
pkg, err := loadPackage(cfg, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -543,18 +541,31 @@ func (p *EntityParser) loadImportedPackages() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load the package containing the type definition
|
// loadPackage loads the package that pattern names.
|
||||||
// TODO: we don't support named imports yet
|
//
|
||||||
func loadPackage(pkgPath string) (*packages.Package, error) {
|
// packages.Load reports parse, type, and list errors in Package.Errors
|
||||||
cfg := &packages.Config{Mode: packages.NeedTypes | packages.NeedSyntax | packages.NeedTypesInfo}
|
// rather than in its returned error, so loadPackage converts them into an
|
||||||
pkgs, err := packages.Load(cfg, pkgPath)
|
// 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)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(pkgs) == 0 {
|
if len(pkgs) == 0 {
|
||||||
return nil, errors.New("no packages found")
|
return nil, errors.New("no packages found")
|
||||||
}
|
}
|
||||||
return pkgs[0], nil
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// exprToString converts an expression to a string
|
// exprToString converts an expression to a string
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package ipmi
|
||||||
|
|
||||||
|
import (
|
||||||
|
lib_ipmi "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/ipmi"
|
||||||
|
|
||||||
|
"github.com/projectdiscovery/goja"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/js/gojs"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
module = gojs.NewGojaModule("nuclei/ipmi")
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
module.Set(
|
||||||
|
gojs.Objects{
|
||||||
|
// Functions
|
||||||
|
"CheckRAKPPassword": lib_ipmi.CheckRAKPPassword,
|
||||||
|
"FindRAKPPassword": lib_ipmi.FindRAKPPassword,
|
||||||
|
"GetAuthCapabilities": lib_ipmi.GetAuthCapabilities,
|
||||||
|
"GetCipherSuites": lib_ipmi.GetCipherSuites,
|
||||||
|
"GetRAKPHash": lib_ipmi.GetRAKPHash,
|
||||||
|
"GetSystemGUID": lib_ipmi.GetSystemGUID,
|
||||||
|
"Ping": lib_ipmi.Ping,
|
||||||
|
|
||||||
|
// Var and consts
|
||||||
|
|
||||||
|
// Objects / Classes
|
||||||
|
"AuthCapabilities": gojs.GetClassConstructor[lib_ipmi.AuthCapabilities](&lib_ipmi.AuthCapabilities{}),
|
||||||
|
"Client": gojs.GetClassConstructor[lib_ipmi.Client](&lib_ipmi.Client{}),
|
||||||
|
"CommandResponse": gojs.GetClassConstructor[lib_ipmi.CommandResponse](&lib_ipmi.CommandResponse{}),
|
||||||
|
"RAKPHash": gojs.GetClassConstructor[lib_ipmi.RAKPHash](&lib_ipmi.RAKPHash{}),
|
||||||
|
},
|
||||||
|
).Register()
|
||||||
|
}
|
||||||
|
|
||||||
|
func Enable(runtime *goja.Runtime) {
|
||||||
|
module.Enable(runtime)
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package mqtt
|
||||||
|
|
||||||
|
import (
|
||||||
|
lib_mqtt "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/mqtt"
|
||||||
|
|
||||||
|
"github.com/projectdiscovery/goja"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/js/gojs"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
module = gojs.NewGojaModule("nuclei/mqtt")
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
module.Set(
|
||||||
|
gojs.Objects{
|
||||||
|
// Functions
|
||||||
|
"IsMQTT": lib_mqtt.IsMQTT,
|
||||||
|
"IsAnonymousAllowed": lib_mqtt.IsAnonymousAllowed,
|
||||||
|
|
||||||
|
// Var and consts
|
||||||
|
|
||||||
|
// Objects / Classes
|
||||||
|
"Client": gojs.GetClassConstructor[lib_mqtt.Client](&lib_mqtt.Client{}),
|
||||||
|
"ConnectOptions": gojs.GetClassConstructor[lib_mqtt.ConnectOptions](&lib_mqtt.ConnectOptions{}),
|
||||||
|
"ConnectResult": gojs.GetClassConstructor[lib_mqtt.ConnectResult](&lib_mqtt.ConnectResult{}),
|
||||||
|
"Message": gojs.GetClassConstructor[lib_mqtt.Message](&lib_mqtt.Message{}),
|
||||||
|
},
|
||||||
|
).Register()
|
||||||
|
}
|
||||||
|
|
||||||
|
func Enable(runtime *goja.Runtime) {
|
||||||
|
module.Enable(runtime)
|
||||||
|
}
|
||||||
@@ -8,12 +8,14 @@ export * as grpc from './grpc';
|
|||||||
export * as hash from './hash';
|
export * as hash from './hash';
|
||||||
export * as http from './http';
|
export * as http from './http';
|
||||||
export * as ikev2 from './ikev2';
|
export * as ikev2 from './ikev2';
|
||||||
|
export * as ipmi from './ipmi';
|
||||||
export * as kerberos from './kerberos';
|
export * as kerberos from './kerberos';
|
||||||
export * as krbforge from './krbforge';
|
export * as krbforge from './krbforge';
|
||||||
export * as krbroast from './krbroast';
|
export * as krbroast from './krbroast';
|
||||||
export * as ldap from './ldap';
|
export * as ldap from './ldap';
|
||||||
export * as mssql from './mssql';
|
export * as mssql from './mssql';
|
||||||
export * as mysql from './mysql';
|
export * as mysql from './mysql';
|
||||||
|
export * as mqtt from './mqtt';
|
||||||
export * as net from './net';
|
export * as net from './net';
|
||||||
export * as oracle from './oracle';
|
export * as oracle from './oracle';
|
||||||
export * as pop3 from './pop3';
|
export * as pop3 from './pop3';
|
||||||
|
|||||||
Executable
+304
@@ -0,0 +1,304 @@
|
|||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CheckRAKPPassword reports whether password produced hash. It runs offline,
|
||||||
|
* so it can test any number of passwords without touching the BMC.
|
||||||
|
* @example
|
||||||
|
* ```javascript
|
||||||
|
* const ipmi = require('nuclei/ipmi');
|
||||||
|
* const hash = ipmi.GetRAKPHash('acme.com', 623, 'ADMIN');
|
||||||
|
* log(ipmi.CheckRAKPPassword(hash, 'ADMIN'));
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function CheckRAKPPassword(hash: RAKPHash, password: string): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FindRAKPPassword returns the first of passwords that produced hash, or an
|
||||||
|
* empty string.
|
||||||
|
* @example
|
||||||
|
* ```javascript
|
||||||
|
* const ipmi = require('nuclei/ipmi');
|
||||||
|
* const hash = ipmi.GetRAKPHash('acme.com', 623, 'root');
|
||||||
|
* log(ipmi.FindRAKPPassword(hash, ['calvin', 'root', 'admin']));
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function FindRAKPPassword(hash: RAKPHash, passwords: string[]): string {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GetAuthCapabilities returns the authentication capabilities the BMC
|
||||||
|
* advertises on the current channel.
|
||||||
|
* @example
|
||||||
|
* ```javascript
|
||||||
|
* const ipmi = require('nuclei/ipmi');
|
||||||
|
* const caps = ipmi.GetAuthCapabilities('acme.com', 623);
|
||||||
|
* log(caps.AnonymousLogin);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function GetAuthCapabilities(ctx: any, host: string, port: number): AuthCapabilities | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GetCipherSuites returns the IDs of the RMCP+ cipher suites the BMC
|
||||||
|
* offers. Cipher suite 0 authenticates nothing, so any password logs in.
|
||||||
|
* @example
|
||||||
|
* ```javascript
|
||||||
|
* const ipmi = require('nuclei/ipmi');
|
||||||
|
* const suites = ipmi.GetCipherSuites('acme.com', 623);
|
||||||
|
* log(suites.includes(0));
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function GetCipherSuites(ctx: any, host: string, port: number): number[] | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GetRAKPHash starts an RMCP+ session as username and returns the HMAC the
|
||||||
|
* BMC answers with. The session is never completed, so no login is
|
||||||
|
* attempted; the BMC drops the half-open session after its timeout. It fails
|
||||||
|
* when the BMC rejects the username.
|
||||||
|
* @example
|
||||||
|
* ```javascript
|
||||||
|
* const ipmi = require('nuclei/ipmi');
|
||||||
|
* const hash = ipmi.GetRAKPHash('acme.com', 623, 'ADMIN');
|
||||||
|
* log(ipmi.CheckRAKPPassword(hash, 'ADMIN'));
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function GetRAKPHash(ctx: any, host: string, port: number, username: string): RAKPHash | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GetSystemGUID returns the BMC system GUID, which vendors derive from
|
||||||
|
* serial numbers or MAC addresses.
|
||||||
|
* @example
|
||||||
|
* ```javascript
|
||||||
|
* const ipmi = require('nuclei/ipmi');
|
||||||
|
* const guid = ipmi.GetSystemGUID('acme.com', 623);
|
||||||
|
* log(guid);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function GetSystemGUID(ctx: any, host: string, port: number): string | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ping reports whether the host answers an RMCP presence ping and advertises
|
||||||
|
* IPMI support.
|
||||||
|
* @example
|
||||||
|
* ```javascript
|
||||||
|
* const ipmi = require('nuclei/ipmi');
|
||||||
|
* const isIPMI = ipmi.Ping('acme.com', 623);
|
||||||
|
* log(isIPMI);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function Ping(ctx: any, host: string, port: number): boolean | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client holds one RMCP+ session, for commands that need a login.
|
||||||
|
* Close it when done: BMCs allow only a few sessions at a time.
|
||||||
|
* @example
|
||||||
|
* ```javascript
|
||||||
|
* const ipmi = require('nuclei/ipmi');
|
||||||
|
* const client = new ipmi.Client();
|
||||||
|
* client.Login('acme.com', 623, 'ADMIN', 'ADMIN', 3);
|
||||||
|
* const id = client.Command(0x06, 0x01, []);
|
||||||
|
* client.Close();
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class Client {
|
||||||
|
|
||||||
|
|
||||||
|
// Constructor of Client
|
||||||
|
constructor() {}
|
||||||
|
/**
|
||||||
|
* Login opens an RMCP+ session as username with the given cipher suite
|
||||||
|
* (0, 1, 2, 3 or 6). Cipher suite 0 skips authentication, so a BMC
|
||||||
|
* that allows it accepts any password. A rejected login is an error.
|
||||||
|
* @example
|
||||||
|
* ```javascript
|
||||||
|
* const ipmi = require('nuclei/ipmi');
|
||||||
|
* const client = new ipmi.Client();
|
||||||
|
* client.Login('acme.com', 623, 'ADMIN', 'not-the-password', 0);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
public Login(ctx: any, host: string, port: number, username: string, password: string, cipherSuite: number): boolean | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command sends an IPMI request in the session, such as Get Device ID
|
||||||
|
* (netFn 0x06, cmd 0x01) or Set Session Privilege Level (0x06, 0x3b).
|
||||||
|
* @example
|
||||||
|
* ```javascript
|
||||||
|
* const ipmi = require('nuclei/ipmi');
|
||||||
|
* const client = new ipmi.Client();
|
||||||
|
* client.Login('acme.com', 623, 'ADMIN', 'ADMIN', 3);
|
||||||
|
* const admin = client.Command(0x06, 0x3b, [0x04]).CompletionCode == 0;
|
||||||
|
* client.Close();
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
public Command(netFn: number, cmd: number, data: Uint8Array): CommandResponse | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close ends the session on the BMC, freeing its slot.
|
||||||
|
* @example
|
||||||
|
* ```javascript
|
||||||
|
* const ipmi = require('nuclei/ipmi');
|
||||||
|
* const client = new ipmi.Client();
|
||||||
|
* client.Login('acme.com', 623, 'ADMIN', 'ADMIN', 3);
|
||||||
|
* client.Close();
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
public Close(): void {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AuthCapabilities is what a BMC advertises before any login, from Get
|
||||||
|
* Channel Authentication Capabilities.
|
||||||
|
* @example
|
||||||
|
* ```javascript
|
||||||
|
* const ipmi = require('nuclei/ipmi');
|
||||||
|
* const caps = ipmi.GetAuthCapabilities('acme.com', 623);
|
||||||
|
* log(toJSON(caps));
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export interface AuthCapabilities {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IPMI20 is true when the BMC supports IPMI 2.0 (RMCP+).
|
||||||
|
*/
|
||||||
|
|
||||||
|
IPMI20?: boolean,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AuthTypes are the IPMI 1.5 authentication types the channel
|
||||||
|
* accepts: none, md2, md5, straight (cleartext password), oem.
|
||||||
|
*/
|
||||||
|
|
||||||
|
AuthTypes?: string[],
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AnonymousLogin is true when a null username with a null password
|
||||||
|
* is accepted.
|
||||||
|
*/
|
||||||
|
|
||||||
|
AnonymousLogin?: boolean,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NullUsernames is true when accounts with an empty name are enabled.
|
||||||
|
*/
|
||||||
|
|
||||||
|
NullUsernames?: boolean,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NonNullUsernames is true when named accounts are enabled.
|
||||||
|
*/
|
||||||
|
|
||||||
|
NonNullUsernames?: boolean,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PerMessageAuth is false when the BMC skips authenticating
|
||||||
|
* messages after login.
|
||||||
|
*/
|
||||||
|
|
||||||
|
PerMessageAuth?: boolean,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UserLevelAuth is false when user-level commands skip
|
||||||
|
* authentication.
|
||||||
|
*/
|
||||||
|
|
||||||
|
UserLevelAuth?: boolean,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OEMID is the IANA enterprise number of the BMC vendor, or 0.
|
||||||
|
*/
|
||||||
|
|
||||||
|
OEMID?: number,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CommandResponse is the reply to an IPMI command.
|
||||||
|
*/
|
||||||
|
export interface CommandResponse {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CompletionCode is 0 on success.
|
||||||
|
*/
|
||||||
|
|
||||||
|
CompletionCode?: number,
|
||||||
|
|
||||||
|
Data?: Uint8Array,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RAKPHash is the HMAC a BMC returns for a user during RMCP+ session
|
||||||
|
* setup (CVE-2013-4786). It can be checked offline against candidate
|
||||||
|
* passwords, which tests credentials without a login attempt.
|
||||||
|
* @example
|
||||||
|
* ```javascript
|
||||||
|
* const ipmi = require('nuclei/ipmi');
|
||||||
|
* const hash = ipmi.GetRAKPHash('acme.com', 623, 'ADMIN');
|
||||||
|
* log(hash.Hashcat);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export interface RAKPHash {
|
||||||
|
|
||||||
|
Username?: string,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Algorithm is the HMAC the BMC used: sha1 or md5.
|
||||||
|
*/
|
||||||
|
|
||||||
|
Algorithm?: string,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Salt and HMAC are hex encoded; Salt is everything the HMAC covers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
Salt?: string,
|
||||||
|
|
||||||
|
HMAC?: string,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hashcat is the hash in hashcat mode 7300 format, for sha1 only.
|
||||||
|
*/
|
||||||
|
|
||||||
|
Hashcat?: string,
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
|
||||||
|
/**
|
||||||
|
* IsMQTT checks whether the target host:port is running an MQTT broker.
|
||||||
|
* @example
|
||||||
|
* ```javascript
|
||||||
|
* const mqtt = require('nuclei/mqtt');
|
||||||
|
* const isMqtt = mqtt.IsMQTT('acme.com', 1883);
|
||||||
|
* log(isMqtt);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function IsMQTT(host: string, port: number): boolean | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IsAnonymousAllowed checks whether an MQTT broker permits unauthenticated / anonymous login.
|
||||||
|
* @example
|
||||||
|
* ```javascript
|
||||||
|
* const mqtt = require('nuclei/mqtt');
|
||||||
|
* const allowed = mqtt.IsAnonymousAllowed('acme.com', 1883);
|
||||||
|
* log(allowed);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function IsAnonymousAllowed(host: string, port: number): boolean | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Message represents an MQTT published message.
|
||||||
|
* @example
|
||||||
|
* ```javascript
|
||||||
|
* const mqtt = require('nuclei/mqtt');
|
||||||
|
* const client = new mqtt.Client();
|
||||||
|
* client.Connect('broker.hivemq.com', 1883);
|
||||||
|
* const msgs = client.Subscribe('test/topic', 2);
|
||||||
|
* log(msgs[0].Topic, msgs[0].Payload);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export interface Message {
|
||||||
|
Topic?: string;
|
||||||
|
Payload?: string;
|
||||||
|
QoS?: number;
|
||||||
|
Retain?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ConnectOptions contains connection options for an MQTT broker.
|
||||||
|
* @example
|
||||||
|
* ```javascript
|
||||||
|
* const mqtt = require('nuclei/mqtt');
|
||||||
|
* const opts = new mqtt.ConnectOptions();
|
||||||
|
* opts.Username = 'admin';
|
||||||
|
* opts.Password = 'admin';
|
||||||
|
* opts.CleanSession = true;
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export interface ConnectOptions {
|
||||||
|
Username?: string;
|
||||||
|
Password?: string;
|
||||||
|
ClientID?: string;
|
||||||
|
CleanSession?: boolean;
|
||||||
|
KeepAlive?: number;
|
||||||
|
TLS?: boolean;
|
||||||
|
InsecureSkipVerify?: boolean;
|
||||||
|
Timeout?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ConnectResult is the response returned by an MQTT connection attempt.
|
||||||
|
* @example
|
||||||
|
* ```javascript
|
||||||
|
* const mqtt = require('nuclei/mqtt');
|
||||||
|
* const client = new mqtt.Client();
|
||||||
|
* const res = client.Connect('acme.com', 1883);
|
||||||
|
* log(res.Success, res.ReturnCode, res.Reason);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export interface ConnectResult {
|
||||||
|
Success?: boolean;
|
||||||
|
ReturnCode?: number;
|
||||||
|
Reason?: string;
|
||||||
|
SessionPresent?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client is an MQTT client for testing MQTT broker misconfigurations and authentication.
|
||||||
|
* @example
|
||||||
|
* ```javascript
|
||||||
|
* const mqtt = require('nuclei/mqtt');
|
||||||
|
* const client = new mqtt.Client();
|
||||||
|
* const res = client.Connect('acme.com', 1883);
|
||||||
|
* if (res.Success) {
|
||||||
|
* const msgs = client.Subscribe('#', 2);
|
||||||
|
* client.Publish('nuclei/probe', 'test');
|
||||||
|
* }
|
||||||
|
* client.Close();
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export class Client {
|
||||||
|
constructor() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect connects to an MQTT broker using optional credentials and settings.
|
||||||
|
* @example
|
||||||
|
* ```javascript
|
||||||
|
* const mqtt = require('nuclei/mqtt');
|
||||||
|
* const client = new mqtt.Client();
|
||||||
|
* const res = client.Connect('acme.com', 1883);
|
||||||
|
* log(res.Success);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
public Connect(host: string, port: number, options?: ConnectOptions): ConnectResult | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ConnectAuth connects to an MQTT broker with a username and password.
|
||||||
|
* @example
|
||||||
|
* ```javascript
|
||||||
|
* const mqtt = require('nuclei/mqtt');
|
||||||
|
* const client = new mqtt.Client();
|
||||||
|
* const res = client.ConnectAuth('acme.com', 1883, 'admin', 'admin');
|
||||||
|
* log(res.Success);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
public ConnectAuth(host: string, port: number, username: string, password: string): ConnectResult | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe subscribes to a topic filter and collects received messages up to timeoutSec.
|
||||||
|
* @example
|
||||||
|
* ```javascript
|
||||||
|
* const mqtt = require('nuclei/mqtt');
|
||||||
|
* const client = new mqtt.Client();
|
||||||
|
* client.Connect('acme.com', 1883);
|
||||||
|
* const msgs = client.Subscribe('#', 3);
|
||||||
|
* for (const m of msgs) {
|
||||||
|
* log(m.Topic, m.Payload);
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
public Subscribe(topic: string, timeoutSec?: number): Message[] | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publish publishes a message payload to a topic.
|
||||||
|
* @example
|
||||||
|
* ```javascript
|
||||||
|
* const mqtt = require('nuclei/mqtt');
|
||||||
|
* const client = new mqtt.Client();
|
||||||
|
* client.Connect('acme.com', 1883);
|
||||||
|
* const ok = client.Publish('nuclei/test', 'probe payload');
|
||||||
|
* log(ok);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
public Publish(topic: string, payload: string): boolean | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ping sends an MQTT PINGREQ and awaits PINGRESP.
|
||||||
|
* @example
|
||||||
|
* ```javascript
|
||||||
|
* const mqtt = require('nuclei/mqtt');
|
||||||
|
* const client = new mqtt.Client();
|
||||||
|
* client.Connect('acme.com', 1883);
|
||||||
|
* const alive = client.Ping();
|
||||||
|
* log(alive);
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
public Ping(): boolean | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close cleanly disconnects and closes the connection.
|
||||||
|
* @example
|
||||||
|
* ```javascript
|
||||||
|
* const mqtt = require('nuclei/mqtt');
|
||||||
|
* const client = new mqtt.Client();
|
||||||
|
* client.Connect('acme.com', 1883);
|
||||||
|
* client.Close();
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
public Close(): error | null {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,6 +46,7 @@ export class NetConn {
|
|||||||
constructor() {}
|
constructor() {}
|
||||||
/**
|
/**
|
||||||
* Close closes the connection.
|
* Close closes the connection.
|
||||||
|
* Close does nothing if no connection is open.
|
||||||
* @example
|
* @example
|
||||||
* ```javascript
|
* ```javascript
|
||||||
* const net = require('nuclei/net');
|
* const net = require('nuclei/net');
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* PGClient is a client for Postgres database.
|
* PGClient is a client for Postgres database.
|
||||||
* Internally client uses go-pg/pg driver.
|
* Internally client uses lib/pq driver.
|
||||||
* @example
|
* @example
|
||||||
* ```javascript
|
* ```javascript
|
||||||
* const postgres = require('nuclei/postgres');
|
* const postgres = require('nuclei/postgres');
|
||||||
|
|||||||
@@ -106,7 +106,8 @@ export class SSHClient {
|
|||||||
/**
|
/**
|
||||||
* Close closes the SSH connection and destroys the client
|
* Close closes the SSH connection and destroys the client
|
||||||
* Returns the success state and error. If error is not nil,
|
* Returns the success state and error. If error is not nil,
|
||||||
* state will be false
|
* state will be false. Close does nothing and returns true
|
||||||
|
* if no connection is open.
|
||||||
* @example
|
* @example
|
||||||
* ```javascript
|
* ```javascript
|
||||||
* const ssh = require('nuclei/ssh');
|
* const ssh = require('nuclei/ssh');
|
||||||
|
|||||||
+6
-2
@@ -128,9 +128,13 @@ func (p *GojaModule) Register() Module {
|
|||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetClassConstructor returns a constructor for any given go struct type for goja runtime
|
// GetClassConstructor returns a constructor for any given go struct type for goja runtime.
|
||||||
|
// Each object it constructs starts as a shallow copy of instance, so objects
|
||||||
|
// do not share state.
|
||||||
func GetClassConstructor[T any](instance *T) func(call goja.ConstructorCall, runtime *goja.Runtime) *goja.Object {
|
func GetClassConstructor[T any](instance *T) func(call goja.ConstructorCall, runtime *goja.Runtime) *goja.Object {
|
||||||
return func(call goja.ConstructorCall, runtime *goja.Runtime) *goja.Object {
|
return func(call goja.ConstructorCall, runtime *goja.Runtime) *goja.Object {
|
||||||
return utils.LinkConstructor[*T](call, runtime, instance)
|
obj := *instance
|
||||||
|
|
||||||
|
return utils.LinkConstructor(call, runtime, &obj)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package gojs_test
|
||||||
|
|
||||||
|
//nolint:depguard // No depguard rules are configured, so its default list allows only the standard library.
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/projectdiscovery/goja"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/js/gojs"
|
||||||
|
)
|
||||||
|
|
||||||
|
type counter struct{ n int }
|
||||||
|
|
||||||
|
func (c *counter) Inc() int {
|
||||||
|
c.n++
|
||||||
|
|
||||||
|
return c.n
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetClassConstructorReturnsNewObjects(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
prototype := new(counter)
|
||||||
|
runtime := goja.New()
|
||||||
|
|
||||||
|
err := runtime.Set("Counter", gojs.GetClassConstructor(prototype))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
value, err := runtime.RunString(`
|
||||||
|
const first = new Counter();
|
||||||
|
first.Inc();
|
||||||
|
new Counter().Inc();
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := value.ToInteger(); got != 1 {
|
||||||
|
t.Errorf("new Counter().Inc() = %d, want 1", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
if prototype.n != 0 {
|
||||||
|
t.Errorf("prototype.n = %d, want 0", prototype.n)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,394 @@
|
|||||||
|
package ipmi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/md5"
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
// AuthCapabilities is what a BMC advertises before any login, from Get
|
||||||
|
// Channel Authentication Capabilities.
|
||||||
|
// @example
|
||||||
|
// ```javascript
|
||||||
|
// const ipmi = require('nuclei/ipmi');
|
||||||
|
// const caps = ipmi.GetAuthCapabilities('acme.com', 623);
|
||||||
|
// log(toJSON(caps));
|
||||||
|
// ```
|
||||||
|
AuthCapabilities struct {
|
||||||
|
// IPMI20 is true when the BMC supports IPMI 2.0 (RMCP+).
|
||||||
|
IPMI20 bool
|
||||||
|
// AuthTypes are the IPMI 1.5 authentication types the channel
|
||||||
|
// accepts: none, md2, md5, straight (cleartext password), oem.
|
||||||
|
AuthTypes []string
|
||||||
|
// AnonymousLogin is true when a null username with a null password
|
||||||
|
// is accepted.
|
||||||
|
AnonymousLogin bool
|
||||||
|
// NullUsernames is true when accounts with an empty name are enabled.
|
||||||
|
NullUsernames bool
|
||||||
|
// NonNullUsernames is true when named accounts are enabled.
|
||||||
|
NonNullUsernames bool
|
||||||
|
// PerMessageAuth is false when the BMC skips authenticating
|
||||||
|
// messages after login.
|
||||||
|
PerMessageAuth bool
|
||||||
|
// UserLevelAuth is false when user-level commands skip
|
||||||
|
// authentication.
|
||||||
|
UserLevelAuth bool
|
||||||
|
// OEMID is the IANA enterprise number of the BMC vendor, or 0.
|
||||||
|
OEMID int
|
||||||
|
}
|
||||||
|
|
||||||
|
// RAKPHash is the HMAC a BMC returns for a user during RMCP+ session
|
||||||
|
// setup (CVE-2013-4786). It can be checked offline against candidate
|
||||||
|
// passwords, which tests credentials without a login attempt.
|
||||||
|
// @example
|
||||||
|
// ```javascript
|
||||||
|
// const ipmi = require('nuclei/ipmi');
|
||||||
|
// const hash = ipmi.GetRAKPHash('acme.com', 623, 'ADMIN');
|
||||||
|
// log(hash.Hashcat);
|
||||||
|
// ```
|
||||||
|
RAKPHash struct {
|
||||||
|
Username string
|
||||||
|
// Algorithm is the HMAC the BMC used: sha1 or md5.
|
||||||
|
Algorithm string
|
||||||
|
// Salt and HMAC are hex encoded; Salt is everything the HMAC covers.
|
||||||
|
Salt string
|
||||||
|
HMAC string
|
||||||
|
// Hashcat is the hash in hashcat mode 7300 format, for sha1 only.
|
||||||
|
Hashcat string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client holds one RMCP+ session, for commands that need a login.
|
||||||
|
// Close it when done: BMCs allow only a few sessions at a time.
|
||||||
|
// @example
|
||||||
|
// ```javascript
|
||||||
|
// const ipmi = require('nuclei/ipmi');
|
||||||
|
// const client = new ipmi.Client();
|
||||||
|
// client.Login('acme.com', 623, 'ADMIN', 'ADMIN', 3);
|
||||||
|
// const id = client.Command(0x06, 0x01, []);
|
||||||
|
// client.Close();
|
||||||
|
// ```
|
||||||
|
Client struct {
|
||||||
|
session *established
|
||||||
|
}
|
||||||
|
|
||||||
|
// CommandResponse is the reply to an IPMI command.
|
||||||
|
CommandResponse struct {
|
||||||
|
// CompletionCode is 0 on success.
|
||||||
|
CompletionCode int
|
||||||
|
Data []byte
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
var authTypes = []struct {
|
||||||
|
bit byte
|
||||||
|
name string
|
||||||
|
}{{0x01, "none"}, {0x02, "md2"}, {0x04, "md5"}, {0x10, "straight"}, {0x20, "oem"}}
|
||||||
|
|
||||||
|
// Ping reports whether the host answers an RMCP presence ping and advertises
|
||||||
|
// IPMI support.
|
||||||
|
// @example
|
||||||
|
// ```javascript
|
||||||
|
// const ipmi = require('nuclei/ipmi');
|
||||||
|
// const isIPMI = ipmi.Ping('acme.com', 623);
|
||||||
|
// log(isIPMI);
|
||||||
|
// ```
|
||||||
|
func Ping(ctx context.Context, host string, port int) (bool, error) {
|
||||||
|
s, err := dial(ctx, host, port)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer s.close()
|
||||||
|
return s.ping()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAuthCapabilities returns the authentication capabilities the BMC
|
||||||
|
// advertises on the current channel.
|
||||||
|
// @example
|
||||||
|
// ```javascript
|
||||||
|
// const ipmi = require('nuclei/ipmi');
|
||||||
|
// const caps = ipmi.GetAuthCapabilities('acme.com', 623);
|
||||||
|
// log(caps.AnonymousLogin);
|
||||||
|
// ```
|
||||||
|
func GetAuthCapabilities(ctx context.Context, host string, port int) (*AuthCapabilities, error) {
|
||||||
|
s, err := dial(ctx, host, port)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer s.close()
|
||||||
|
|
||||||
|
// bit 7 asks for the IPMI 2.0 extended data; an IPMI 1.5 only BMC
|
||||||
|
// rejects it, so ask again without
|
||||||
|
data, err := s.command(cmdGetChannelAuthCaps, []byte{0x80 | currentChannel, privAdmin})
|
||||||
|
if err != nil {
|
||||||
|
if data, err = s.command(cmdGetChannelAuthCaps, []byte{currentChannel, privAdmin}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parseAuthCapabilities(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAuthCapabilities decodes the Get Channel Authentication Capabilities
|
||||||
|
// response data (IPMI v2.0 table 22-15).
|
||||||
|
func parseAuthCapabilities(data []byte) (*AuthCapabilities, error) {
|
||||||
|
if len(data) < 8 {
|
||||||
|
return nil, fmt.Errorf("short channel authentication capabilities response")
|
||||||
|
}
|
||||||
|
caps := &AuthCapabilities{
|
||||||
|
IPMI20: data[1]&0x80 != 0 && data[3]&0x02 != 0,
|
||||||
|
AnonymousLogin: data[2]&0x01 != 0,
|
||||||
|
NullUsernames: data[2]&0x02 != 0,
|
||||||
|
NonNullUsernames: data[2]&0x04 != 0,
|
||||||
|
UserLevelAuth: data[2]&0x08 == 0,
|
||||||
|
PerMessageAuth: data[2]&0x10 == 0,
|
||||||
|
OEMID: int(data[4]) | int(data[5])<<8 | int(data[6])<<16,
|
||||||
|
}
|
||||||
|
for _, authType := range authTypes {
|
||||||
|
if data[1]&authType.bit != 0 {
|
||||||
|
caps.AuthTypes = append(caps.AuthTypes, authType.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return caps, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSystemGUID returns the BMC system GUID, which vendors derive from
|
||||||
|
// serial numbers or MAC addresses.
|
||||||
|
// @example
|
||||||
|
// ```javascript
|
||||||
|
// const ipmi = require('nuclei/ipmi');
|
||||||
|
// const guid = ipmi.GetSystemGUID('acme.com', 623);
|
||||||
|
// log(guid);
|
||||||
|
// ```
|
||||||
|
func GetSystemGUID(ctx context.Context, host string, port int) (string, error) {
|
||||||
|
s, err := dial(ctx, host, port)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer s.close()
|
||||||
|
|
||||||
|
data, err := s.command(cmdGetSystemGUID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(data) < 16 {
|
||||||
|
return "", fmt.Errorf("short system guid response")
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(data[:16]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCipherSuites returns the IDs of the RMCP+ cipher suites the BMC
|
||||||
|
// offers. Cipher suite 0 authenticates nothing, so any password logs in.
|
||||||
|
// @example
|
||||||
|
// ```javascript
|
||||||
|
// const ipmi = require('nuclei/ipmi');
|
||||||
|
// const suites = ipmi.GetCipherSuites('acme.com', 623);
|
||||||
|
// log(suites.includes(0));
|
||||||
|
// ```
|
||||||
|
func GetCipherSuites(ctx context.Context, host string, port int) ([]int, error) {
|
||||||
|
s, err := dial(ctx, host, port)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer s.close()
|
||||||
|
|
||||||
|
// the records come in pages of 16 bytes; a shorter page is the last
|
||||||
|
var records []byte
|
||||||
|
for index := byte(0); index < 64; index++ {
|
||||||
|
data, err := s.command(cmdGetCipherSuites, []byte{currentChannel, 0x00, 0x80 | index})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(data) > 0 {
|
||||||
|
records = append(records, data[1:]...)
|
||||||
|
}
|
||||||
|
if len(data) < 17 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseCipherSuites(records), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseCipherSuites returns the suite IDs in Get Channel Cipher Suites
|
||||||
|
// record data. A record starts with 0xc0 (standard) or 0xc1 (oem, followed
|
||||||
|
// by a 3 byte IANA); algorithm bytes are tagged below 0xc0, so they never
|
||||||
|
// look like a record start.
|
||||||
|
func parseCipherSuites(records []byte) []int {
|
||||||
|
suites := []int{}
|
||||||
|
for i := 0; i < len(records); i++ {
|
||||||
|
switch records[i] {
|
||||||
|
case 0xc0:
|
||||||
|
if i+1 < len(records) {
|
||||||
|
suites = append(suites, int(records[i+1]))
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
case 0xc1:
|
||||||
|
if i+1 < len(records) {
|
||||||
|
suites = append(suites, int(records[i+1]))
|
||||||
|
i += 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return suites
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRAKPHash starts an RMCP+ session as username and returns the HMAC the
|
||||||
|
// BMC answers with. The session is never completed, so no login is
|
||||||
|
// attempted; the BMC drops the half-open session after its timeout. It fails
|
||||||
|
// when the BMC rejects the username.
|
||||||
|
// @example
|
||||||
|
// ```javascript
|
||||||
|
// const ipmi = require('nuclei/ipmi');
|
||||||
|
// const hash = ipmi.GetRAKPHash('acme.com', 623, 'ADMIN');
|
||||||
|
// log(ipmi.CheckRAKPPassword(hash, 'ADMIN'));
|
||||||
|
// ```
|
||||||
|
func GetRAKPHash(ctx context.Context, host string, port int, username string) (*RAKPHash, error) {
|
||||||
|
s, err := dial(ctx, host, port)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer s.close()
|
||||||
|
|
||||||
|
// the session is never completed, so no login is attempted; md5 is for
|
||||||
|
// BMCs that refuse sha1
|
||||||
|
var h *handshake
|
||||||
|
for _, auth := range []byte{authHMACSHA1, authHMACMD5} {
|
||||||
|
if h, err = s.startSession(algorithms{auth: auth}, username, privAdmin); err == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
var refused *rakpError
|
||||||
|
if !errors.As(err, &refused) || refused.step != "the session" {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
hash := &RAKPHash{Username: username, Algorithm: "sha1", Salt: hex.EncodeToString(h.rakp2Salt()), HMAC: hex.EncodeToString(h.bmcMAC)}
|
||||||
|
if h.auth == authHMACMD5 {
|
||||||
|
hash.Algorithm = "md5"
|
||||||
|
} else {
|
||||||
|
hash.Hashcat = hash.Salt + ":" + hash.HMAC
|
||||||
|
}
|
||||||
|
return hash, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckRAKPPassword reports whether password produced hash. It runs offline,
|
||||||
|
// so it can test any number of passwords without touching the BMC.
|
||||||
|
// @example
|
||||||
|
// ```javascript
|
||||||
|
// const ipmi = require('nuclei/ipmi');
|
||||||
|
// const hash = ipmi.GetRAKPHash('acme.com', 623, 'ADMIN');
|
||||||
|
// log(ipmi.CheckRAKPPassword(hash, 'ADMIN'));
|
||||||
|
// ```
|
||||||
|
func CheckRAKPPassword(hash *RAKPHash, password string) bool {
|
||||||
|
if hash == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
salt, err := hex.DecodeString(hash.Salt)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
want, err := hex.DecodeString(hash.HMAC)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
newHash := sha1.New
|
||||||
|
if hash.Algorithm == "md5" {
|
||||||
|
newHash = md5.New
|
||||||
|
}
|
||||||
|
return hmac.Equal(hmacOf(newHash, []byte(password), salt), want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindRAKPPassword returns the first of passwords that produced hash, or an
|
||||||
|
// empty string.
|
||||||
|
// @example
|
||||||
|
// ```javascript
|
||||||
|
// const ipmi = require('nuclei/ipmi');
|
||||||
|
// const hash = ipmi.GetRAKPHash('acme.com', 623, 'root');
|
||||||
|
// log(ipmi.FindRAKPPassword(hash, ['calvin', 'root', 'admin']));
|
||||||
|
// ```
|
||||||
|
func FindRAKPPassword(hash *RAKPHash, passwords []string) string {
|
||||||
|
for _, password := range passwords {
|
||||||
|
if CheckRAKPPassword(hash, password) {
|
||||||
|
return password
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login opens an RMCP+ session as username with the given cipher suite
|
||||||
|
// (0, 1, 2, 3 or 6). Cipher suite 0 skips authentication, so a BMC
|
||||||
|
// that allows it accepts any password. A rejected login is an error.
|
||||||
|
// @example
|
||||||
|
// ```javascript
|
||||||
|
// const ipmi = require('nuclei/ipmi');
|
||||||
|
// const client = new ipmi.Client();
|
||||||
|
// client.Login('acme.com', 623, 'ADMIN', 'not-the-password', 0);
|
||||||
|
// ```
|
||||||
|
func (c *Client) Login(ctx context.Context, host string, port int, username string, password string, cipherSuite int) (bool, error) {
|
||||||
|
alg, ok := cipherSuites[cipherSuite]
|
||||||
|
if !ok {
|
||||||
|
return false, fmt.Errorf("unsupported cipher suite %d", cipherSuite)
|
||||||
|
}
|
||||||
|
c.Close()
|
||||||
|
s, err := dial(ctx, host, port)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
h, err := s.startSession(alg, username, privAdmin)
|
||||||
|
if err != nil {
|
||||||
|
s.close()
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
k, err := s.finishSession(h, password)
|
||||||
|
if err != nil {
|
||||||
|
s.close()
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
c.session = &established{session: s, algorithms: alg, keys: k, bmcSessionID: h.bmcSessionID, consoleSessionID: h.consoleSessionID}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command sends an IPMI request in the session, such as Get Device ID
|
||||||
|
// (netFn 0x06, cmd 0x01) or Set Session Privilege Level (0x06, 0x3b).
|
||||||
|
// @example
|
||||||
|
// ```javascript
|
||||||
|
// const ipmi = require('nuclei/ipmi');
|
||||||
|
// const client = new ipmi.Client();
|
||||||
|
// client.Login('acme.com', 623, 'ADMIN', 'ADMIN', 3);
|
||||||
|
// const admin = client.Command(0x06, 0x3b, [0x04]).CompletionCode == 0;
|
||||||
|
// client.Close();
|
||||||
|
// ```
|
||||||
|
func (c *Client) Command(netFn int, cmd int, data []byte) (*CommandResponse, error) {
|
||||||
|
if c.session == nil {
|
||||||
|
return nil, fmt.Errorf("not logged in")
|
||||||
|
}
|
||||||
|
code, reply, err := c.session.command(byte(netFn), byte(cmd), data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &CommandResponse{CompletionCode: int(code), Data: reply}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close ends the session on the BMC, freeing its slot.
|
||||||
|
// @example
|
||||||
|
// ```javascript
|
||||||
|
// const ipmi = require('nuclei/ipmi');
|
||||||
|
// const client = new ipmi.Client();
|
||||||
|
// client.Login('acme.com', 623, 'ADMIN', 'ADMIN', 3);
|
||||||
|
// client.Close();
|
||||||
|
// ```
|
||||||
|
func (c *Client) Close() {
|
||||||
|
if c.session == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _, _ = c.session.command(netFnApp, cmdCloseSession, c.session.bmcSessionID)
|
||||||
|
c.session.close()
|
||||||
|
c.session = nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
package ipmi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/hex"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The fixtures were captured from OpenIPMI's ipmi_sim with an enabled user
|
||||||
|
// with an empty name and password, ADMIN/ADMIN and root/calvin.
|
||||||
|
|
||||||
|
func mustHex(t *testing.T, s string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
data, err := hex.DecodeString(s)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseAuthCapabilities(t *testing.T) {
|
||||||
|
caps, err := parseAuthCapabilities(mustHex(t, "0197050300000000"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, &AuthCapabilities{
|
||||||
|
IPMI20: true,
|
||||||
|
AuthTypes: []string{"none", "md2", "md5", "straight"},
|
||||||
|
AnonymousLogin: true,
|
||||||
|
NonNullUsernames: true,
|
||||||
|
PerMessageAuth: true,
|
||||||
|
UserLevelAuth: true,
|
||||||
|
}, caps)
|
||||||
|
|
||||||
|
_, err = parseAuthCapabilities(mustHex(t, "0197"))
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseCipherSuites(t *testing.T) {
|
||||||
|
records := mustHex(t, "c000004080c001014080c002014180c003014181c004014182c005014183c006024080c007024280c008024281c009024282c00a024283c00b024380c00c024381c00d024382c00e024383")
|
||||||
|
require.Equal(t, []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14}, parseCipherSuites(records))
|
||||||
|
|
||||||
|
// an oem record carries a 3 byte IANA before its algorithms
|
||||||
|
require.Equal(t, []int{1, 7}, parseCipherSuites(mustHex(t, "c0010140c107d70200014080")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRAKPPasswordChecksAreOffline(t *testing.T) {
|
||||||
|
// hashcat -m 7300 cracks this hash to calvin as well
|
||||||
|
hash := &RAKPHash{
|
||||||
|
Username: "root",
|
||||||
|
Salt: "988200cd0402000069b30c7a5e3deb30504c5b76f7b1840d5e36439998d9e0f59ae49b09119a10b2a123456789abcdefa123456789abcdef1404726f6f74",
|
||||||
|
HMAC: "23c711e5c7a737c3c0beb9f4680ef31fa6aad398",
|
||||||
|
}
|
||||||
|
require.True(t, CheckRAKPPassword(hash, "calvin"))
|
||||||
|
require.False(t, CheckRAKPPassword(hash, "ADMIN"))
|
||||||
|
require.Equal(t, "calvin", FindRAKPPassword(hash, []string{"admin", "root", "calvin"}))
|
||||||
|
require.Empty(t, FindRAKPPassword(hash, []string{"admin"}))
|
||||||
|
require.False(t, CheckRAKPPassword(nil, "calvin"))
|
||||||
|
require.False(t, CheckRAKPPassword(&RAKPHash{Salt: "zz", HMAC: "00"}, "calvin"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestsHonourNetworkPolicy(t *testing.T) {
|
||||||
|
execID := "ipmi-deny"
|
||||||
|
require.NoError(t, protocolstate.Init(&types.Options{ExecutionId: execID, ExcludeTargets: []string{"203.0.113.50"}}))
|
||||||
|
t.Cleanup(func() { protocolstate.Close(execID) })
|
||||||
|
ctx := context.WithValue(context.Background(), "executionId", execID) //nolint:staticcheck
|
||||||
|
|
||||||
|
_, err := GetRAKPHash(ctx, "203.0.113.50", 623, "ADMIN")
|
||||||
|
require.ErrorContains(t, err, "denied")
|
||||||
|
require.NotErrorIs(t, err, errNoResponse, "denied before sending, not timed out")
|
||||||
|
_, err = Ping(ctx, "203.0.113.50", 623)
|
||||||
|
require.ErrorContains(t, err, "denied")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPayloadEncryptionRoundTrips(t *testing.T) {
|
||||||
|
key := mustHex(t, "000102030405060708090a0b0c0d0e0f")
|
||||||
|
for size := range 40 {
|
||||||
|
payload := make([]byte, size)
|
||||||
|
for i := range payload {
|
||||||
|
payload[i] = byte(i)
|
||||||
|
}
|
||||||
|
sealed, err := encryptPayload(key, payload)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Zero(t, len(sealed)%16, "iv and padded blocks")
|
||||||
|
opened, err := decryptPayload(key, sealed)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, payload, opened)
|
||||||
|
}
|
||||||
|
_, err := decryptPayload(key, []byte{1, 2, 3})
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSignAlignsTheIntegrityPad(t *testing.T) {
|
||||||
|
e := &established{algorithms: cipherSuites[3], keys: &keys{integrity: make([]byte, 20)}}
|
||||||
|
for size := range 8 {
|
||||||
|
packet := append(rmcpHeader(rmcpClassIPMI), make([]byte, 12+size)...)
|
||||||
|
signed := e.sign(packet)
|
||||||
|
// everything from the auth type through the next header is a
|
||||||
|
// multiple of 4, then 12 bytes of HMAC-SHA1-96
|
||||||
|
require.Zero(t, (len(signed)-12-4)%4)
|
||||||
|
require.Equal(t, byte(0x07), signed[len(signed)-13])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoginRejectsUnsupportedCipherSuites(t *testing.T) {
|
||||||
|
_, err := (&Client{}).Login(context.Background(), "127.0.0.1", 623, "ADMIN", "ADMIN", 17)
|
||||||
|
require.ErrorContains(t, err, "unsupported cipher suite 17")
|
||||||
|
_, err = (&Client{}).Command(0x06, 0x01, nil)
|
||||||
|
require.ErrorContains(t, err, "not logged in")
|
||||||
|
}
|
||||||
|
|
||||||
|
// capturedHandshake is a cipher suite 3 session setup with ipmi_sim for
|
||||||
|
// ADMIN/ADMIN, up to and including RAKP message 4.
|
||||||
|
func capturedHandshake(t *testing.T) (*handshake, []byte) {
|
||||||
|
return &handshake{
|
||||||
|
algorithms: cipherSuites[3],
|
||||||
|
consoleSessionID: mustHex(t, "244a5ac8"),
|
||||||
|
bmcSessionID: mustHex(t, "04170000"),
|
||||||
|
consoleRandom: mustHex(t, "de862025bb0c5ee6c30a58d383d44cb5"),
|
||||||
|
bmcRandom: mustHex(t, "b56fef2ee0fcab6664a019a4c88df4f6"),
|
||||||
|
bmcGUID: mustHex(t, "a123456789abcdefa123456789abcdef"),
|
||||||
|
role: 0x14,
|
||||||
|
username: "ADMIN",
|
||||||
|
bmcMAC: mustHex(t, "7fac6f776553c58df508b9ff656b5eacb6c2def9"),
|
||||||
|
}, mustHex(t, "00000000244a5ac8231219c5fc212cdd58c09424")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBMCProofsMatchOnlyTheRealPassword(t *testing.T) {
|
||||||
|
h, rakp4 := capturedHandshake(t)
|
||||||
|
sik := func(password string) []byte {
|
||||||
|
return hmacOf(sha1.New, []byte(password), h.consoleRandom, h.bmcRandom, []byte{h.role, byte(len(h.username))}, []byte(h.username))
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Equal(t, h.bmcMAC, hmacOf(sha1.New, []byte("ADMIN"), h.rakp2Salt()), "rakp 2 code")
|
||||||
|
require.NotEqual(t, h.bmcMAC, hmacOf(sha1.New, []byte("wrong"), h.rakp2Salt()))
|
||||||
|
|
||||||
|
require.True(t, rakp4Valid(sha1.New, sik("ADMIN"), h, rakp4))
|
||||||
|
require.False(t, rakp4Valid(sha1.New, sik("wrong"), h, rakp4))
|
||||||
|
tampered := bytes.Clone(rakp4)
|
||||||
|
tampered[len(tampered)-1] ^= 0xff
|
||||||
|
require.False(t, rakp4Valid(sha1.New, sik("ADMIN"), h, tampered), "a forged success must not pass")
|
||||||
|
require.False(t, rakp4Valid(sha1.New, sik("ADMIN"), h, rakp4[:10]))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShortCommandReplyIsRejectedNotPanicked(t *testing.T) {
|
||||||
|
execID := "ipmi-short"
|
||||||
|
require.NoError(t, protocolstate.Init(&types.Options{ExecutionId: execID}))
|
||||||
|
t.Cleanup(func() { protocolstate.Close(execID) })
|
||||||
|
ctx := context.WithValue(context.Background(), "executionId", execID) //nolint:staticcheck
|
||||||
|
|
||||||
|
// a reply that ends at the completion code, without the checksum
|
||||||
|
bmc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = bmc.Close() })
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, 512)
|
||||||
|
for {
|
||||||
|
n, addr, err := bmc.ReadFrom(buf)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reply := bytes.Clone(buf[:21])
|
||||||
|
reply[19] = buf[n-2] // echo the command
|
||||||
|
reply[20] = 0x00
|
||||||
|
_, _ = bmc.WriteTo(reply, addr)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
port := bmc.LocalAddr().(*net.UDPAddr).Port
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
_, err = GetSystemGUID(ctx, "127.0.0.1", port)
|
||||||
|
require.ErrorIs(t, err, errNoResponse)
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package ipmi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
libnet "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/net"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Wire constants from the IPMI v2.0 specification and DSP0136 (ASF).
|
||||||
|
const (
|
||||||
|
rmcpVersion = 0x06
|
||||||
|
rmcpNoAck = 0xff
|
||||||
|
rmcpClassASF = 0x06
|
||||||
|
rmcpClassIPMI = 0x07
|
||||||
|
|
||||||
|
asfIANA = 0x000011be
|
||||||
|
asfTypePing = 0x80
|
||||||
|
asfTypePong = 0x40
|
||||||
|
asfEntitiesIPMI = 0x80
|
||||||
|
|
||||||
|
authTypeNone = 0x00
|
||||||
|
authTypeRMCPP = 0x06
|
||||||
|
|
||||||
|
netFnApp = 0x06
|
||||||
|
cmdGetSystemGUID = 0x37
|
||||||
|
cmdGetChannelAuthCaps = 0x38
|
||||||
|
cmdGetCipherSuites = 0x54
|
||||||
|
|
||||||
|
bmcAddress = 0x20
|
||||||
|
consoleAddress = 0x81
|
||||||
|
currentChannel = 0x0e
|
||||||
|
privAdmin = 0x04
|
||||||
|
|
||||||
|
payloadOpenSessionRequest = 0x10
|
||||||
|
payloadOpenSessionResponse = 0x11
|
||||||
|
payloadRAKP1 = 0x12
|
||||||
|
payloadRAKP2 = 0x13
|
||||||
|
payloadRAKP3 = 0x14
|
||||||
|
payloadRAKP4 = 0x15
|
||||||
|
|
||||||
|
// rakpNameOnlyLookup asks the BMC to find the user by name alone, so the
|
||||||
|
// role does not have to match the one the account was created with.
|
||||||
|
rakpNameOnlyLookup = 0x10
|
||||||
|
|
||||||
|
attempts = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
var errNoResponse = errors.New("no ipmi response")
|
||||||
|
|
||||||
|
// session is one UDP exchange with a BMC. UDP is lossy, so each request is
|
||||||
|
// sent up to attempts times.
|
||||||
|
type session struct {
|
||||||
|
conn net.Conn
|
||||||
|
timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func dial(ctx context.Context, host string, port int) (*session, error) {
|
||||||
|
conn, err := libnet.Dial(ctx, "udp", net.JoinHostPort(host, fmt.Sprint(port)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
timeout := 3 * time.Second
|
||||||
|
if deadline, ok := ctx.Deadline(); ok {
|
||||||
|
timeout = min(timeout, time.Until(deadline)/attempts)
|
||||||
|
}
|
||||||
|
return &session{conn: conn, timeout: timeout}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *session) close() { _ = s.conn.Close() }
|
||||||
|
|
||||||
|
// exchange sends packet and returns the first reply accepted by match.
|
||||||
|
func (s *session) exchange(packet []byte, match func([]byte) bool) ([]byte, error) {
|
||||||
|
buf := make([]byte, 1024)
|
||||||
|
for range attempts {
|
||||||
|
if _, err := s.conn.Write(packet); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = s.conn.SetReadDeadline(time.Now().Add(s.timeout))
|
||||||
|
for {
|
||||||
|
n, err := s.conn.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if reply := buf[:n]; match(reply) {
|
||||||
|
return bytes.Clone(reply), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, errNoResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
func rmcpHeader(class byte) []byte {
|
||||||
|
return []byte{rmcpVersion, 0x00, rmcpNoAck, class}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ping sends an ASF presence ping and reports whether the pong advertises
|
||||||
|
// IPMI support.
|
||||||
|
func (s *session) ping() (bool, error) {
|
||||||
|
packet := append(rmcpHeader(rmcpClassASF), 0, 0, 0, 0, asfTypePing, 0x00, 0x00, 0x00)
|
||||||
|
binary.BigEndian.PutUint32(packet[4:8], asfIANA)
|
||||||
|
reply, err := s.exchange(packet, func(reply []byte) bool {
|
||||||
|
return len(reply) >= 12 && reply[3] == rmcpClassASF && reply[8] == asfTypePong
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
// pong data: IANA (4), OEM (4), supported entities (1), interactions (1)
|
||||||
|
if len(reply) < 21 {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
return reply[20]&asfEntitiesIPMI != 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checksum(data []byte) byte {
|
||||||
|
var sum byte
|
||||||
|
for _, b := range data {
|
||||||
|
sum += b
|
||||||
|
}
|
||||||
|
return -sum
|
||||||
|
}
|
||||||
|
|
||||||
|
// command sends a session-less IPMI 1.5 request and returns the response
|
||||||
|
// data after a successful completion code.
|
||||||
|
func (s *session) command(cmd byte, data []byte) ([]byte, error) {
|
||||||
|
header := []byte{bmcAddress, netFnApp << 2}
|
||||||
|
body := append([]byte{consoleAddress, 0x00, cmd}, data...)
|
||||||
|
message := append(append(header, checksum(header)), body...)
|
||||||
|
message = append(message, checksum(body))
|
||||||
|
|
||||||
|
packet := append(rmcpHeader(rmcpClassIPMI), authTypeNone, 0, 0, 0, 0, 0, 0, 0, 0, byte(len(message)))
|
||||||
|
packet = append(packet, message...)
|
||||||
|
|
||||||
|
reply, err := s.exchange(packet, func(reply []byte) bool {
|
||||||
|
// rmcp (4), session header (10), then rqAddr, netFn, checksum,
|
||||||
|
// rsAddr, seq, cmd, completion code
|
||||||
|
// ... completion code, then at least the trailing checksum
|
||||||
|
return len(reply) >= 22 && reply[3] == rmcpClassIPMI && reply[4] == authTypeNone && reply[19] == cmd
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if code := reply[20]; code != 0 {
|
||||||
|
return nil, fmt.Errorf("ipmi command 0x%02x failed with completion code 0x%02x", cmd, code)
|
||||||
|
}
|
||||||
|
// drop the trailing checksum
|
||||||
|
return reply[21 : len(reply)-1], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// rmcpPlus sends an IPMI 2.0 session-setup payload and returns the reply
|
||||||
|
// payload of the expected type.
|
||||||
|
func (s *session) rmcpPlus(payloadType byte, payload []byte, replyType byte) ([]byte, error) {
|
||||||
|
packet := append(rmcpHeader(rmcpClassIPMI), authTypeRMCPP, payloadType, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0)
|
||||||
|
binary.LittleEndian.PutUint16(packet[14:16], uint16(len(payload)))
|
||||||
|
packet = append(packet, payload...)
|
||||||
|
|
||||||
|
reply, err := s.exchange(packet, func(reply []byte) bool {
|
||||||
|
return len(reply) >= 16 && reply[3] == rmcpClassIPMI && reply[4] == authTypeRMCPP && reply[5]&0x3f == replyType
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
length := int(binary.LittleEndian.Uint16(reply[14:16]))
|
||||||
|
if len(reply) < 16+length {
|
||||||
|
return nil, fmt.Errorf("truncated ipmi payload")
|
||||||
|
}
|
||||||
|
return reply[16 : 16+length], nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,398 @@
|
|||||||
|
package ipmi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/hmac"
|
||||||
|
"crypto/md5"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"hash"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RMCP+ algorithm numbers (IPMI v2.0 tables 13-17 to 13-19).
|
||||||
|
const (
|
||||||
|
authNone = 0x00
|
||||||
|
authHMACSHA1 = 0x01
|
||||||
|
authHMACMD5 = 0x02
|
||||||
|
|
||||||
|
integrityNone = 0x00
|
||||||
|
integrityHMACSHA196 = 0x01
|
||||||
|
|
||||||
|
confidentialityNone = 0x00
|
||||||
|
confidentialityAESCBC128 = 0x01
|
||||||
|
|
||||||
|
payloadIPMI = 0x00
|
||||||
|
payloadEncrypted = 0x80
|
||||||
|
payloadSigned = 0x40
|
||||||
|
|
||||||
|
cmdCloseSession = 0x3c
|
||||||
|
|
||||||
|
// rakp status codes worth naming in errors
|
||||||
|
rakpInvalidRole = 0x09
|
||||||
|
rakpUnauthorizedName = 0x0d
|
||||||
|
)
|
||||||
|
|
||||||
|
type algorithms struct {
|
||||||
|
auth, integrity, confidentiality byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// cipherSuites lists the suites a session can be opened with. Suites with
|
||||||
|
// SHA-256 or MD5 message integrity are left out until they can be checked
|
||||||
|
// against a BMC that implements them correctly.
|
||||||
|
var cipherSuites = map[int]algorithms{
|
||||||
|
0: {authNone, integrityNone, confidentialityNone},
|
||||||
|
1: {authHMACSHA1, integrityNone, confidentialityNone},
|
||||||
|
2: {authHMACSHA1, integrityHMACSHA196, confidentialityNone},
|
||||||
|
3: {authHMACSHA1, integrityHMACSHA196, confidentialityAESCBC128},
|
||||||
|
6: {authHMACMD5, integrityNone, confidentialityNone},
|
||||||
|
}
|
||||||
|
|
||||||
|
func authHash(auth byte) func() hash.Hash {
|
||||||
|
switch auth {
|
||||||
|
case authHMACSHA1:
|
||||||
|
return sha1.New
|
||||||
|
case authHMACMD5:
|
||||||
|
return md5.New
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func hmacOf(newHash func() hash.Hash, key []byte, parts ...[]byte) []byte {
|
||||||
|
mac := hmac.New(newHash, key)
|
||||||
|
for _, part := range parts {
|
||||||
|
_, _ = mac.Write(part)
|
||||||
|
}
|
||||||
|
return mac.Sum(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handshake is the state of an RMCP+ session setup up to RAKP message 2.
|
||||||
|
type handshake struct {
|
||||||
|
algorithms
|
||||||
|
consoleSessionID, bmcSessionID []byte
|
||||||
|
consoleRandom, bmcRandom []byte
|
||||||
|
bmcGUID []byte
|
||||||
|
role byte
|
||||||
|
username string
|
||||||
|
// bmcMAC is the RAKP message 2 key exchange code, empty without
|
||||||
|
// authentication.
|
||||||
|
bmcMAC []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// rakpError is a non-zero status in an RMCP+ session setup reply.
|
||||||
|
type rakpError struct {
|
||||||
|
step string
|
||||||
|
status byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *rakpError) Error() string {
|
||||||
|
switch e.status {
|
||||||
|
case rakpUnauthorizedName:
|
||||||
|
return fmt.Sprintf("bmc rejected the username in %s (status 0x%02x)", e.step, e.status)
|
||||||
|
case rakpInvalidRole:
|
||||||
|
return fmt.Sprintf("bmc rejected the requested privilege in %s (status 0x%02x)", e.step, e.status)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("bmc refused %s with status 0x%02x", e.step, e.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// startSession opens an RMCP+ session and runs RAKP messages 1 and 2.
|
||||||
|
func (s *session) startSession(alg algorithms, username string, privilege byte) (*handshake, error) {
|
||||||
|
if len(username) > 16 {
|
||||||
|
return nil, fmt.Errorf("ipmi usernames are at most 16 bytes")
|
||||||
|
}
|
||||||
|
h := &handshake{
|
||||||
|
algorithms: alg,
|
||||||
|
consoleSessionID: make([]byte, 4),
|
||||||
|
consoleRandom: make([]byte, 16),
|
||||||
|
role: privilege | rakpNameOnlyLookup,
|
||||||
|
username: username,
|
||||||
|
}
|
||||||
|
if _, err := rand.Read(h.consoleSessionID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := rand.Read(h.consoleRandom); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
open := []byte{0x00, privilege, 0x00, 0x00}
|
||||||
|
open = append(open, h.consoleSessionID...)
|
||||||
|
open = append(open,
|
||||||
|
0x00, 0x00, 0x00, 0x08, alg.auth, 0x00, 0x00, 0x00,
|
||||||
|
0x01, 0x00, 0x00, 0x08, alg.integrity, 0x00, 0x00, 0x00,
|
||||||
|
0x02, 0x00, 0x00, 0x08, alg.confidentiality, 0x00, 0x00, 0x00,
|
||||||
|
)
|
||||||
|
opened, err := s.rmcpPlus(payloadOpenSessionRequest, open, payloadOpenSessionResponse)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(opened) < 2 {
|
||||||
|
return nil, fmt.Errorf("short open session response")
|
||||||
|
}
|
||||||
|
if opened[1] != 0 {
|
||||||
|
return nil, &rakpError{step: "the session", status: opened[1]}
|
||||||
|
}
|
||||||
|
if len(opened) < 12 {
|
||||||
|
return nil, fmt.Errorf("short open session response")
|
||||||
|
}
|
||||||
|
h.bmcSessionID = bytes.Clone(opened[8:12])
|
||||||
|
|
||||||
|
rakp1 := []byte{0x00, 0x00, 0x00, 0x00}
|
||||||
|
rakp1 = append(rakp1, h.bmcSessionID...)
|
||||||
|
rakp1 = append(rakp1, h.consoleRandom...)
|
||||||
|
rakp1 = append(rakp1, h.role, 0x00, 0x00, byte(len(username)))
|
||||||
|
rakp1 = append(rakp1, username...)
|
||||||
|
rakp2, err := s.rmcpPlus(payloadRAKP1, rakp1, payloadRAKP2)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(rakp2) < 2 {
|
||||||
|
return nil, fmt.Errorf("short rakp message 2")
|
||||||
|
}
|
||||||
|
if rakp2[1] != 0 {
|
||||||
|
return nil, &rakpError{step: "rakp message 1", status: rakp2[1]}
|
||||||
|
}
|
||||||
|
if len(rakp2) < 40 {
|
||||||
|
return nil, fmt.Errorf("short rakp message 2")
|
||||||
|
}
|
||||||
|
h.bmcRandom = bytes.Clone(rakp2[8:24])
|
||||||
|
h.bmcGUID = bytes.Clone(rakp2[24:40])
|
||||||
|
h.bmcMAC = bytes.Clone(rakp2[40:])
|
||||||
|
return h, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// rakp2Salt is everything the RAKP message 2 code covers.
|
||||||
|
func (h *handshake) rakp2Salt() []byte {
|
||||||
|
salt := make([]byte, 0, 58+len(h.username))
|
||||||
|
salt = append(salt, h.consoleSessionID...)
|
||||||
|
salt = append(salt, h.bmcSessionID...)
|
||||||
|
salt = append(salt, h.consoleRandom...)
|
||||||
|
salt = append(salt, h.bmcRandom...)
|
||||||
|
salt = append(salt, h.bmcGUID...)
|
||||||
|
salt = append(salt, h.role, byte(len(h.username)))
|
||||||
|
return append(salt, h.username...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// keys are the session keys derived from the session integrity key.
|
||||||
|
type keys struct {
|
||||||
|
integrity []byte // K1
|
||||||
|
aes []byte // first 16 bytes of K2
|
||||||
|
}
|
||||||
|
|
||||||
|
// finishSession runs RAKP messages 3 and 4, which is where a wrong password
|
||||||
|
// is rejected, and derives the session keys. Both directions are proven:
|
||||||
|
// the BMC's RAKP message 2 code and RAKP message 4 integrity value must
|
||||||
|
// match the password, so a peer that only answers with success statuses
|
||||||
|
// cannot pass for a login.
|
||||||
|
func (s *session) finishSession(h *handshake, password string) (*keys, error) {
|
||||||
|
if len(password) > 20 {
|
||||||
|
return nil, fmt.Errorf("ipmi passwords are at most 20 bytes")
|
||||||
|
}
|
||||||
|
newHash := authHash(h.auth)
|
||||||
|
if newHash != nil && !hmac.Equal(h.bmcMAC, hmacOf(newHash, []byte(password), h.rakp2Salt())) {
|
||||||
|
return nil, errBMCProof
|
||||||
|
}
|
||||||
|
|
||||||
|
rakp3 := []byte{0x00, 0x00, 0x00, 0x00}
|
||||||
|
rakp3 = append(rakp3, h.bmcSessionID...)
|
||||||
|
if newHash != nil {
|
||||||
|
rakp3 = append(rakp3, hmacOf(newHash, []byte(password),
|
||||||
|
h.bmcRandom, h.consoleSessionID, []byte{h.role, byte(len(h.username))}, []byte(h.username))...)
|
||||||
|
}
|
||||||
|
rakp4, err := s.rmcpPlus(payloadRAKP3, rakp3, payloadRAKP4)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(rakp4) < 2 {
|
||||||
|
return nil, fmt.Errorf("short rakp message 4")
|
||||||
|
}
|
||||||
|
if rakp4[1] != 0 {
|
||||||
|
return nil, &rakpError{step: "the login", status: rakp4[1]}
|
||||||
|
}
|
||||||
|
if newHash == nil {
|
||||||
|
return &keys{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
sik := hmacOf(newHash, []byte(password),
|
||||||
|
h.consoleRandom, h.bmcRandom, []byte{h.role, byte(len(h.username))}, []byte(h.username))
|
||||||
|
if !rakp4Valid(newHash, sik, h, rakp4) {
|
||||||
|
return nil, errBMCProof
|
||||||
|
}
|
||||||
|
return &keys{
|
||||||
|
integrity: hmacOf(newHash, sik, bytes.Repeat([]byte{0x01}, 20)),
|
||||||
|
aes: hmacOf(newHash, sik, bytes.Repeat([]byte{0x02}, 20))[:16],
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// errBMCProof means the peer's authentication code does not match the
|
||||||
|
// password: a wrong password, or something other than the BMC answering.
|
||||||
|
var errBMCProof = errors.New("bmc authentication code does not match the password")
|
||||||
|
|
||||||
|
// rakp4Valid checks the RAKP message 4 integrity value, the session key's
|
||||||
|
// HMAC over the console random, BMC session ID and BMC GUID, truncated to
|
||||||
|
// 12 bytes for sha1 and 16 for md5.
|
||||||
|
func rakp4Valid(newHash func() hash.Hash, sik []byte, h *handshake, rakp4 []byte) bool {
|
||||||
|
size := 12
|
||||||
|
if h.auth == authHMACMD5 {
|
||||||
|
size = 16
|
||||||
|
}
|
||||||
|
if len(rakp4) < 8+size {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
want := hmacOf(newHash, sik, h.consoleRandom, h.bmcSessionID, h.bmcGUID)[:size]
|
||||||
|
return hmac.Equal(rakp4[8:8+size], want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// established is an authenticated RMCP+ session.
|
||||||
|
type established struct {
|
||||||
|
*session
|
||||||
|
algorithms
|
||||||
|
keys *keys
|
||||||
|
bmcSessionID []byte
|
||||||
|
consoleSessionID []byte
|
||||||
|
sequence uint32
|
||||||
|
requestSeq byte
|
||||||
|
}
|
||||||
|
|
||||||
|
// command sends an IPMI request inside the session and returns its
|
||||||
|
// completion code and response data.
|
||||||
|
func (e *established) command(netFn, cmd byte, data []byte) (byte, []byte, error) {
|
||||||
|
// the request sequence is 6 bits on the wire
|
||||||
|
e.requestSeq = (e.requestSeq + 1) & 0x3f
|
||||||
|
header := []byte{bmcAddress, netFn << 2}
|
||||||
|
body := append([]byte{consoleAddress, e.requestSeq << 2, cmd}, data...)
|
||||||
|
message := append(append(header, checksum(header)), body...)
|
||||||
|
message = append(message, checksum(body))
|
||||||
|
|
||||||
|
payloadType := byte(payloadIPMI)
|
||||||
|
if e.confidentiality == confidentialityAESCBC128 {
|
||||||
|
payloadType |= payloadEncrypted
|
||||||
|
var err error
|
||||||
|
if message, err = encryptPayload(e.keys.aes, message); err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if e.integrity != integrityNone {
|
||||||
|
payloadType |= payloadSigned
|
||||||
|
}
|
||||||
|
|
||||||
|
e.sequence++
|
||||||
|
packet := append(rmcpHeader(rmcpClassIPMI), authTypeRMCPP, payloadType)
|
||||||
|
packet = append(packet, e.bmcSessionID...)
|
||||||
|
packet = binary.LittleEndian.AppendUint32(packet, e.sequence)
|
||||||
|
packet = binary.LittleEndian.AppendUint16(packet, uint16(len(message)))
|
||||||
|
packet = append(packet, message...)
|
||||||
|
if e.integrity != integrityNone {
|
||||||
|
packet = e.sign(packet)
|
||||||
|
}
|
||||||
|
|
||||||
|
// a reply must be for this session, pass the integrity check when the
|
||||||
|
// session is signed, and answer this request, not an earlier one that
|
||||||
|
// arrived late
|
||||||
|
var payload []byte
|
||||||
|
_, err := e.exchange(packet, func(reply []byte) bool {
|
||||||
|
payload = e.open(reply)
|
||||||
|
return len(payload) >= 8 && payload[4]>>2 == e.requestSeq && payload[5] == cmd
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
// rqAddr, netFn, checksum, rsAddr, seq, cmd, completion code, data,
|
||||||
|
// checksum
|
||||||
|
return payload[6], bytes.Clone(payload[7 : len(payload)-1]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// open returns the IPMI message in a session reply, or nil when the reply
|
||||||
|
// is not for this session or fails its integrity check.
|
||||||
|
func (e *established) open(reply []byte) []byte {
|
||||||
|
if len(reply) < 16 || reply[3] != rmcpClassIPMI || reply[4] != authTypeRMCPP || reply[5]&0x3f != payloadIPMI {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !bytes.Equal(reply[6:10], e.consoleSessionID) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
length := int(binary.LittleEndian.Uint16(reply[14:16]))
|
||||||
|
if len(reply) < 16+length {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if e.integrity != integrityNone {
|
||||||
|
if reply[5]&payloadSigned == 0 || !e.verify(reply, 16+length) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
payload := reply[16 : 16+length]
|
||||||
|
if reply[5]&payloadEncrypted != 0 {
|
||||||
|
var err error
|
||||||
|
if payload, err = decryptPayload(e.keys.aes, payload); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
// verify checks a reply's HMAC-SHA1-96 trailer: the pad after the payload,
|
||||||
|
// pad length and next header, then the code over everything from the auth
|
||||||
|
// type on.
|
||||||
|
func (e *established) verify(reply []byte, payloadEnd int) bool {
|
||||||
|
const codeSize = 12
|
||||||
|
if len(reply) < payloadEnd+2+codeSize {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
codeStart := len(reply) - codeSize
|
||||||
|
if reply[codeStart-1] != 0x07 || int(reply[codeStart-2]) != codeStart-2-payloadEnd {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
want := hmacOf(sha1.New, e.keys.integrity, reply[4:codeStart])[:codeSize]
|
||||||
|
return hmac.Equal(reply[codeStart:], want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// sign appends the HMAC-SHA1-96 integrity trailer: pad to a multiple of 4 from the auth
|
||||||
|
// type on, pad length, next header, and the truncated HMAC over it all.
|
||||||
|
func (e *established) sign(packet []byte) []byte {
|
||||||
|
pad := (4 - (len(packet)-4+2)%4) % 4
|
||||||
|
packet = append(packet, bytes.Repeat([]byte{0xff}, pad)...)
|
||||||
|
packet = append(packet, byte(pad), 0x07)
|
||||||
|
return append(packet, hmacOf(sha1.New, e.keys.integrity, packet[4:])[:12]...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// encryptPayload is AES-CBC-128 with a random IV in front and the
|
||||||
|
// confidentiality pad (1, 2, 3, ... then its length) at the end.
|
||||||
|
func encryptPayload(key, payload []byte) ([]byte, error) {
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
pad := (aes.BlockSize - (len(payload)+1)%aes.BlockSize) % aes.BlockSize
|
||||||
|
plain := bytes.Clone(payload)
|
||||||
|
for i := 1; i <= pad; i++ {
|
||||||
|
plain = append(plain, byte(i))
|
||||||
|
}
|
||||||
|
plain = append(plain, byte(pad))
|
||||||
|
|
||||||
|
out := make([]byte, aes.BlockSize+len(plain))
|
||||||
|
if _, err := rand.Read(out[:aes.BlockSize]); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cipher.NewCBCEncrypter(block, out[:aes.BlockSize]).CryptBlocks(out[aes.BlockSize:], plain)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decryptPayload(key, payload []byte) ([]byte, error) {
|
||||||
|
if len(payload) < 2*aes.BlockSize || len(payload)%aes.BlockSize != 0 {
|
||||||
|
return nil, fmt.Errorf("malformed encrypted payload")
|
||||||
|
}
|
||||||
|
block, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
plain := make([]byte, len(payload)-aes.BlockSize)
|
||||||
|
cipher.NewCBCDecrypter(block, payload[:aes.BlockSize]).CryptBlocks(plain, payload[aes.BlockSize:])
|
||||||
|
pad := int(plain[len(plain)-1])
|
||||||
|
if pad+1 > len(plain) {
|
||||||
|
return nil, fmt.Errorf("malformed encrypted payload")
|
||||||
|
}
|
||||||
|
return plain[:len(plain)-pad-1], nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,596 @@
|
|||||||
|
package mqtt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
libnet "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/net"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
defaultTimeout = 10 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxSubscribedMessages = 1000
|
||||||
|
maxSubscribedBytes = 10 * 1024 * 1024 // 10MB
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
// Message represents an MQTT published message.
|
||||||
|
// @example
|
||||||
|
// ```javascript
|
||||||
|
// const mqtt = require('nuclei/mqtt');
|
||||||
|
// const client = new mqtt.Client();
|
||||||
|
// client.Connect('broker.hivemq.com', 1883);
|
||||||
|
// const msgs = client.Subscribe('test/topic', 2);
|
||||||
|
// log(msgs[0].Topic, msgs[0].Payload);
|
||||||
|
// ```
|
||||||
|
Message struct {
|
||||||
|
Topic string `json:"topic"`
|
||||||
|
Payload string `json:"payload"`
|
||||||
|
QoS int `json:"qos"`
|
||||||
|
Retain bool `json:"retain"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectOptions contains connection options for an MQTT broker.
|
||||||
|
// @example
|
||||||
|
// ```javascript
|
||||||
|
// const mqtt = require('nuclei/mqtt');
|
||||||
|
// const opts = new mqtt.ConnectOptions();
|
||||||
|
// opts.Username = 'admin';
|
||||||
|
// opts.Password = 'admin';
|
||||||
|
// ```
|
||||||
|
ConnectOptions struct {
|
||||||
|
Username string `json:"username,omitempty"`
|
||||||
|
Password string `json:"password,omitempty"`
|
||||||
|
ClientID string `json:"client_id,omitempty"`
|
||||||
|
CleanSession *bool `json:"clean_session,omitempty"`
|
||||||
|
KeepAlive int `json:"keep_alive,omitempty"`
|
||||||
|
TLS bool `json:"tls,omitempty"`
|
||||||
|
InsecureSkipVerify *bool `json:"insecure_skip_verify,omitempty"`
|
||||||
|
Timeout int `json:"timeout,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectResult is the response returned by an MQTT connection attempt.
|
||||||
|
// @example
|
||||||
|
// ```javascript
|
||||||
|
// const mqtt = require('nuclei/mqtt');
|
||||||
|
// const client = new mqtt.Client();
|
||||||
|
// const res = client.Connect('acme.com', 1883);
|
||||||
|
// log(res.Success, res.ReturnCode, res.Reason);
|
||||||
|
// ```
|
||||||
|
ConnectResult struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
ReturnCode int `json:"return_code"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
SessionPresent bool `json:"session_present"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client is an MQTT client for testing MQTT broker misconfigurations and authentication.
|
||||||
|
// @example
|
||||||
|
// ```javascript
|
||||||
|
// const mqtt = require('nuclei/mqtt');
|
||||||
|
// const client = new mqtt.Client();
|
||||||
|
// const res = client.Connect('acme.com', 1883);
|
||||||
|
// if (res.Success) {
|
||||||
|
// const msgs = client.Subscribe('#', 2);
|
||||||
|
// client.Publish('nuclei/probe', 'test');
|
||||||
|
// }
|
||||||
|
// client.Close();
|
||||||
|
// ```
|
||||||
|
Client struct {
|
||||||
|
conn net.Conn
|
||||||
|
packetID uint16
|
||||||
|
mu sync.Mutex
|
||||||
|
timeout time.Duration
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// dial opens a network connection to host:port respecting protocolstate policy and context.
|
||||||
|
func dial(ctx context.Context, host string, port int, useTLS bool, insecureSkipVerify bool, timeout time.Duration) (net.Conn, error) {
|
||||||
|
if host == "" || port <= 0 {
|
||||||
|
return nil, fmt.Errorf("invalid host or port")
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := net.JoinHostPort(host, strconv.Itoa(port))
|
||||||
|
conn, err := libnet.Dial(ctx, "tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(timeout))
|
||||||
|
|
||||||
|
if useTLS {
|
||||||
|
tlsConn := tls.Client(conn, &tls.Config{
|
||||||
|
InsecureSkipVerify: insecureSkipVerify,
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
ServerName: host,
|
||||||
|
})
|
||||||
|
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return nil, fmt.Errorf("TLS handshake failed: %w", err)
|
||||||
|
}
|
||||||
|
conn = tlsConn
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(timeout))
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsMQTT checks whether the target host:port is running an MQTT broker.
|
||||||
|
// @example
|
||||||
|
// ```javascript
|
||||||
|
// const mqtt = require('nuclei/mqtt');
|
||||||
|
// const isMqtt = mqtt.IsMQTT('acme.com', 1883);
|
||||||
|
// log(isMqtt);
|
||||||
|
// ```
|
||||||
|
func IsMQTT(ctx context.Context, host string, port int) (bool, error) {
|
||||||
|
conn, err := dial(ctx, host, port, false, true, defaultTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer func() { _ = conn.Close() }()
|
||||||
|
|
||||||
|
pkt, err := buildConnectPacket("nuclei-probe", "", "", true, 60)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
_ = conn.SetWriteDeadline(time.Now().Add(defaultTimeout))
|
||||||
|
if _, err := conn.Write(pkt); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
header, payload, err := readPacket(conn)
|
||||||
|
if err != nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any valid CONNACK indicates an active MQTT broker
|
||||||
|
if (header >> 4) == PacketConnack {
|
||||||
|
_, _, err := parseConnack(payload)
|
||||||
|
return err == nil, nil
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAnonymousAllowed checks whether an MQTT broker permits unauthenticated / anonymous login.
|
||||||
|
// @example
|
||||||
|
// ```javascript
|
||||||
|
// const mqtt = require('nuclei/mqtt');
|
||||||
|
// const allowed = mqtt.IsAnonymousAllowed('acme.com', 1883);
|
||||||
|
// log(allowed);
|
||||||
|
// ```
|
||||||
|
func IsAnonymousAllowed(ctx context.Context, host string, port int) (bool, error) {
|
||||||
|
conn, err := dial(ctx, host, port, false, true, defaultTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
defer func() { _ = conn.Close() }()
|
||||||
|
|
||||||
|
pkt, err := buildConnectPacket("", "", "", true, 60)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
_ = conn.SetWriteDeadline(time.Now().Add(defaultTimeout))
|
||||||
|
if _, err := conn.Write(pkt); err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
header, payload, err := readPacket(conn)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if (header >> 4) != PacketConnack {
|
||||||
|
return false, fmt.Errorf("expected CONNACK, got packet type %d", header>>4)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, returnCode, err := parseConnack(payload)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return returnCode == ConnAccepted, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connect connects to an MQTT broker using optional credentials and settings.
|
||||||
|
// @example
|
||||||
|
// ```javascript
|
||||||
|
// const mqtt = require('nuclei/mqtt');
|
||||||
|
// const client = new mqtt.Client();
|
||||||
|
// const res = client.Connect('acme.com', 1883);
|
||||||
|
// log(res.Success);
|
||||||
|
// ```
|
||||||
|
func (c *Client) Connect(ctx context.Context, host string, port int, options *ConnectOptions) (*ConnectResult, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if c.conn != nil {
|
||||||
|
_ = c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := defaultTimeout
|
||||||
|
cleanSession := true
|
||||||
|
keepAlive := uint16(60)
|
||||||
|
clientID := "nuclei"
|
||||||
|
var username, password string
|
||||||
|
useTLS := false
|
||||||
|
insecureSkipVerify := true
|
||||||
|
|
||||||
|
if options != nil {
|
||||||
|
if options.Timeout > 0 {
|
||||||
|
timeout = time.Duration(options.Timeout) * time.Second
|
||||||
|
}
|
||||||
|
if options.KeepAlive > 0 {
|
||||||
|
if options.KeepAlive > 65535 {
|
||||||
|
keepAlive = 65535
|
||||||
|
} else {
|
||||||
|
keepAlive = uint16(options.KeepAlive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if options.ClientID != "" {
|
||||||
|
clientID = options.ClientID
|
||||||
|
}
|
||||||
|
if options.CleanSession != nil {
|
||||||
|
cleanSession = *options.CleanSession
|
||||||
|
}
|
||||||
|
if options.InsecureSkipVerify != nil {
|
||||||
|
insecureSkipVerify = *options.InsecureSkipVerify
|
||||||
|
}
|
||||||
|
username = options.Username
|
||||||
|
password = options.Password
|
||||||
|
useTLS = options.TLS
|
||||||
|
}
|
||||||
|
|
||||||
|
c.timeout = timeout
|
||||||
|
conn, err := dial(ctx, host, port, useTLS, insecureSkipVerify, timeout)
|
||||||
|
if err != nil {
|
||||||
|
return &ConnectResult{
|
||||||
|
Success: false,
|
||||||
|
ReturnCode: -1,
|
||||||
|
Reason: err.Error(),
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pkt, err := buildConnectPacket(clientID, username, password, cleanSession, keepAlive)
|
||||||
|
if err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return &ConnectResult{
|
||||||
|
Success: false,
|
||||||
|
ReturnCode: -1,
|
||||||
|
Reason: err.Error(),
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = conn.SetWriteDeadline(time.Now().Add(timeout))
|
||||||
|
if _, err := conn.Write(pkt); err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return &ConnectResult{
|
||||||
|
Success: false,
|
||||||
|
ReturnCode: -1,
|
||||||
|
Reason: fmt.Sprintf("failed to send CONNECT: %v", err),
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
header, payload, err := readPacket(conn)
|
||||||
|
if err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return &ConnectResult{
|
||||||
|
Success: false,
|
||||||
|
ReturnCode: -1,
|
||||||
|
Reason: fmt.Sprintf("failed to read CONNACK: %v", err),
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if (header >> 4) != PacketConnack {
|
||||||
|
_ = conn.Close()
|
||||||
|
return &ConnectResult{
|
||||||
|
Success: false,
|
||||||
|
ReturnCode: -1,
|
||||||
|
Reason: fmt.Sprintf("unexpected packet %d (expected CONNACK)", header>>4),
|
||||||
|
}, fmt.Errorf("unexpected packet %d", header>>4)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessPresent, returnCode, err := parseConnack(payload)
|
||||||
|
if err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
|
return &ConnectResult{
|
||||||
|
Success: false,
|
||||||
|
ReturnCode: int(returnCode),
|
||||||
|
Reason: err.Error(),
|
||||||
|
}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
res := &ConnectResult{
|
||||||
|
Success: returnCode == ConnAccepted,
|
||||||
|
ReturnCode: int(returnCode),
|
||||||
|
Reason: ConnackReason(returnCode),
|
||||||
|
SessionPresent: sessPresent,
|
||||||
|
}
|
||||||
|
|
||||||
|
if res.Success {
|
||||||
|
c.conn = conn
|
||||||
|
} else {
|
||||||
|
_ = conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConnectAuth connects to an MQTT broker with a username and password.
|
||||||
|
// @example
|
||||||
|
// ```javascript
|
||||||
|
// const mqtt = require('nuclei/mqtt');
|
||||||
|
// const client = new mqtt.Client();
|
||||||
|
// const res = client.ConnectAuth('acme.com', 1883, 'admin', 'admin');
|
||||||
|
// log(res.Success);
|
||||||
|
// ```
|
||||||
|
func (c *Client) ConnectAuth(ctx context.Context, host string, port int, username, password string) (*ConnectResult, error) {
|
||||||
|
clean := true
|
||||||
|
return c.Connect(ctx, host, port, &ConnectOptions{
|
||||||
|
Username: username,
|
||||||
|
Password: password,
|
||||||
|
CleanSession: &clean,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe subscribes to a topic filter and collects received messages up to timeoutSec.
|
||||||
|
// @example
|
||||||
|
// ```javascript
|
||||||
|
// const mqtt = require('nuclei/mqtt');
|
||||||
|
// const client = new mqtt.Client();
|
||||||
|
// client.Connect('acme.com', 1883);
|
||||||
|
// const msgs = client.Subscribe('#', 3);
|
||||||
|
//
|
||||||
|
// for (const m of msgs) {
|
||||||
|
// log(m.Topic, m.Payload);
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// ```
|
||||||
|
func (c *Client) Subscribe(ctx context.Context, topic string, timeoutSec int) ([]*Message, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if c.conn == nil {
|
||||||
|
return nil, fmt.Errorf("not connected to MQTT broker")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.packetID++
|
||||||
|
if c.packetID == 0 {
|
||||||
|
c.packetID = 1
|
||||||
|
}
|
||||||
|
pktID := c.packetID
|
||||||
|
|
||||||
|
subPkt, err := buildSubscribePacket(pktID, topic, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to build SUBSCRIBE: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = c.conn.SetWriteDeadline(time.Now().Add(c.timeout))
|
||||||
|
if _, err := c.conn.Write(subPkt); err != nil {
|
||||||
|
_ = c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
return nil, fmt.Errorf("failed to send SUBSCRIBE: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
waitDuration := 2 * time.Second
|
||||||
|
if timeoutSec > 0 {
|
||||||
|
if timeoutSec > 60 {
|
||||||
|
timeoutSec = 60
|
||||||
|
}
|
||||||
|
waitDuration = time.Duration(timeoutSec) * time.Second
|
||||||
|
}
|
||||||
|
if deadline, ok := ctx.Deadline(); ok {
|
||||||
|
if remaining := time.Until(deadline); remaining < waitDuration {
|
||||||
|
waitDuration = remaining
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
readDeadline := time.Now().Add(waitDuration)
|
||||||
|
_ = c.conn.SetDeadline(readDeadline)
|
||||||
|
defer func() {
|
||||||
|
if c.conn != nil {
|
||||||
|
_ = c.conn.SetDeadline(time.Time{})
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
var messages []*Message
|
||||||
|
var totalBytes int
|
||||||
|
subackReceived := false
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
_ = c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
return messages, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
header, payload, err := readPacket(c.conn)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, errBrokenStream) {
|
||||||
|
// The window closed in the middle of a packet. The stream cannot be
|
||||||
|
// read in sync again, so the connection is dropped.
|
||||||
|
_ = c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
if !subackReceived {
|
||||||
|
return nil, fmt.Errorf("failed waiting for SUBACK: %w", err)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
var netErr net.Error
|
||||||
|
if errors.As(err, &netErr) && netErr.Timeout() {
|
||||||
|
// Window finished
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !subackReceived {
|
||||||
|
_ = c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
return nil, fmt.Errorf("failed waiting for SUBACK: %w", err)
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
packetType := header >> 4
|
||||||
|
switch packetType {
|
||||||
|
case PacketSuback:
|
||||||
|
subackPID, grantedQoS, err := parseSuback(payload)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if subackPID != pktID {
|
||||||
|
continue // Ignore stale/mismatched SUBACK from an earlier subscription
|
||||||
|
}
|
||||||
|
for _, qos := range grantedQoS {
|
||||||
|
if qos == 0x80 {
|
||||||
|
return nil, fmt.Errorf("subscription rejected by broker for topic %q", topic)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
subackReceived = true
|
||||||
|
|
||||||
|
case PacketPublish:
|
||||||
|
msg, err := parsePublish(header, payload)
|
||||||
|
if err == nil {
|
||||||
|
messages = append(messages, msg)
|
||||||
|
totalBytes += len(msg.Topic) + len(msg.Payload)
|
||||||
|
if len(messages) >= maxSubscribedMessages || totalBytes >= maxSubscribedBytes {
|
||||||
|
return messages, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if time.Now().After(readDeadline) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !subackReceived {
|
||||||
|
_ = c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
return nil, fmt.Errorf("subscription timed out waiting for SUBACK")
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Publish publishes a message payload to a topic.
|
||||||
|
// @example
|
||||||
|
// ```javascript
|
||||||
|
// const mqtt = require('nuclei/mqtt');
|
||||||
|
// const client = new mqtt.Client();
|
||||||
|
// client.Connect('acme.com', 1883);
|
||||||
|
// const ok = client.Publish('nuclei/test', 'probe payload');
|
||||||
|
// log(ok);
|
||||||
|
// ```
|
||||||
|
func (c *Client) Publish(ctx context.Context, topic string, payload string) (bool, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if c.conn == nil {
|
||||||
|
return false, fmt.Errorf("not connected to MQTT broker")
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return false, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
pkt, err := buildPublishPacket(topic, []byte(payload), 0, false, 0)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to build PUBLISH: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = c.conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
if _, err := c.conn.Write(pkt); err != nil {
|
||||||
|
_ = c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
return false, fmt.Errorf("failed to send PUBLISH: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ping sends an MQTT PINGREQ and awaits PINGRESP.
|
||||||
|
// @example
|
||||||
|
// ```javascript
|
||||||
|
// const mqtt = require('nuclei/mqtt');
|
||||||
|
// const client = new mqtt.Client();
|
||||||
|
// client.Connect('acme.com', 1883);
|
||||||
|
// const alive = client.Ping();
|
||||||
|
// log(alive);
|
||||||
|
// ```
|
||||||
|
func (c *Client) Ping(ctx context.Context) (bool, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if c.conn == nil {
|
||||||
|
return false, fmt.Errorf("not connected to MQTT broker")
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return false, ctx.Err()
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = c.conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
_ = c.conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
|
||||||
|
if _, err := c.conn.Write(buildPingreqPacket()); err != nil {
|
||||||
|
_ = c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
return false, fmt.Errorf("failed to send PINGREQ: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
header, _, err := readPacket(c.conn)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
return false, fmt.Errorf("failed to read PINGRESP: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
packetType := header >> 4
|
||||||
|
if packetType == PacketPingresp {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if packetType == PacketPublish {
|
||||||
|
// Broker delivered an incoming publish message while ping was in flight; continue waiting for PINGRESP
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return false, fmt.Errorf("unexpected ping response packet: %d", packetType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close cleanly disconnects and closes the connection.
|
||||||
|
// @example
|
||||||
|
// ```javascript
|
||||||
|
// const mqtt = require('nuclei/mqtt');
|
||||||
|
// const client = new mqtt.Client();
|
||||||
|
// client.Connect('acme.com', 1883);
|
||||||
|
// client.Close();
|
||||||
|
// ```
|
||||||
|
func (c *Client) Close() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
|
if c.conn != nil {
|
||||||
|
_ = c.conn.SetWriteDeadline(time.Now().Add(3 * time.Second))
|
||||||
|
_, _ = c.conn.Write(buildDisconnectPacket())
|
||||||
|
err := c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,395 @@
|
|||||||
|
package mqtt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestVarLengthEncoding(t *testing.T) {
|
||||||
|
testCases := []int{0, 1, 64, 127, 128, 16383, 16384, 2097151}
|
||||||
|
for _, tc := range testCases {
|
||||||
|
encoded, err := encodeVarLength(tc)
|
||||||
|
require.NoError(t, err)
|
||||||
|
decoded, err := decodeVarLength(bytes.NewReader(encoded))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, tc, decoded)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test negative or overflow
|
||||||
|
_, err := encodeVarLength(-1)
|
||||||
|
require.Error(t, err)
|
||||||
|
_, err = encodeVarLength(MaxRemainingLength + 1)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOversizedStringsAndPackets(t *testing.T) {
|
||||||
|
hugeString := strings.Repeat("A", MaxStringLength+1)
|
||||||
|
_, err := encodeString(hugeString)
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
_, err = buildConnectPacket("id", hugeString, "pass", true, 60)
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
_, err = buildSubscribePacket(1, hugeString, 0)
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
_, err = buildPublishPacket(hugeString, []byte("ok"), 0, false, 0)
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
// Oversized packet reading
|
||||||
|
oversizedLen, err := encodeVarLength(MaxPacketSize + 100)
|
||||||
|
require.NoError(t, err)
|
||||||
|
buf := append([]byte{PacketPublish << 4}, oversizedLen...)
|
||||||
|
_, _, err = readPacket(bytes.NewReader(buf))
|
||||||
|
require.ErrorContains(t, err, "exceeds maximum allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPacketConnect(t *testing.T) {
|
||||||
|
pkt, err := buildConnectPacket("test-client", "user1", "pass1", true, 60)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, pkt)
|
||||||
|
require.Equal(t, byte(PacketConnect<<4), pkt[0])
|
||||||
|
|
||||||
|
r := bytes.NewReader(pkt)
|
||||||
|
header, payload, err := readPacket(r)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, byte(PacketConnect<<4), header)
|
||||||
|
|
||||||
|
// Verify protocol name "MQTT"
|
||||||
|
protoLen := binary.BigEndian.Uint16(payload[0:2])
|
||||||
|
require.Equal(t, uint16(4), protoLen)
|
||||||
|
require.Equal(t, "MQTT", string(payload[2:6]))
|
||||||
|
require.Equal(t, byte(0x04), payload[6]) // Protocol level 4 (MQTT 3.1.1)
|
||||||
|
|
||||||
|
// CleanSession (0x02) | User (0x80) | Pass (0x40) = 0xC2
|
||||||
|
require.Equal(t, byte(0xC2), payload[7])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPacketPublishAndParse(t *testing.T) {
|
||||||
|
pkt, err := buildPublishPacket("sensors/temperature", []byte("23.5C"), 0, true, 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, pkt)
|
||||||
|
|
||||||
|
r := bytes.NewReader(pkt)
|
||||||
|
header, payload, err := readPacket(r)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
msg, err := parsePublish(header, payload)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "sensors/temperature", msg.Topic)
|
||||||
|
require.Equal(t, "23.5C", msg.Payload)
|
||||||
|
require.Equal(t, 0, msg.QoS)
|
||||||
|
require.True(t, msg.Retain)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPacketSubscribe(t *testing.T) {
|
||||||
|
pkt, err := buildSubscribePacket(42, "devices/#", 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, pkt)
|
||||||
|
require.Equal(t, byte((PacketSubscribe<<4)|0x02), pkt[0])
|
||||||
|
|
||||||
|
r := bytes.NewReader(pkt)
|
||||||
|
header, payload, err := readPacket(r)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, byte((PacketSubscribe<<4)|0x02), header)
|
||||||
|
|
||||||
|
pid := binary.BigEndian.Uint16(payload[0:2])
|
||||||
|
require.Equal(t, uint16(42), pid)
|
||||||
|
|
||||||
|
subackPayload := []byte{0x00, 0x2A, 0x00} // PID 42, Success QoS 0
|
||||||
|
subackPid, granted, err := parseSuback(subackPayload)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, uint16(42), subackPid)
|
||||||
|
require.Equal(t, []byte{0x00}, granted)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConnackReasons(t *testing.T) {
|
||||||
|
require.Equal(t, "connection accepted", ConnackReason(ConnAccepted))
|
||||||
|
require.Equal(t, "connection refused: bad user name or password", ConnackReason(ConnRefusedBadAuth))
|
||||||
|
require.Equal(t, "connection refused: not authorized", ConnackReason(ConnRefusedNotAuthorized))
|
||||||
|
require.Contains(t, ConnackReason(99), "connection refused: return code 99")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestsHonourNetworkPolicy(t *testing.T) {
|
||||||
|
execID := "mqtt-deny"
|
||||||
|
require.NoError(t, protocolstate.Init(&types.Options{ExecutionId: execID, ExcludeTargets: []string{"203.0.113.50"}}))
|
||||||
|
t.Cleanup(func() { protocolstate.Close(execID) })
|
||||||
|
ctx := context.WithValue(context.Background(), "executionId", execID) //nolint:staticcheck
|
||||||
|
|
||||||
|
_, err := IsMQTT(ctx, "203.0.113.50", 1883)
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
client := &Client{}
|
||||||
|
_, err = client.Connect(ctx, "203.0.113.50", 1883, nil)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func startMockMQTTBroker(t *testing.T) (net.Listener, int) {
|
||||||
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
port := l.Addr().(*net.TCPAddr).Port
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
conn, err := l.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go handleMockClient(conn)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return l, port
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleMockClient(conn net.Conn) {
|
||||||
|
defer func() { _ = conn.Close() }()
|
||||||
|
|
||||||
|
for {
|
||||||
|
header, payload, err := readPacket(conn)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
packetType := header >> 4
|
||||||
|
switch packetType {
|
||||||
|
case PacketConnect:
|
||||||
|
// Check if username is present (flags byte at offset 7)
|
||||||
|
if len(payload) >= 8 {
|
||||||
|
flags := payload[7]
|
||||||
|
if (flags & 0x80) != 0 {
|
||||||
|
// Check username
|
||||||
|
offset := 10 // after proto(6) + level(1) + flags(1) + keepalive(2)
|
||||||
|
if len(payload) >= offset+2 {
|
||||||
|
clientLen := int(binary.BigEndian.Uint16(payload[offset : offset+2]))
|
||||||
|
offset += 2 + clientLen
|
||||||
|
if len(payload) >= offset+2 {
|
||||||
|
userLen := int(binary.BigEndian.Uint16(payload[offset : offset+2]))
|
||||||
|
user := string(payload[offset+2 : offset+2+userLen])
|
||||||
|
if user == "baduser" {
|
||||||
|
// Send CONNACK Refused Bad Auth
|
||||||
|
_, _ = conn.Write([]byte{0x20, 0x02, 0x00, ConnRefusedBadAuth})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Send CONNACK Accepted
|
||||||
|
_, _ = conn.Write([]byte{0x20, 0x02, 0x00, ConnAccepted})
|
||||||
|
|
||||||
|
case PacketSubscribe:
|
||||||
|
// Send SUBACK
|
||||||
|
pid := binary.BigEndian.Uint16(payload[0:2])
|
||||||
|
suback := []byte{0x90, 0x03, byte(pid >> 8), byte(pid & 0xFF), 0x00}
|
||||||
|
_, _ = conn.Write(suback)
|
||||||
|
|
||||||
|
// Publish test message
|
||||||
|
pubPkt, err := buildPublishPacket("nuclei/alert", []byte("vuln-detected"), 0, false, 0)
|
||||||
|
if err == nil {
|
||||||
|
_, _ = conn.Write(pubPkt)
|
||||||
|
}
|
||||||
|
|
||||||
|
case PacketPublish:
|
||||||
|
// QoS 0, no ack needed
|
||||||
|
|
||||||
|
case PacketPingreq:
|
||||||
|
// Send PINGRESP
|
||||||
|
_, _ = conn.Write([]byte{PacketPingresp << 4, 0x00})
|
||||||
|
|
||||||
|
case PacketDisconnect:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMockBrokerInteractions(t *testing.T) {
|
||||||
|
execID := "mqtt-mock-test"
|
||||||
|
require.NoError(t, protocolstate.Init(&types.Options{ExecutionId: execID}))
|
||||||
|
t.Cleanup(func() { protocolstate.Close(execID) })
|
||||||
|
ctx := context.WithValue(context.Background(), "executionId", execID) //nolint:staticcheck
|
||||||
|
|
||||||
|
l, port := startMockMQTTBroker(t)
|
||||||
|
defer func() { _ = l.Close() }()
|
||||||
|
|
||||||
|
// 1. Test IsMQTT
|
||||||
|
isMQTT, err := IsMQTT(ctx, "127.0.0.1", port)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, isMQTT)
|
||||||
|
|
||||||
|
// 2. Test IsAnonymousAllowed
|
||||||
|
anonAllowed, err := IsAnonymousAllowed(ctx, "127.0.0.1", port)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, anonAllowed)
|
||||||
|
|
||||||
|
// 3. Test Client Connect Auth Failure
|
||||||
|
badClient := &Client{}
|
||||||
|
badRes, _ := badClient.ConnectAuth(ctx, "127.0.0.1", port, "baduser", "wrongpass")
|
||||||
|
require.False(t, badRes.Success)
|
||||||
|
require.Equal(t, int(ConnRefusedBadAuth), badRes.ReturnCode)
|
||||||
|
|
||||||
|
// 4. Test Client Connect Success with default CleanSession preservation
|
||||||
|
client := &Client{}
|
||||||
|
res, err := client.Connect(ctx, "127.0.0.1", port, &ConnectOptions{KeepAlive: 70000})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, res.Success)
|
||||||
|
require.Equal(t, int(ConnAccepted), res.ReturnCode)
|
||||||
|
|
||||||
|
// 5. Test Ping
|
||||||
|
alive, err := client.Ping(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, alive)
|
||||||
|
|
||||||
|
// 6. Test Subscribe and Receive Message
|
||||||
|
msgs, err := client.Subscribe(ctx, "nuclei/alert", 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, msgs)
|
||||||
|
require.Equal(t, "nuclei/alert", msgs[0].Topic)
|
||||||
|
require.Equal(t, "vuln-detected", msgs[0].Payload)
|
||||||
|
|
||||||
|
// 7. Test Publish
|
||||||
|
pubOk, err := client.Publish(ctx, "nuclei/probe", "test payload")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, pubOk)
|
||||||
|
|
||||||
|
// 8. Test Close and Double Close
|
||||||
|
require.NoError(t, client.Close())
|
||||||
|
require.NoError(t, client.Close())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPasswordWithoutUsername(t *testing.T) {
|
||||||
|
// MQTT 3.1.1 (MQTT-3.1.2-22): If Password flag is set, User Name flag MUST be set.
|
||||||
|
pkt, err := buildConnectPacket("token-client", "", "my-secret-token", true, 60)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
r := bytes.NewReader(pkt)
|
||||||
|
_, payload, err := readPacket(r)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// CleanSession (0x02) | User (0x80) | Pass (0x40) = 0xC2
|
||||||
|
require.Equal(t, byte(0xC2), payload[7])
|
||||||
|
|
||||||
|
// The client ID comes right after the keep alive at payload[8:10], and the
|
||||||
|
// username length follows it.
|
||||||
|
clientIDLen := int(binary.BigEndian.Uint16(payload[10:12]))
|
||||||
|
off := 12 + clientIDLen
|
||||||
|
|
||||||
|
// Username length should be 0 (empty string)
|
||||||
|
userLen := binary.BigEndian.Uint16(payload[off : off+2])
|
||||||
|
require.Equal(t, uint16(0), userLen)
|
||||||
|
off += 2
|
||||||
|
|
||||||
|
// Password follows the username
|
||||||
|
passLen := binary.BigEndian.Uint16(payload[off : off+2])
|
||||||
|
require.Equal(t, uint16(len("my-secret-token")), passLen)
|
||||||
|
off += 2
|
||||||
|
require.Equal(t, "my-secret-token", string(payload[off:off+int(passLen)]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A window that closes in the middle of a PUBLISH must drop the connection.
|
||||||
|
// Keeping it would let the next read start inside that packet.
|
||||||
|
func TestSubscribePartialPacketClosesConnection(t *testing.T) {
|
||||||
|
execID := "mqtt-partial-subscribe"
|
||||||
|
require.NoError(t, protocolstate.Init(&types.Options{ExecutionId: execID}))
|
||||||
|
t.Cleanup(func() { protocolstate.Close(execID) })
|
||||||
|
ctx := context.WithValue(context.Background(), "executionId", execID) //nolint:staticcheck
|
||||||
|
|
||||||
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = l.Close() }()
|
||||||
|
port := l.Addr().(*net.TCPAddr).Port
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
conn, err := l.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _ = conn.Close() }()
|
||||||
|
for {
|
||||||
|
header, payload, err := readPacket(conn)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch header >> 4 {
|
||||||
|
case PacketConnect:
|
||||||
|
_, _ = conn.Write([]byte{PacketConnack << 4, 0x02, 0x00, ConnAccepted})
|
||||||
|
case PacketSubscribe:
|
||||||
|
// SUBACK for the packet id, then a PUBLISH that promises 10 bytes
|
||||||
|
// and sends 5. The client's window closes in the middle of it.
|
||||||
|
_, _ = conn.Write([]byte{PacketSuback << 4, 0x03, payload[0], payload[1], 0x00})
|
||||||
|
_, _ = conn.Write([]byte{PacketPublish << 4, 0x0a, 0x00, 0x03, 'a', '/', 'b'})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
client := &Client{}
|
||||||
|
res, err := client.Connect(ctx, "127.0.0.1", port, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, res.Success)
|
||||||
|
|
||||||
|
msgs, err := client.Subscribe(ctx, "a/b", 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, msgs)
|
||||||
|
require.Nil(t, client.conn, "a stream cut in the middle of a packet must not be reused")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPingWithInFlightPublish(t *testing.T) {
|
||||||
|
execID := "mqtt-ping-inflight"
|
||||||
|
require.NoError(t, protocolstate.Init(&types.Options{ExecutionId: execID}))
|
||||||
|
t.Cleanup(func() { protocolstate.Close(execID) })
|
||||||
|
ctx := context.WithValue(context.Background(), "executionId", execID) //nolint:staticcheck
|
||||||
|
|
||||||
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = l.Close() }()
|
||||||
|
|
||||||
|
port := l.Addr().(*net.TCPAddr).Port
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
conn, err := l.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _ = conn.Close() }()
|
||||||
|
|
||||||
|
for {
|
||||||
|
header, _, err := readPacket(conn)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
packetType := header >> 4
|
||||||
|
switch packetType {
|
||||||
|
case PacketConnect:
|
||||||
|
_, _ = conn.Write([]byte{0x20, 0x02, 0x00, ConnAccepted})
|
||||||
|
case PacketPingreq:
|
||||||
|
// Send a PUBLISH packet first before PINGRESP
|
||||||
|
pubPkt, _ := buildPublishPacket("sensors/temp", []byte("25.0"), 0, false, 0)
|
||||||
|
_, _ = conn.Write(pubPkt)
|
||||||
|
// Then send PINGRESP
|
||||||
|
_, _ = conn.Write([]byte{PacketPingresp << 4, 0x00})
|
||||||
|
case PacketDisconnect:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
client := &Client{}
|
||||||
|
res, err := client.Connect(ctx, "127.0.0.1", port, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, res.Success)
|
||||||
|
|
||||||
|
// Ping should skip the in-flight PUBLISH and successfully receive PINGRESP
|
||||||
|
alive, err := client.Ping(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, alive)
|
||||||
|
|
||||||
|
_ = client.Close()
|
||||||
|
}
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
package mqtt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Limits to prevent DoS and buffer overflows
|
||||||
|
const (
|
||||||
|
MaxStringLength = 65535
|
||||||
|
MaxRemainingLength = 268435455
|
||||||
|
MaxPacketSize = 10 * 1024 * 1024 // 10MB limit on incoming packets
|
||||||
|
)
|
||||||
|
|
||||||
|
// MQTT 3.1.1 Control Packet Types
|
||||||
|
const (
|
||||||
|
PacketConnect byte = 1
|
||||||
|
PacketConnack byte = 2
|
||||||
|
PacketPublish byte = 3
|
||||||
|
PacketPuback byte = 4
|
||||||
|
PacketPubrec byte = 5
|
||||||
|
PacketPubrel byte = 6
|
||||||
|
PacketPubcomp byte = 7
|
||||||
|
PacketSubscribe byte = 8
|
||||||
|
PacketSuback byte = 9
|
||||||
|
PacketUnsub byte = 10
|
||||||
|
PacketUnsuback byte = 11
|
||||||
|
PacketPingreq byte = 12
|
||||||
|
PacketPingresp byte = 13
|
||||||
|
PacketDisconnect byte = 14
|
||||||
|
)
|
||||||
|
|
||||||
|
// CONNACK Return Codes (MQTT 3.1.1 table 3.1)
|
||||||
|
const (
|
||||||
|
ConnAccepted byte = 0
|
||||||
|
ConnRefusedUnacceptableProtocol byte = 1
|
||||||
|
ConnRefusedIdentifierRejected byte = 2
|
||||||
|
ConnRefusedServerUnavailable byte = 3
|
||||||
|
ConnRefusedBadAuth byte = 4
|
||||||
|
ConnRefusedNotAuthorized byte = 5
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConnackReason returns a human-readable description for CONNACK return codes.
|
||||||
|
func ConnackReason(code byte) string {
|
||||||
|
switch code {
|
||||||
|
case ConnAccepted:
|
||||||
|
return "connection accepted"
|
||||||
|
case ConnRefusedUnacceptableProtocol:
|
||||||
|
return "connection refused: unacceptable protocol version"
|
||||||
|
case ConnRefusedIdentifierRejected:
|
||||||
|
return "connection refused: identifier rejected"
|
||||||
|
case ConnRefusedServerUnavailable:
|
||||||
|
return "connection refused: server unavailable"
|
||||||
|
case ConnRefusedBadAuth:
|
||||||
|
return "connection refused: bad user name or password"
|
||||||
|
case ConnRefusedNotAuthorized:
|
||||||
|
return "connection refused: not authorized"
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("connection refused: return code %d", code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeVarLength encodes a length integer as an MQTT variable byte integer.
|
||||||
|
func encodeVarLength(length int) ([]byte, error) {
|
||||||
|
if length < 0 || length > MaxRemainingLength {
|
||||||
|
return nil, fmt.Errorf("remaining length %d exceeds MQTT max %d", length, MaxRemainingLength)
|
||||||
|
}
|
||||||
|
var out []byte
|
||||||
|
for {
|
||||||
|
encodedByte := byte(length % 128)
|
||||||
|
length /= 128
|
||||||
|
if length > 0 {
|
||||||
|
encodedByte |= 128
|
||||||
|
}
|
||||||
|
out = append(out, encodedByte)
|
||||||
|
if length == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeVarLength decodes an MQTT variable byte integer from reader.
|
||||||
|
func decodeVarLength(r io.Reader) (int, error) {
|
||||||
|
multiplier := 1
|
||||||
|
value := 0
|
||||||
|
buf := make([]byte, 1)
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
if _, err := io.ReadFull(r, buf); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
encodedByte := buf[0]
|
||||||
|
value += int(encodedByte&127) * multiplier
|
||||||
|
if multiplier > 128*128*128 {
|
||||||
|
return 0, errors.New("malformed remaining length")
|
||||||
|
}
|
||||||
|
multiplier *= 128
|
||||||
|
if encodedByte&128 == 0 {
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, errors.New("malformed remaining length")
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeString encodes a UTF-8 string with a 2-byte big-endian length prefix.
|
||||||
|
func encodeString(s string) ([]byte, error) {
|
||||||
|
b := []byte(s)
|
||||||
|
if len(b) > MaxStringLength {
|
||||||
|
return nil, fmt.Errorf("string length %d exceeds MQTT max %d", len(b), MaxStringLength)
|
||||||
|
}
|
||||||
|
out := make([]byte, 2+len(b))
|
||||||
|
binary.BigEndian.PutUint16(out[0:2], uint16(len(b)))
|
||||||
|
copy(out[2:], b)
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildConnectPacket constructs an MQTT 3.1.1 CONNECT packet.
|
||||||
|
func buildConnectPacket(clientID, username, password string, cleanSession bool, keepAlive uint16) ([]byte, error) {
|
||||||
|
var varHeader []byte
|
||||||
|
protoName, err := encodeString("MQTT")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
varHeader = append(varHeader, protoName...)
|
||||||
|
varHeader = append(varHeader, 0x04) // Protocol Level: 4 (MQTT 3.1.1)
|
||||||
|
|
||||||
|
// Connect Flags
|
||||||
|
var flags byte
|
||||||
|
if cleanSession {
|
||||||
|
flags |= 0x02
|
||||||
|
}
|
||||||
|
// Per MQTT 3.1.1 spec (MQTT-3.1.2-22): If the User Name Flag is 0, the Password Flag MUST be 0.
|
||||||
|
// If a password is provided, the User Name Flag MUST be set (encoding an empty username if omitted).
|
||||||
|
hasUsername := username != "" || password != ""
|
||||||
|
if hasUsername {
|
||||||
|
flags |= 0x80
|
||||||
|
}
|
||||||
|
if password != "" {
|
||||||
|
flags |= 0x40
|
||||||
|
}
|
||||||
|
varHeader = append(varHeader, flags)
|
||||||
|
|
||||||
|
// Keep Alive (2 bytes big endian)
|
||||||
|
ka := make([]byte, 2)
|
||||||
|
binary.BigEndian.PutUint16(ka, keepAlive)
|
||||||
|
varHeader = append(varHeader, ka...)
|
||||||
|
|
||||||
|
// Payload
|
||||||
|
var payload []byte
|
||||||
|
cidBytes, err := encodeString(clientID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid clientID: %w", err)
|
||||||
|
}
|
||||||
|
payload = append(payload, cidBytes...)
|
||||||
|
|
||||||
|
if hasUsername {
|
||||||
|
userBytes, err := encodeString(username)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid username: %w", err)
|
||||||
|
}
|
||||||
|
payload = append(payload, userBytes...)
|
||||||
|
}
|
||||||
|
if password != "" {
|
||||||
|
passBytes, err := encodeString(password)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid password: %w", err)
|
||||||
|
}
|
||||||
|
payload = append(payload, passBytes...)
|
||||||
|
}
|
||||||
|
|
||||||
|
remainingLength := len(varHeader) + len(payload)
|
||||||
|
lenBytes, err := encodeVarLength(remainingLength)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var pkt []byte
|
||||||
|
pkt = append(pkt, PacketConnect<<4)
|
||||||
|
pkt = append(pkt, lenBytes...)
|
||||||
|
pkt = append(pkt, varHeader...)
|
||||||
|
pkt = append(pkt, payload...)
|
||||||
|
return pkt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSubscribePacket constructs an MQTT 3.1.1 SUBSCRIBE packet for a single topic.
|
||||||
|
func buildSubscribePacket(packetID uint16, topic string, qos byte) ([]byte, error) {
|
||||||
|
var varHeader [2]byte
|
||||||
|
binary.BigEndian.PutUint16(varHeader[:], packetID)
|
||||||
|
|
||||||
|
topicBytes, err := encodeString(topic)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid topic: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload []byte
|
||||||
|
payload = append(payload, topicBytes...)
|
||||||
|
payload = append(payload, qos&0x03)
|
||||||
|
|
||||||
|
remainingLength := len(varHeader) + len(payload)
|
||||||
|
lenBytes, err := encodeVarLength(remainingLength)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var pkt []byte
|
||||||
|
// SUBSCRIBE fixed header flags must be 0010 (0x02)
|
||||||
|
pkt = append(pkt, (PacketSubscribe<<4)|0x02)
|
||||||
|
pkt = append(pkt, lenBytes...)
|
||||||
|
pkt = append(pkt, varHeader[:]...)
|
||||||
|
pkt = append(pkt, payload...)
|
||||||
|
return pkt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildPublishPacket constructs an MQTT 3.1.1 PUBLISH packet.
|
||||||
|
func buildPublishPacket(topic string, payload []byte, qos byte, retain bool, packetID uint16) ([]byte, error) {
|
||||||
|
headerByte := (PacketPublish << 4)
|
||||||
|
if retain {
|
||||||
|
headerByte |= 0x01
|
||||||
|
}
|
||||||
|
headerByte |= (qos & 0x03) << 1
|
||||||
|
|
||||||
|
topicBytes, err := encodeString(topic)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid topic: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var varHeader []byte
|
||||||
|
varHeader = append(varHeader, topicBytes...)
|
||||||
|
if qos > 0 {
|
||||||
|
pid := make([]byte, 2)
|
||||||
|
binary.BigEndian.PutUint16(pid, packetID)
|
||||||
|
varHeader = append(varHeader, pid...)
|
||||||
|
}
|
||||||
|
|
||||||
|
remainingLength := len(varHeader) + len(payload)
|
||||||
|
lenBytes, err := encodeVarLength(remainingLength)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var pkt []byte
|
||||||
|
pkt = append(pkt, headerByte)
|
||||||
|
pkt = append(pkt, lenBytes...)
|
||||||
|
pkt = append(pkt, varHeader...)
|
||||||
|
pkt = append(pkt, payload...)
|
||||||
|
return pkt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildPingreqPacket constructs an MQTT PINGREQ packet.
|
||||||
|
func buildPingreqPacket() []byte {
|
||||||
|
return []byte{PacketPingreq << 4, 0x00}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildDisconnectPacket constructs an MQTT DISCONNECT packet.
|
||||||
|
func buildDisconnectPacket() []byte {
|
||||||
|
return []byte{PacketDisconnect << 4, 0x00}
|
||||||
|
}
|
||||||
|
|
||||||
|
// errBrokenStream means a read failed after part of a packet was consumed, or
|
||||||
|
// a packet was refused, so the next read would start in the middle of a packet.
|
||||||
|
// The connection must not be used again.
|
||||||
|
var errBrokenStream = errors.New("MQTT stream is out of sync")
|
||||||
|
|
||||||
|
// readPacket reads a full MQTT packet from reader, rejecting packets over MaxPacketSize.
|
||||||
|
// Failures before the first byte is read are returned as they are. Failures after
|
||||||
|
// it wrap errBrokenStream.
|
||||||
|
func readPacket(r io.Reader) (headerByte byte, payload []byte, err error) {
|
||||||
|
var header [1]byte
|
||||||
|
if _, err := io.ReadFull(r, header[:]); err != nil {
|
||||||
|
return 0, nil, err
|
||||||
|
}
|
||||||
|
length, err := decodeVarLength(r)
|
||||||
|
if err != nil {
|
||||||
|
return 0, nil, fmt.Errorf("%w: %w", errBrokenStream, err)
|
||||||
|
}
|
||||||
|
if length > MaxPacketSize {
|
||||||
|
return 0, nil, fmt.Errorf("%w: MQTT packet length %d exceeds maximum allowed %d", errBrokenStream, length, MaxPacketSize)
|
||||||
|
}
|
||||||
|
buf := make([]byte, length)
|
||||||
|
if length > 0 {
|
||||||
|
if _, err := io.ReadFull(r, buf); err != nil {
|
||||||
|
return 0, nil, fmt.Errorf("%w: %w", errBrokenStream, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return header[0], buf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseConnack decodes a CONNACK variable header.
|
||||||
|
func parseConnack(payload []byte) (sessionPresent bool, returnCode byte, err error) {
|
||||||
|
if len(payload) < 2 {
|
||||||
|
return false, 0, errors.New("short CONNACK response")
|
||||||
|
}
|
||||||
|
sessionPresent = (payload[0] & 0x01) != 0
|
||||||
|
returnCode = payload[1]
|
||||||
|
return sessionPresent, returnCode, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSuback decodes a SUBACK packet payload.
|
||||||
|
func parseSuback(payload []byte) (packetID uint16, grantedQoS []byte, err error) {
|
||||||
|
if len(payload) < 3 {
|
||||||
|
return 0, nil, errors.New("short SUBACK response")
|
||||||
|
}
|
||||||
|
packetID = binary.BigEndian.Uint16(payload[0:2])
|
||||||
|
grantedQoS = payload[2:]
|
||||||
|
return packetID, grantedQoS, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parsePublish decodes a PUBLISH packet.
|
||||||
|
func parsePublish(headerByte byte, body []byte) (*Message, error) {
|
||||||
|
qos := (headerByte >> 1) & 0x03
|
||||||
|
retain := (headerByte & 0x01) != 0
|
||||||
|
|
||||||
|
if len(body) < 2 {
|
||||||
|
return nil, errors.New("short PUBLISH packet")
|
||||||
|
}
|
||||||
|
topicLen := int(binary.BigEndian.Uint16(body[0:2]))
|
||||||
|
if len(body) < 2+topicLen {
|
||||||
|
return nil, errors.New("short PUBLISH topic")
|
||||||
|
}
|
||||||
|
topic := string(body[2 : 2+topicLen])
|
||||||
|
offset := 2 + topicLen
|
||||||
|
|
||||||
|
if qos > 0 {
|
||||||
|
if len(body) < offset+2 {
|
||||||
|
return nil, errors.New("short PUBLISH packet ID")
|
||||||
|
}
|
||||||
|
offset += 2
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := string(body[offset:])
|
||||||
|
return &Message{
|
||||||
|
Topic: topic,
|
||||||
|
Payload: payload,
|
||||||
|
QoS: int(qos),
|
||||||
|
Retain: retain,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -8,8 +8,6 @@ import (
|
|||||||
|
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
_ "github.com/microsoft/go-mssqldb"
|
|
||||||
|
|
||||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
|
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+87
-16
@@ -9,11 +9,58 @@ import (
|
|||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
_ "github.com/microsoft/go-mssqldb"
|
gomssql "github.com/microsoft/go-mssqldb"
|
||||||
"github.com/projectdiscovery/nuclei/v3/pkg/js/utils"
|
"github.com/projectdiscovery/nuclei/v3/pkg/js/utils"
|
||||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
|
"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 (
|
type (
|
||||||
// Client is a client for MS SQL database.
|
// Client is a client for MS SQL database.
|
||||||
// Internally client uses microsoft/go-mssqldb driver.
|
// Internally client uses microsoft/go-mssqldb driver.
|
||||||
@@ -67,9 +114,7 @@ func connect(ctx context.Context, executionId string, host string, port int, use
|
|||||||
|
|
||||||
target := net.JoinHostPort(host, fmt.Sprintf("%d", port))
|
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 {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
@@ -79,16 +124,7 @@ func connect(ctx context.Context, executionId string, host string, port int, use
|
|||||||
|
|
||||||
_, err = db.ExecContext(ctx, "select 1")
|
_, err = db.ExecContext(ctx, "select 1")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
if isNetworkError(err) {
|
||||||
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, err
|
||||||
}
|
}
|
||||||
return false, nil
|
return false, nil
|
||||||
@@ -96,6 +132,42 @@ func connect(ctx context.Context, executionId string, host string, port int, use
|
|||||||
return true, nil
|
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.
|
// 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 running MS SQL database, it returns true.
|
||||||
// If the host is not running MS SQL database, it returns false.
|
// If the host is not running MS SQL database, it returns false.
|
||||||
@@ -156,9 +228,8 @@ func (c *MSSQLClient) ExecuteQuery(ctx context.Context, host string, port int, u
|
|||||||
}
|
}
|
||||||
|
|
||||||
target := net.JoinHostPort(host, fmt.Sprintf("%d", port))
|
target := net.JoinHostPort(host, fmt.Sprintf("%d", port))
|
||||||
connString := mssqlConnString(target, username, password, dbName)
|
|
||||||
|
|
||||||
db, err := sql.Open("sqlserver", connString)
|
db, err := openDB(executionId, target, username, password, dbName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
package mssql
|
package mssql
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
gomssql "github.com/microsoft/go-mssqldb"
|
||||||
"github.com/microsoft/go-mssqldb/msdsn"
|
"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"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,3 +42,114 @@ func TestConnectionStringKeepsPlainDatabaseName(t *testing.T) {
|
|||||||
|
|
||||||
require.Equal(t, "master", cfg.Database)
|
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)
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -20,6 +21,8 @@ import (
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
defaultTimeout = time.Duration(5) * time.Second
|
defaultTimeout = time.Duration(5) * time.Second
|
||||||
|
|
||||||
|
errNoConnection = errors.New("no connection: open one with net.Open or net.OpenTLS")
|
||||||
)
|
)
|
||||||
|
|
||||||
// getTimeoutFromContext returns the configured TCP timeout or defaultTimeout.
|
// getTimeoutFromContext returns the configured TCP timeout or defaultTimeout.
|
||||||
@@ -271,12 +274,14 @@ type (
|
|||||||
// const conn = net.Open('tcp', 'acme.com:80');
|
// const conn = net.Open('tcp', 'acme.com:80');
|
||||||
// ```
|
// ```
|
||||||
NetConn struct {
|
NetConn struct {
|
||||||
|
// conn is nil when no connection is open.
|
||||||
conn net.Conn
|
conn net.Conn
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
// Close closes the connection.
|
// Close closes the connection.
|
||||||
|
// Close does nothing if no connection is open.
|
||||||
// @example
|
// @example
|
||||||
// ```javascript
|
// ```javascript
|
||||||
// const net = require('nuclei/net');
|
// const net = require('nuclei/net');
|
||||||
@@ -284,7 +289,13 @@ type (
|
|||||||
// conn.Close();
|
// conn.Close();
|
||||||
// ```
|
// ```
|
||||||
func (c *NetConn) Close() error {
|
func (c *NetConn) Close() error {
|
||||||
|
if c.conn == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
err := c.conn.Close()
|
err := c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,6 +332,10 @@ func (c *NetConn) unsetDeadLine() {
|
|||||||
// conn.SendArray(['hello', 'world']);
|
// conn.SendArray(['hello', 'world']);
|
||||||
// ```
|
// ```
|
||||||
func (c *NetConn) SendArray(data []interface{}) error {
|
func (c *NetConn) SendArray(data []interface{}) error {
|
||||||
|
if c.conn == nil {
|
||||||
|
return errNoConnection
|
||||||
|
}
|
||||||
|
|
||||||
c.setDeadLine()
|
c.setDeadLine()
|
||||||
defer c.unsetDeadLine()
|
defer c.unsetDeadLine()
|
||||||
input := types.ToByteSlice(data)
|
input := types.ToByteSlice(data)
|
||||||
@@ -342,6 +357,10 @@ func (c *NetConn) SendArray(data []interface{}) error {
|
|||||||
// conn.SendHex('68656c6c6f');
|
// conn.SendHex('68656c6c6f');
|
||||||
// ```
|
// ```
|
||||||
func (c *NetConn) SendHex(data string) error {
|
func (c *NetConn) SendHex(data string) error {
|
||||||
|
if c.conn == nil {
|
||||||
|
return errNoConnection
|
||||||
|
}
|
||||||
|
|
||||||
c.setDeadLine()
|
c.setDeadLine()
|
||||||
defer c.unsetDeadLine()
|
defer c.unsetDeadLine()
|
||||||
bin, err := hex.DecodeString(data)
|
bin, err := hex.DecodeString(data)
|
||||||
@@ -366,6 +385,10 @@ func (c *NetConn) SendHex(data string) error {
|
|||||||
// conn.Send('hello');
|
// conn.Send('hello');
|
||||||
// ```
|
// ```
|
||||||
func (c *NetConn) Send(data string) error {
|
func (c *NetConn) Send(data string) error {
|
||||||
|
if c.conn == nil {
|
||||||
|
return errNoConnection
|
||||||
|
}
|
||||||
|
|
||||||
c.setDeadLine()
|
c.setDeadLine()
|
||||||
defer c.unsetDeadLine()
|
defer c.unsetDeadLine()
|
||||||
bin := []byte(data)
|
bin := []byte(data)
|
||||||
@@ -389,6 +412,10 @@ func (c *NetConn) Send(data string) error {
|
|||||||
// const data = conn.RecvFull(1024);
|
// const data = conn.RecvFull(1024);
|
||||||
// ```
|
// ```
|
||||||
func (c *NetConn) RecvFull(N int) ([]byte, error) {
|
func (c *NetConn) RecvFull(N int) ([]byte, error) {
|
||||||
|
if c.conn == nil {
|
||||||
|
return []byte{}, errNoConnection
|
||||||
|
}
|
||||||
|
|
||||||
c.setDeadLine()
|
c.setDeadLine()
|
||||||
defer c.unsetDeadLine()
|
defer c.unsetDeadLine()
|
||||||
if N == 0 {
|
if N == 0 {
|
||||||
@@ -414,6 +441,10 @@ func (c *NetConn) RecvFull(N int) ([]byte, error) {
|
|||||||
// log(`Received ${data.length} bytes from the server`)
|
// log(`Received ${data.length} bytes from the server`)
|
||||||
// ```
|
// ```
|
||||||
func (c *NetConn) Recv(N int) ([]byte, error) {
|
func (c *NetConn) Recv(N int) ([]byte, error) {
|
||||||
|
if c.conn == nil {
|
||||||
|
return []byte{}, errNoConnection
|
||||||
|
}
|
||||||
|
|
||||||
c.setDeadLine()
|
c.setDeadLine()
|
||||||
defer c.unsetDeadLine()
|
defer c.unsetDeadLine()
|
||||||
if N == 0 {
|
if N == 0 {
|
||||||
|
|||||||
@@ -406,3 +406,41 @@ func (f *fakeConn) RemoteAddr() net.Addr { return nil }
|
|||||||
func (f *fakeConn) SetDeadline(time.Time) error { return nil }
|
func (f *fakeConn) SetDeadline(time.Time) error { return nil }
|
||||||
func (f *fakeConn) SetReadDeadline(time.Time) error { return nil }
|
func (f *fakeConn) SetReadDeadline(time.Time) error { return nil }
|
||||||
func (f *fakeConn) SetWriteDeadline(time.Time) error { return nil }
|
func (f *fakeConn) SetWriteDeadline(time.Time) error { return nil }
|
||||||
|
|
||||||
|
func TestNetConnWithoutConnection(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := map[string]func(t *testing.T) *NetConn{
|
||||||
|
"never opened": func(*testing.T) *NetConn { return new(NetConn) },
|
||||||
|
"already closed": func(t *testing.T) *NetConn {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
local, remote := net.Pipe()
|
||||||
|
|
||||||
|
t.Cleanup(func() { _ = remote.Close() })
|
||||||
|
|
||||||
|
conn := new(NetConn)
|
||||||
|
conn.conn = local
|
||||||
|
require.NoError(t, conn.Close())
|
||||||
|
|
||||||
|
return conn
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for name, newConn := range tests {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
conn := newConn(t)
|
||||||
|
require.NoError(t, conn.Close())
|
||||||
|
require.ErrorContains(t, conn.Send("x"), "no connection")
|
||||||
|
require.ErrorContains(t, conn.SendHex("78"), "no connection")
|
||||||
|
require.ErrorContains(t, conn.SendArray([]any{120}), "no connection")
|
||||||
|
|
||||||
|
_, err := conn.Recv(1)
|
||||||
|
require.ErrorContains(t, err, "no connection")
|
||||||
|
|
||||||
|
_, err = conn.RecvFull(1)
|
||||||
|
require.ErrorContains(t, err, "no connection")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,12 +3,12 @@ package postgres
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"os"
|
||||||
"net/url"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-pg/pg/v10"
|
|
||||||
"github.com/praetorian-inc/fingerprintx/pkg/plugins"
|
"github.com/praetorian-inc/fingerprintx/pkg/plugins"
|
||||||
postgres "github.com/praetorian-inc/fingerprintx/pkg/plugins/services/postgresql"
|
postgres "github.com/praetorian-inc/fingerprintx/pkg/plugins/services/postgresql"
|
||||||
utils "github.com/projectdiscovery/nuclei/v3/pkg/js/utils"
|
utils "github.com/projectdiscovery/nuclei/v3/pkg/js/utils"
|
||||||
@@ -18,7 +18,7 @@ import (
|
|||||||
|
|
||||||
type (
|
type (
|
||||||
// PGClient is a client for Postgres database.
|
// PGClient is a client for Postgres database.
|
||||||
// Internally client uses go-pg/pg driver.
|
// Internally client uses lib/pq driver.
|
||||||
// @example
|
// @example
|
||||||
// ```javascript
|
// ```javascript
|
||||||
// const postgres = require('nuclei/postgres');
|
// const postgres = require('nuclei/postgres');
|
||||||
@@ -124,9 +124,7 @@ func executeQuery(ctx context.Context, executionId string, host string, port int
|
|||||||
return nil, protocolstate.ErrHostDenied.Msgf(host)
|
return nil, protocolstate.ErrHostDenied.Msgf(host)
|
||||||
}
|
}
|
||||||
|
|
||||||
target := net.JoinHostPort(host, fmt.Sprintf("%d", port))
|
connStr := buildPostgresDSN(host, port, username, password, dbName)
|
||||||
|
|
||||||
connStr := buildPostgresConnURL(username, password, target, dbName, executionId)
|
|
||||||
db, err := pgwrap.OpenDB(ctx, executionId, connStr)
|
db, err := pgwrap.OpenDB(ctx, executionId, connStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -146,17 +144,30 @@ func executeQuery(ctx context.Context, executionId string, host string, port int
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildPostgresConnURL(username, password, target, dbName, executionId string) string {
|
// buildPostgresDSN returns a lib/pq key=value connection string. user, password, dbname, service and passfile
|
||||||
values := url.Values{}
|
// are always set, also when empty: lib/pq fills unset values from the scanner's environment (PGUSER, PGPASSWORD,
|
||||||
values.Set("sslmode", "disable")
|
// PGDATABASE), a service file (PGSERVICE) and ~/.pgpass (PGPASSFILE), which sends the operator's credentials to the target.
|
||||||
values.Set("executionId", executionId)
|
func buildPostgresDSN(host string, port int, username, password, dbName string) string {
|
||||||
|
quote := strings.NewReplacer(`\`, `\\`, `'`, `\'`)
|
||||||
return fmt.Sprintf("postgres://%s@%s/%s?%s",
|
params := []struct{ key, value string }{
|
||||||
url.UserPassword(username, password).String(),
|
{"host", host},
|
||||||
target,
|
{"port", strconv.Itoa(port)},
|
||||||
url.PathEscape(dbName),
|
{"user", username},
|
||||||
values.Encode(),
|
{"password", password},
|
||||||
)
|
{"dbname", dbName},
|
||||||
|
{"sslmode", "disable"},
|
||||||
|
{"connect_timeout", "10"},
|
||||||
|
{"service", ""},
|
||||||
|
{"options", ""},
|
||||||
|
{"application_name", ""},
|
||||||
|
// a path below a file never exists, so no password file is read
|
||||||
|
{"passfile", filepath.Join(os.DevNull, "pgpass")},
|
||||||
|
}
|
||||||
|
kvs := make([]string, 0, len(params))
|
||||||
|
for _, param := range params {
|
||||||
|
kvs = append(kvs, param.key+"='"+quote.Replace(param.value)+"'")
|
||||||
|
}
|
||||||
|
return strings.Join(kvs, " ")
|
||||||
}
|
}
|
||||||
|
|
||||||
// ConnectWithDB connects to Postgres database using given credentials and database name.
|
// ConnectWithDB connects to Postgres database using given credentials and database name.
|
||||||
@@ -194,32 +205,20 @@ func connect(ctx context.Context, executionId string, host string, port int, use
|
|||||||
return false, protocolstate.ErrHostDenied.Msgf(host)
|
return false, protocolstate.ErrHostDenied.Msgf(host)
|
||||||
}
|
}
|
||||||
|
|
||||||
target := net.JoinHostPort(host, fmt.Sprintf("%d", port))
|
execCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
|
||||||
execCtx, cancel := context.WithCancel(ctx)
|
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
dialer := protocolstate.GetDialersWithId(executionId)
|
// lib/pq with every credential set, as go-pg replaces an empty user, password or database
|
||||||
if dialer == nil {
|
// with PGUSER, PGPASSWORD or PGDATABASE from the scanner's environment
|
||||||
return false, fmt.Errorf("dialers not initialized for %s", executionId)
|
db, err := pgwrap.OpenDB(execCtx, executionId, buildPostgresDSN(host, port, username, password, dbName))
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
db := pg.Connect(&pg.Options{
|
|
||||||
Addr: target,
|
|
||||||
User: username,
|
|
||||||
Password: password,
|
|
||||||
Database: dbName,
|
|
||||||
Dialer: func(dialCtx context.Context, network, addr string) (net.Conn, error) {
|
|
||||||
return dialer.Fastdialer.Dial(dialCtx, network, addr)
|
|
||||||
},
|
|
||||||
IdleCheckFrequency: -1,
|
|
||||||
}).WithTimeout(10 * time.Second)
|
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
_ = db.Close()
|
_ = db.Close()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
_, err := db.ExecContext(execCtx, "select 1")
|
_, err = db.ExecContext(execCtx, "select 1")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch true {
|
switch true {
|
||||||
case strings.Contains(err.Error(), "connect: connection refused"):
|
case strings.Contains(err.Error(), "connect: connection refused"):
|
||||||
|
|||||||
@@ -1,74 +1,165 @@
|
|||||||
package postgres
|
package postgres
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/url"
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/lib/pq"
|
"github.com/lib/pq"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestBuildPostgresConnectionURLDoesNotAllowDBNameQueryInjection(t *testing.T) {
|
func TestBuildPostgresDSNEscapesDBName(t *testing.T) {
|
||||||
dbName := "testdb?sslrootcert=/etc/passwd&sslmode=verify-ca&junk="
|
dbName := "testdb' sslrootcert='/etc/passwd' sslmode='verify-ca"
|
||||||
connStr := buildPostgresConnURL("postgres", "x", "127.0.0.1:5432", dbName, "exec-1")
|
cfg, err := pq.NewConfig(buildPostgresDSN("127.0.0.1", 5432, "postgres", "x", dbName))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
u, err := url.Parse(connStr)
|
require.Equal(t, dbName, cfg.Database)
|
||||||
if err != nil {
|
require.Equal(t, pq.SSLModeDisable, cfg.SSLMode)
|
||||||
t.Fatalf("parse connection URL: %v", err)
|
require.Empty(t, cfg.SSLRootCert)
|
||||||
}
|
require.Empty(t, cfg.Runtime)
|
||||||
|
|
||||||
if got := strings.TrimPrefix(u.Path, "/"); got != dbName {
|
|
||||||
t.Fatalf("database name = %q, want %q", got, dbName)
|
|
||||||
}
|
|
||||||
|
|
||||||
values := u.Query()
|
|
||||||
if got := values.Get("sslmode"); got != "disable" {
|
|
||||||
t.Fatalf("sslmode = %q, want disable", got)
|
|
||||||
}
|
|
||||||
if got := values.Get("executionId"); got != "exec-1" {
|
|
||||||
t.Fatalf("executionId = %q, want exec-1", got)
|
|
||||||
}
|
|
||||||
deniedParams := []string{"sslrootcert", "sslcert", "sslkey", "service", "junk"}
|
|
||||||
for _, denied := range deniedParams {
|
|
||||||
if got := values.Get(denied); got != "" {
|
|
||||||
t.Fatalf("%s was injected with value %q", denied, got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pqDSN, err := pq.ParseURL(connStr) //nolint:staticcheck // validates lib/pq URL parsing of the generated DSN.
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parse connection URL as lib/pq DSN: %v", err)
|
|
||||||
}
|
|
||||||
if !strings.Contains(pqDSN, "dbname='"+dbName+"'") {
|
|
||||||
t.Fatalf("lib/pq DSN = %q, want dbName preserved as dbname", pqDSN)
|
|
||||||
}
|
|
||||||
for _, denied := range deniedParams {
|
|
||||||
if strings.Contains(pqDSN, " "+denied+"=") {
|
|
||||||
t.Fatalf("%s was injected into lib/pq DSN %q", denied, pqDSN)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildPostgresConnectionURLEscapesCredentials(t *testing.T) {
|
func TestBuildPostgresDSNEscapesCredentials(t *testing.T) {
|
||||||
username := "user:[email protected]"
|
username := `user:[email protected]' host='other`
|
||||||
password := "pa:ss@word?x"
|
password := `pa:ss@word?x\' sslmode='require`
|
||||||
connStr := buildPostgresConnURL(username, password, "127.0.0.1:5432", "postgres", "exec-1")
|
cfg, err := pq.NewConfig(buildPostgresDSN("127.0.0.1", 5432, username, password, "postgres"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Equal(t, username, cfg.User)
|
||||||
|
require.Equal(t, password, cfg.Password)
|
||||||
|
require.Equal(t, "127.0.0.1", cfg.Host)
|
||||||
|
require.Equal(t, uint16(5432), cfg.Port)
|
||||||
|
require.Equal(t, "postgres", cfg.Database)
|
||||||
|
require.Equal(t, pq.SSLModeDisable, cfg.SSLMode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setOperatorPostgresEnv sets every source lib/pq and go-pg read credentials from
|
||||||
|
func setOperatorPostgresEnv(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
pgpass := filepath.Join(home, ".pgpass")
|
||||||
|
require.NoError(t, os.WriteFile(pgpass, []byte("*:*:*:*:OPERATOR-PGPASS-SECRET\n"), 0600))
|
||||||
|
serviceFile := filepath.Join(home, ".pg_service.conf")
|
||||||
|
require.NoError(t, os.WriteFile(serviceFile, []byte("[operator]\npassword=OPERATOR-SERVICE-SECRET\n"), 0600))
|
||||||
|
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
t.Setenv("PGPASSFILE", pgpass)
|
||||||
|
t.Setenv("PGSERVICEFILE", serviceFile)
|
||||||
|
t.Setenv("PGSERVICE", "operator")
|
||||||
|
t.Setenv("PGPASSWORD", "OPERATOR-ENV-PGPASSWORD")
|
||||||
|
t.Setenv("PGUSER", "operator-user")
|
||||||
|
t.Setenv("PGDATABASE", "operator-db")
|
||||||
|
t.Setenv("PGOPTIONS", "-c search_path=operator")
|
||||||
|
t.Setenv("PGAPPNAME", "operator-app")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildPostgresDSNIgnoresOperatorEnvironment(t *testing.T) {
|
||||||
|
setOperatorPostgresEnv(t)
|
||||||
|
|
||||||
|
cfg, err := pq.NewConfig(buildPostgresDSN("127.0.0.1", 5432, "", "", ""))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, cfg.User)
|
||||||
|
require.Empty(t, cfg.Password)
|
||||||
|
require.Empty(t, cfg.Database)
|
||||||
|
require.Empty(t, cfg.Service)
|
||||||
|
require.Empty(t, cfg.Options)
|
||||||
|
require.Empty(t, cfg.ApplicationName)
|
||||||
|
}
|
||||||
|
|
||||||
|
type startup struct {
|
||||||
|
user, database, password string
|
||||||
|
}
|
||||||
|
|
||||||
|
// fakePostgres asks every client for a cleartext password, records it and rejects the login
|
||||||
|
func fakePostgres(t *testing.T) (int, <-chan startup) {
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = listener.Close() })
|
||||||
|
|
||||||
|
logins := make(chan startup, 16)
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
conn, err := listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func(conn net.Conn) {
|
||||||
|
defer func() { _ = conn.Close() }()
|
||||||
|
readMessage := func(typed bool) []byte {
|
||||||
|
header := make([]byte, 4)
|
||||||
|
if typed {
|
||||||
|
header = make([]byte, 5)
|
||||||
|
}
|
||||||
|
if _, err := io.ReadFull(conn, header); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
body := make([]byte, binary.BigEndian.Uint32(header[len(header)-4:])-4)
|
||||||
|
if _, err := io.ReadFull(conn, body); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
params := readMessage(false)
|
||||||
|
if len(params) < 4 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fields := strings.Split(string(params[4:]), "\x00")
|
||||||
|
var login startup
|
||||||
|
for i := 0; i+1 < len(fields); i += 2 {
|
||||||
|
switch fields[i] {
|
||||||
|
case "user":
|
||||||
|
login.user = fields[i+1]
|
||||||
|
case "database":
|
||||||
|
login.database = fields[i+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// AuthenticationCleartextPassword
|
||||||
|
if _, err := conn.Write([]byte{'R', 0, 0, 0, 8, 0, 0, 0, 3}); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
password := readMessage(true)
|
||||||
|
login.password = strings.TrimSuffix(string(password), "\x00")
|
||||||
|
logins <- login
|
||||||
|
errorBody := "SFATAL\x00C28P01\x00Mpassword authentication failed\x00\x00"
|
||||||
|
header := []byte{'E', 0, 0, 0, 0}
|
||||||
|
binary.BigEndian.PutUint32(header[1:], uint32(len(errorBody)+4))
|
||||||
|
_, _ = conn.Write(append(header, errorBody...))
|
||||||
|
}(conn)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return listener.Addr().(*net.TCPAddr).Port, logins
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostgresEmptyCredentialsAreNotFilledFromEnvironment(t *testing.T) {
|
||||||
|
setOperatorPostgresEnv(t)
|
||||||
|
|
||||||
|
executionID := t.Name()
|
||||||
|
require.NoError(t, protocolstate.Init(&types.Options{ExecutionId: executionID}))
|
||||||
|
t.Cleanup(func() { protocolstate.Close(executionID) })
|
||||||
|
|
||||||
|
port, logins := fakePostgres(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, err := executeQuery(ctx, executionID, "127.0.0.1", port, "postgres", "", "postgres", "select 1")
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Equal(t, startup{user: "postgres", database: "postgres"}, <-logins)
|
||||||
|
|
||||||
|
connected, err := connect(ctx, executionID, "127.0.0.1", port, "postgres", "", "postgres")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, connected)
|
||||||
|
require.Equal(t, startup{user: "postgres", database: "postgres"}, <-logins)
|
||||||
|
|
||||||
|
_, _ = connect(ctx, executionID, "127.0.0.1", port, "", "", "")
|
||||||
|
require.Equal(t, startup{}, <-logins)
|
||||||
|
|
||||||
|
_, _ = executeQuery(ctx, executionID, "127.0.0.1", port, "admin", "secret", "db", "select 1")
|
||||||
|
require.Equal(t, startup{user: "admin", database: "db", password: "secret"}, <-logins)
|
||||||
|
|
||||||
u, err := url.Parse(connStr)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("parse connection URL: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if got := u.User.Username(); got != username {
|
|
||||||
t.Fatalf("username = %q, want %q", got, username)
|
|
||||||
}
|
|
||||||
if got, _ := u.User.Password(); got != password {
|
|
||||||
t.Fatalf("password = %q, want %q", got, password)
|
|
||||||
}
|
|
||||||
if got := u.Host; got != "127.0.0.1:5432" {
|
|
||||||
t.Fatalf("host = %q, want 127.0.0.1:5432", got)
|
|
||||||
}
|
|
||||||
if got := strings.TrimPrefix(u.Path, "/"); got != "postgres" {
|
|
||||||
t.Fatalf("database name = %q, want postgres", got)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+12
-2
@@ -23,6 +23,7 @@ type (
|
|||||||
// const client = new ssh.SSHClient();
|
// const client = new ssh.SSHClient();
|
||||||
// ```
|
// ```
|
||||||
SSHClient struct {
|
SSHClient struct {
|
||||||
|
// connection is nil when no connection is open.
|
||||||
connection *ssh.Client
|
connection *ssh.Client
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
}
|
}
|
||||||
@@ -158,7 +159,8 @@ func (c *SSHClient) Run(cmd string) (string, error) {
|
|||||||
|
|
||||||
// Close closes the SSH connection and destroys the client
|
// Close closes the SSH connection and destroys the client
|
||||||
// Returns the success state and error. If error is not nil,
|
// Returns the success state and error. If error is not nil,
|
||||||
// state will be false
|
// state will be false. Close does nothing and returns true
|
||||||
|
// if no connection is open.
|
||||||
// @example
|
// @example
|
||||||
// ```javascript
|
// ```javascript
|
||||||
// const ssh = require('nuclei/ssh');
|
// const ssh = require('nuclei/ssh');
|
||||||
@@ -167,9 +169,17 @@ func (c *SSHClient) Run(cmd string) (string, error) {
|
|||||||
// const closed = client.Close();
|
// const closed = client.Close();
|
||||||
// ```
|
// ```
|
||||||
func (c *SSHClient) Close() (bool, error) {
|
func (c *SSHClient) Close() (bool, error) {
|
||||||
if err := c.connection.Close(); err != nil {
|
if c.connection == nil {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
err := c.connection.Close()
|
||||||
|
c.connection = nil
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
package ssh_test
|
||||||
|
|
||||||
|
//nolint:depguard // No depguard rules are configured, so its default list allows only the standard library.
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
libssh "github.com/projectdiscovery/nuclei/v3/pkg/js/libs/ssh"
|
||||||
|
"github.com/zmap/zgrab2/lib/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
testUser = "user"
|
||||||
|
testPassword = "secret"
|
||||||
|
)
|
||||||
|
|
||||||
|
var errAccessDenied = errors.New("access denied")
|
||||||
|
|
||||||
|
func TestCloseWithoutConnection(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
host, port := startServer(t)
|
||||||
|
|
||||||
|
tests := map[string]func(t *testing.T, client *libssh.SSHClient){
|
||||||
|
"never connected": func(*testing.T, *libssh.SSHClient) {},
|
||||||
|
"failed connect": func(t *testing.T, client *libssh.SSHClient) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
_, err := client.Connect(executionContext(t), host, port, testUser, "wrong")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("Connect() with a wrong password succeeded")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"already closed": func(t *testing.T, client *libssh.SSHClient) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
connect(t, client, host, port)
|
||||||
|
closeClient(t, client)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for name, setup := range tests {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
client := new(libssh.SSHClient)
|
||||||
|
setup(t, client)
|
||||||
|
closeClient(t, client)
|
||||||
|
|
||||||
|
_, err := client.Run("id")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "no connection") {
|
||||||
|
t.Errorf("Run() error = %v, want no connection", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCloseConnected(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
host, port := startServer(t)
|
||||||
|
client := new(libssh.SSHClient)
|
||||||
|
connect(t, client, host, port)
|
||||||
|
closeClient(t, client)
|
||||||
|
}
|
||||||
|
|
||||||
|
func connect(t *testing.T, client *libssh.SSHClient, host string, port int) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
_, err := client.Connect(executionContext(t), host, port, testUser, testPassword)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Connect() error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func closeClient(t *testing.T, client *libssh.SSHClient) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
closed, err := client.Close()
|
||||||
|
if err != nil || !closed {
|
||||||
|
t.Fatalf("Close() = %v, %v; want true, nil", closed, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func executionContext(t *testing.T) context.Context {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
return context.WithValue(t.Context(), "executionId", t.Name()) //nolint:staticcheck // Connect reads this string key.
|
||||||
|
}
|
||||||
|
|
||||||
|
// startServer serves SSH on a loopback port and accepts only
|
||||||
|
// testUser with testPassword. It rejects every channel.
|
||||||
|
func startServer(t *testing.T) (string, int) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
signer, err := ssh.NewSignerFromKey(key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
config := new(ssh.ServerConfig)
|
||||||
|
config.PasswordCallback = checkPassword
|
||||||
|
config.AddHostKey(signer)
|
||||||
|
|
||||||
|
listener, err := new(net.ListenConfig).Listen(t.Context(), "tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() { _ = listener.Close() })
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
conn, err := listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go serve(conn, config)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
addr, ok := listener.Addr().(*net.TCPAddr)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("listener address %v is not TCP", listener.Addr())
|
||||||
|
}
|
||||||
|
|
||||||
|
return addr.IP.String(), addr.Port
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkPassword(conn ssh.ConnMetadata, password []byte) (*ssh.Permissions, error) {
|
||||||
|
if conn.User() != testUser || string(password) != testPassword {
|
||||||
|
return nil, errAccessDenied
|
||||||
|
}
|
||||||
|
|
||||||
|
return new(ssh.Permissions), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func serve(conn net.Conn, config *ssh.ServerConfig) {
|
||||||
|
defer func() { _ = conn.Close() }()
|
||||||
|
|
||||||
|
_, chans, reqs, err := ssh.NewServerConn(conn, config)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go ssh.DiscardRequests(reqs)
|
||||||
|
|
||||||
|
for ch := range chans {
|
||||||
|
_ = ch.Reject(ssh.Prohibited, "test server")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
package output
|
package output
|
||||||
|
|
||||||
import json "github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
json "github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
|
||||||
|
)
|
||||||
|
|
||||||
// formatJSON formats the output for json based formatting
|
// formatJSON formats the output for json based formatting
|
||||||
func (w *StandardWriter) formatJSON(output *ResultEvent) ([]byte, error) {
|
func (w *StandardWriter) formatJSON(output *ResultEvent) ([]byte, error) {
|
||||||
@@ -8,5 +12,50 @@ func (w *StandardWriter) formatJSON(output *ResultEvent) ([]byte, error) {
|
|||||||
output.Request = ""
|
output.Request = ""
|
||||||
output.Response = ""
|
output.Response = ""
|
||||||
}
|
}
|
||||||
|
if len(output.Info.Metadata) > 0 {
|
||||||
|
output.Info.Metadata = sanitizeMetadataForJSON(output.Info.Metadata)
|
||||||
|
}
|
||||||
|
if len(output.Metadata) > 0 {
|
||||||
|
output.Metadata = sanitizeMetadataForJSON(output.Metadata)
|
||||||
|
}
|
||||||
return json.Marshal(output)
|
return json.Marshal(output)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sanitizeMetadataForJSON normalizes any map[interface{}]interface{} or nested
|
||||||
|
// collections in metadata to map[string]interface{}, ensuring JSON encoders
|
||||||
|
// (such as sonic or standard encoding/json) can serialize them without error (#7971).
|
||||||
|
func sanitizeMetadataForJSON(m map[string]interface{}) map[string]interface{} {
|
||||||
|
if len(m) == 0 {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
sanitized := sanitizeJSONValue(m)
|
||||||
|
if res, ok := sanitized.(map[string]interface{}); ok {
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeJSONValue(v interface{}) interface{} {
|
||||||
|
switch typed := v.(type) {
|
||||||
|
case map[interface{}]interface{}:
|
||||||
|
converted := make(map[string]interface{}, len(typed))
|
||||||
|
for k, val := range typed {
|
||||||
|
converted[fmt.Sprint(k)] = sanitizeJSONValue(val)
|
||||||
|
}
|
||||||
|
return converted
|
||||||
|
case map[string]interface{}:
|
||||||
|
converted := make(map[string]interface{}, len(typed))
|
||||||
|
for k, val := range typed {
|
||||||
|
converted[k] = sanitizeJSONValue(val)
|
||||||
|
}
|
||||||
|
return converted
|
||||||
|
case []interface{}:
|
||||||
|
converted := make([]interface{}, len(typed))
|
||||||
|
for i, val := range typed {
|
||||||
|
converted[i] = sanitizeJSONValue(val)
|
||||||
|
}
|
||||||
|
return converted
|
||||||
|
default:
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -209,3 +209,33 @@ type testWriteCloser struct {
|
|||||||
func (w testWriteCloser) Close() error {
|
func (w testWriteCloser) Close() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func TestFormatJSONSanitizesNestedMetadataMaps(t *testing.T) {
|
||||||
|
w, err := NewStandardWriter(&types.Options{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
event := &ResultEvent{
|
||||||
|
TemplateID: "nested-metadata",
|
||||||
|
Type: "file",
|
||||||
|
Host: "target",
|
||||||
|
Matched: "target/input.txt",
|
||||||
|
Info: model.Info{
|
||||||
|
Name: "Nested metadata",
|
||||||
|
Metadata: map[string]interface{}{
|
||||||
|
"labels": map[interface{}]interface{}{
|
||||||
|
"en": "Hello",
|
||||||
|
"nested_list": []interface{}{
|
||||||
|
map[interface{}]interface{}{
|
||||||
|
"inner_key": "inner_val",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := w.formatJSON(event)
|
||||||
|
require.NoError(t, err, "formatJSON should not fail on nested map[interface{}]interface{} (#7971)")
|
||||||
|
require.Contains(t, string(data), `"labels":{"en":"Hello","nested_list":[{"inner_key":"inner_val"}]}`)
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/alecthomas/chroma/quick"
|
"github.com/alecthomas/chroma/v2/quick"
|
||||||
"github.com/ditashi/jsbeautifier-go/jsbeautifier"
|
"github.com/ditashi/jsbeautifier-go/jsbeautifier"
|
||||||
"github.com/projectdiscovery/goja"
|
"github.com/projectdiscovery/goja"
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package expressions
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/projectdiscovery/govaluate"
|
"github.com/projectdiscovery/govaluate"
|
||||||
@@ -48,55 +49,75 @@ func EvaluateWithOptions(data string, base map[string]interface{}, options *type
|
|||||||
return evaluate(data, base, options)
|
return evaluate(data, base, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// evaluate renders data, the template text, with base, the data values.
|
||||||
|
//
|
||||||
|
// Expressions are located in data before anything is substituted, and each
|
||||||
|
// result is written at its expression's position. The text between
|
||||||
|
// expressions only has its placeholders replaced. Nothing that evaluate
|
||||||
|
// writes is scanned again, so a value or a helper result that looks like an
|
||||||
|
// expression stays literal and never takes another expression's result. On
|
||||||
|
// error, data is returned unchanged.
|
||||||
|
//
|
||||||
|
// Expressions can be simple (base keys) or complex (helper functions and base
|
||||||
|
// keys). Literals such as {{2+2}} are not expressions.
|
||||||
func evaluate(data string, base map[string]interface{}, options *types.Options) (string, error) {
|
func evaluate(data string, base map[string]interface{}, options *types.Options) (string, error) {
|
||||||
expressions := FindExpressions(data, marker.ParenthesisOpen, marker.ParenthesisClose, base)
|
spans := findExpressionSpans(data, marker.ParenthesisOpen, marker.ParenthesisClose, base)
|
||||||
|
if len(spans) == 0 {
|
||||||
// replace simple placeholders (key => value) MarkerOpen + key + MarkerClose and General + key + General to value
|
return replacer.Replace(data, base), nil
|
||||||
data = replacer.Replace(data, base)
|
|
||||||
|
|
||||||
// expressions can be:
|
|
||||||
// - simple: containing base values keys (variables)
|
|
||||||
// - complex: containing helper functions [ + variables]
|
|
||||||
// literals like {{2+2}} are not considered expressions
|
|
||||||
for _, expression := range expressions {
|
|
||||||
originalExpression := expression
|
|
||||||
// data has already had simple placeholders replaced; keep the same
|
|
||||||
// marker shape for output replacement, but never compile this string.
|
|
||||||
replacedExpression := replacer.Replace(expression, base)
|
|
||||||
expression = replaceStringPlaceholders(expression, base)
|
|
||||||
|
|
||||||
// turns expressions (either helper functions+base values or base values)
|
|
||||||
compiled, err := govaluate.NewEvaluableExpressionWithFunctions(expression, dsl.HelperFunctions)
|
|
||||||
if err != nil {
|
|
||||||
return data, fmt.Errorf("failed to compile expression %q: %w", originalExpression, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := dsl.EvalWithOptions(compiled, base, options)
|
|
||||||
if err != nil {
|
|
||||||
return data, fmt.Errorf("failed to evaluate expression %q: %w", originalExpression, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
replacement := result
|
|
||||||
// Preserve unresolved markers only when a helper call would otherwise
|
|
||||||
// hide them from downstream validation. Plain expressions such as
|
|
||||||
// comparisons should evaluate normally.
|
|
||||||
if markers := unresolvedVarMarkers(compiled.Vars(), base); markers != "" {
|
|
||||||
usesFunctions := false
|
|
||||||
for _, token := range compiled.Tokens() {
|
|
||||||
if token.Kind == govaluate.FUNCTION {
|
|
||||||
usesFunctions = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if usesFunctions && ContainsUnresolvedVariables(fmt.Sprint(result)) == nil {
|
|
||||||
replacement = markers
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// replace incrementally
|
|
||||||
data = replacer.ReplaceOne(data, replacedExpression, replacement)
|
|
||||||
}
|
}
|
||||||
return data, nil
|
|
||||||
|
var (
|
||||||
|
builder strings.Builder
|
||||||
|
last int
|
||||||
|
)
|
||||||
|
|
||||||
|
builder.Grow(len(data))
|
||||||
|
|
||||||
|
for _, span := range spans {
|
||||||
|
result, err := evaluateExpression(span.expression, base, options)
|
||||||
|
if err != nil {
|
||||||
|
return data, err
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.WriteString(replacer.Replace(data[last:span.start], base))
|
||||||
|
builder.WriteString(result)
|
||||||
|
|
||||||
|
last = span.end
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.WriteString(replacer.Replace(data[last:], base))
|
||||||
|
|
||||||
|
return builder.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// evaluateExpression compiles and evaluates one template-authored expression,
|
||||||
|
// the text between its markers, and returns the result as text.
|
||||||
|
func evaluateExpression(expression string, base map[string]any, options *types.Options) (string, error) {
|
||||||
|
source := replaceStringPlaceholders(expression, base)
|
||||||
|
|
||||||
|
compiled, err := govaluate.NewEvaluableExpressionWithFunctions(source, dsl.HelperFunctions)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to compile expression %q: %w", expression, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := dsl.EvalWithOptions(compiled, base, options)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to evaluate expression %q: %w", expression, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preserve unresolved markers only when a helper call would otherwise
|
||||||
|
// hide them from downstream validation. Plain expressions such as
|
||||||
|
// comparisons should evaluate normally.
|
||||||
|
if markers := unresolvedVarMarkers(compiled.Vars(), base); markers != "" {
|
||||||
|
usesFunctions := slices.ContainsFunc(compiled.Tokens(), func(token govaluate.ExpressionToken) bool {
|
||||||
|
return token.Kind == govaluate.FUNCTION
|
||||||
|
})
|
||||||
|
if usesFunctions && ContainsUnresolvedVariables(fmt.Sprint(result)) == nil {
|
||||||
|
return markers, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return types.ToString(result), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func replaceStringPlaceholders(expression string, base map[string]interface{}) string {
|
func replaceStringPlaceholders(expression string, base map[string]interface{}) string {
|
||||||
@@ -206,59 +227,63 @@ func EscapeStringValue(value string, quote byte) string {
|
|||||||
// maxIterations to avoid infinite loop
|
// maxIterations to avoid infinite loop
|
||||||
const maxIterations = 250
|
const maxIterations = 250
|
||||||
|
|
||||||
|
// FindExpressions returns the expressions in data, the text between each
|
||||||
|
// matching OpenMarker and CloseMarker pair, in order.
|
||||||
func FindExpressions(data, OpenMarker, CloseMarker string, base map[string]interface{}) []string {
|
func FindExpressions(data, OpenMarker, CloseMarker string, base map[string]interface{}) []string {
|
||||||
var (
|
spans := findExpressionSpans(data, OpenMarker, CloseMarker, base)
|
||||||
iterations int
|
if len(spans) == 0 {
|
||||||
exps []string
|
return nil
|
||||||
)
|
}
|
||||||
for iterations <= maxIterations {
|
|
||||||
// check if we reached the maximum number of iterations
|
|
||||||
|
|
||||||
iterations++
|
exps := make([]string, 0, len(spans))
|
||||||
// attempt to find open markers
|
for _, span := range spans {
|
||||||
indexOpenMarker := strings.Index(data, OpenMarker)
|
exps = append(exps, span.expression)
|
||||||
// exits if not found
|
}
|
||||||
if indexOpenMarker < 0 {
|
|
||||||
|
return exps
|
||||||
|
}
|
||||||
|
|
||||||
|
// expressionSpan is one expression in template text: data[start:end] is the
|
||||||
|
// whole marker and expression is the text between its markers.
|
||||||
|
type expressionSpan struct {
|
||||||
|
start, end int
|
||||||
|
expression string
|
||||||
|
}
|
||||||
|
|
||||||
|
// findExpressionSpans finds the expressions in data and where each one sits.
|
||||||
|
// For each open marker, it takes the nearest close marker that makes the text
|
||||||
|
// between them an expression. When none does, the search resumes right after
|
||||||
|
// the open marker.
|
||||||
|
func findExpressionSpans(data, openMarker, closeMarker string, base map[string]any) []expressionSpan {
|
||||||
|
var spans []expressionSpan
|
||||||
|
|
||||||
|
for offset, iterations := 0, 0; iterations <= maxIterations; iterations++ {
|
||||||
|
openIndex := strings.Index(data[offset:], openMarker)
|
||||||
|
if openIndex < 0 {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
indexOpenMarkerOffset := indexOpenMarker + len(OpenMarker)
|
start := offset + openIndex
|
||||||
|
inner := start + len(openMarker)
|
||||||
|
offset = inner
|
||||||
|
|
||||||
shouldSearchCloseMarker := true
|
for search := inner; ; {
|
||||||
closeMarkerFound := false
|
closeIndex := stringsutil.IndexAt(data, closeMarker, search)
|
||||||
innerData := data
|
if closeIndex < 0 {
|
||||||
var potentialMatch string
|
break
|
||||||
var indexCloseMarker, indexCloseMarkerOffset int
|
|
||||||
skip := indexOpenMarkerOffset
|
|
||||||
for shouldSearchCloseMarker {
|
|
||||||
// attempt to find close marker
|
|
||||||
indexCloseMarker = stringsutil.IndexAt(innerData, CloseMarker, skip)
|
|
||||||
// if no close markers are found exit
|
|
||||||
if indexCloseMarker < 0 {
|
|
||||||
shouldSearchCloseMarker = false
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
indexCloseMarkerOffset = indexCloseMarker + len(CloseMarker)
|
|
||||||
|
|
||||||
potentialMatch = innerData[indexOpenMarkerOffset:indexCloseMarker]
|
search = closeIndex + len(closeMarker)
|
||||||
if isExpression(potentialMatch, base) {
|
if expression := data[inner:closeIndex]; isExpression(expression, base) {
|
||||||
closeMarkerFound = true
|
spans = append(spans, expressionSpan{start: start, end: search, expression: expression})
|
||||||
shouldSearchCloseMarker = false
|
offset = search
|
||||||
exps = append(exps, potentialMatch)
|
|
||||||
} else {
|
break
|
||||||
skip = indexCloseMarkerOffset
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if closeMarkerFound {
|
|
||||||
// move after the close marker
|
|
||||||
data = data[indexCloseMarkerOffset:]
|
|
||||||
} else {
|
|
||||||
// move after the open marker
|
|
||||||
data = data[indexOpenMarkerOffset:]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return exps
|
|
||||||
|
return spans
|
||||||
}
|
}
|
||||||
|
|
||||||
func isExpression(data string, base map[string]interface{}) bool {
|
func isExpression(data string, base map[string]interface{}) bool {
|
||||||
|
|||||||
@@ -109,6 +109,71 @@ func TestEvaluateDoesNotReinterpretResolvedValues(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEvaluateReplacesExpressionsInTheirOwnPosition(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// tpl and echo stand for response values that repeat an expression's text.
|
||||||
|
values := map[string]any{
|
||||||
|
"seed": "hello",
|
||||||
|
"tpl": "{{to_upper(seed)}}",
|
||||||
|
"echo": "{{base64('127.0.0.1')}}",
|
||||||
|
"Host": "127.0.0.1",
|
||||||
|
}
|
||||||
|
|
||||||
|
items := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "resolved value echoes a later expression",
|
||||||
|
input: "[{{tpl}}] {{to_upper(seed)}}",
|
||||||
|
expected: "[{{to_upper(seed)}}] HELLO",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "general marker value echoes a later expression",
|
||||||
|
input: "[§tpl§] {{to_upper(seed)}}",
|
||||||
|
expected: "[{{to_upper(seed)}}] HELLO",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "resolved value echoes an expression with substituted arguments",
|
||||||
|
input: "{{echo}} {{base64('{{Host}}')}}",
|
||||||
|
expected: "{{base64('127.0.0.1')}} MTI3LjAuMC4x",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "helper result echoes a later expression",
|
||||||
|
input: "{{concat('{{', 'to_upper(seed)', '}}')}} {{to_upper(seed)}}",
|
||||||
|
expected: "{{to_upper(seed)}} HELLO",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "repeated expression is evaluated in each position",
|
||||||
|
input: "{{to_upper(seed)}}-{{to_upper(seed)}}",
|
||||||
|
expected: "HELLO-HELLO",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
t.Run(item.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
value, err := Evaluate(item.input, values)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, item.expected, value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEvaluateDoesNotResolveGeneralMarkersFromResolvedValues(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
value, err := Evaluate("Authorization: {{body}}", map[string]any{
|
||||||
|
"body": "§secret_token§",
|
||||||
|
"secret_token": "top-secret-cia-mi6-kgb-mossad-classified",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "Authorization: §secret_token§", value)
|
||||||
|
}
|
||||||
|
|
||||||
func TestEvaluateDoesNotExecuteHelpersFromResolvedValues(t *testing.T) {
|
func TestEvaluateDoesNotExecuteHelpersFromResolvedValues(t *testing.T) {
|
||||||
var calls int
|
var calls int
|
||||||
|
|
||||||
@@ -206,6 +271,18 @@ func TestEvaluateReturnsErrorForInvalidTemplateExpression(t *testing.T) {
|
|||||||
require.ErrorContains(t, err, `failed to evaluate expression "base64()"`)
|
require.ErrorContains(t, err, `failed to evaluate expression "base64()"`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEvaluateReturnsInputOnError(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
input := "{{secret_token}} {{base64()}}"
|
||||||
|
|
||||||
|
value, err := Evaluate(input, map[string]any{
|
||||||
|
"secret_token": "top-secret-cia-mi6-kgb-mossad-classified",
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Equal(t, input, value)
|
||||||
|
}
|
||||||
|
|
||||||
func TestEvaluateErrorDoesNotLeakResolvedValues(t *testing.T) {
|
func TestEvaluateErrorDoesNotLeakResolvedValues(t *testing.T) {
|
||||||
_, err := Evaluate("{{base64('{{secret_token}}', 'extra')}}", map[string]interface{}{
|
_, err := Evaluate("{{base64('{{secret_token}}', 'extra')}}", map[string]interface{}{
|
||||||
"secret_token": "top-secret-cia-mi6-kgb-mossad-classified",
|
"secret_token": "top-secret-cia-mi6-kgb-mossad-classified",
|
||||||
|
|||||||
@@ -115,6 +115,26 @@ func ContainsVariablesWithIgnoreList(skipNames map[string]interface{}, items ...
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReferencesVariables reports whether data contains a {{...}} marker that
|
||||||
|
// refers to a variable in names, either alone or inside an expression. It
|
||||||
|
// parses the marker but does not evaluate it.
|
||||||
|
func ReferencesVariables(data string, names map[string]interface{}) bool {
|
||||||
|
for _, match := range unresolvedVariablesRegex.FindAllStringSubmatch(data, -1) {
|
||||||
|
expr, err := govaluate.NewEvaluableExpressionWithFunctions(match[1], dsl.HelperFunctions)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range expr.Vars() {
|
||||||
|
if _, ok := names[name]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func hasLiteralsOnly(data string) bool {
|
func hasLiteralsOnly(data string) bool {
|
||||||
expr, err := govaluate.NewEvaluableExpressionWithFunctions(data, dsl.HelperFunctions)
|
expr, err := govaluate.NewEvaluableExpressionWithFunctions(data, dsl.HelperFunctions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -44,6 +44,26 @@ func TestUnresolvedVariablesCheck(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReferencesVariables(t *testing.T) {
|
||||||
|
names := map[string]interface{}{"Hostname": "", "Port": ""}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
data string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{data: "user@{{Hostname}}", want: true},
|
||||||
|
{data: "{{to_lower(Hostname)}}:{{Port}}", want: true},
|
||||||
|
{data: "{{Hostname_suffix}}", want: false},
|
||||||
|
{data: "{{secret}}-{{to_upper(seed)}}", want: false},
|
||||||
|
{data: "{{7*7}}", want: false},
|
||||||
|
{data: "Hostname", want: false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
require.Equal(t, test.want, ReferencesVariables(test.data, names), test.data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestUnresolvedVariablesCheckDoesNotExecuteHelpers(t *testing.T) {
|
func TestUnresolvedVariablesCheckDoesNotExecuteHelpers(t *testing.T) {
|
||||||
var calls int
|
var calls int
|
||||||
withTestHelperFunction(t, "test_side_effect", func(args ...interface{}) (interface{}, error) {
|
withTestHelperFunction(t, "test_side_effect", func(args ...interface{}) (interface{}, error) {
|
||||||
|
|||||||
@@ -35,5 +35,7 @@ func Init(options *types.Options) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func Close(executionId string) {
|
func Close(executionId string) {
|
||||||
|
dnsclientpool.Close(executionId)
|
||||||
|
rdapclientpool.Close(executionId)
|
||||||
protocolstate.Close(executionId)
|
protocolstate.Close(executionId)
|
||||||
}
|
}
|
||||||
@@ -3,27 +3,186 @@ package replacer
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/projectdiscovery/fasttemplate"
|
|
||||||
convUtil "github.com/projectdiscovery/utils/conversion"
|
|
||||||
|
|
||||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/marker"
|
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/marker"
|
||||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Replace replaces placeholders in template with values on the fly.
|
// Replace substitutes each {{key}} and §key§ placeholder in template whose key
|
||||||
|
// is in values, scanning template once from left to right.
|
||||||
|
//
|
||||||
|
// Values are data: Replace never scans an inserted value for placeholders, so
|
||||||
|
// a value cannot pull in another value, alone or together with the template
|
||||||
|
// text around it. Text that does not form a known placeholder is kept as is.
|
||||||
func Replace(template string, values map[string]interface{}) string {
|
func Replace(template string, values map[string]interface{}) string {
|
||||||
valuesMap := make(map[string]interface{}, len(values))
|
if len(values) == 0 {
|
||||||
for k, v := range values {
|
return template
|
||||||
// fasttemplate copies string values to []byte on every write, so
|
|
||||||
// pass a read-only []byte view instead
|
|
||||||
valuesMap[k] = convUtil.Bytes(types.ToString(v))
|
|
||||||
}
|
}
|
||||||
replaced := fasttemplate.ExecuteStringStd(template, marker.ParenthesisOpen, marker.ParenthesisClose, valuesMap)
|
|
||||||
final := fasttemplate.ExecuteStringStd(replaced, marker.General, marker.General, valuesMap)
|
scan := newScanner(template, values)
|
||||||
return final
|
|
||||||
|
start := scan.nextPlaceholder(0)
|
||||||
|
if start < 0 {
|
||||||
|
return template
|
||||||
|
}
|
||||||
|
|
||||||
|
var builder strings.Builder
|
||||||
|
|
||||||
|
builder.Grow(len(template))
|
||||||
|
|
||||||
|
written := 0 // template[:written] is already in builder
|
||||||
|
|
||||||
|
for pos := start; pos >= 0; {
|
||||||
|
value, end, ok := scan.resolve(pos)
|
||||||
|
if !ok {
|
||||||
|
// Not a known placeholder: keep it as text and look again one
|
||||||
|
// byte later, so a placeholder nested in it still resolves.
|
||||||
|
pos = scan.nextPlaceholder(pos + 1)
|
||||||
|
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.WriteString(template[written:pos])
|
||||||
|
builder.WriteString(value)
|
||||||
|
|
||||||
|
written = end
|
||||||
|
pos = scan.nextPlaceholder(end)
|
||||||
|
}
|
||||||
|
|
||||||
|
builder.WriteString(template[written:])
|
||||||
|
|
||||||
|
return builder.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace replaces one placeholder in template with one value on the fly.
|
// directLookupLimit is the longest candidate key that resolve looks up in
|
||||||
|
// values without first comparing it to the longest key there.
|
||||||
|
const directLookupLimit = 32
|
||||||
|
|
||||||
|
// scanner finds the placeholders of one template. Replace asks for positions
|
||||||
|
// that never decrease, so each finder searches every byte at most once. A
|
||||||
|
// candidate key longer than every key in values is rejected without hashing
|
||||||
|
// it. Together, these keep Replace linear in the template length.
|
||||||
|
type scanner struct {
|
||||||
|
template string
|
||||||
|
values map[string]any
|
||||||
|
longestKey int // length of the longest key in values; -1 until needed
|
||||||
|
braceOpen, braceClose finder
|
||||||
|
generalOpen, generalClose finder
|
||||||
|
}
|
||||||
|
|
||||||
|
func newScanner(template string, values map[string]any) scanner {
|
||||||
|
return scanner{
|
||||||
|
template: template,
|
||||||
|
values: values,
|
||||||
|
longestKey: -1,
|
||||||
|
braceOpen: newFinder(marker.ParenthesisOpen),
|
||||||
|
braceClose: newFinder(marker.ParenthesisClose),
|
||||||
|
generalOpen: newFinder(marker.General),
|
||||||
|
generalClose: newFinder(marker.General),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// nextPlaceholder returns the index of the next {{ or § at or after from that
|
||||||
|
// can still be closed, or -1 when there is none. Once no closing marker of a
|
||||||
|
// kind is left, no later opening marker of that kind can close.
|
||||||
|
func (s *scanner) nextPlaceholder(from int) int {
|
||||||
|
brace, general := -1, -1
|
||||||
|
|
||||||
|
if !s.braceClose.exhausted() {
|
||||||
|
brace = s.braceOpen.index(s.template, from)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.generalClose.exhausted() {
|
||||||
|
general = s.generalOpen.index(s.template, from)
|
||||||
|
}
|
||||||
|
|
||||||
|
if brace < 0 || (general >= 0 && general < brace) {
|
||||||
|
return general
|
||||||
|
}
|
||||||
|
|
||||||
|
return brace
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolve returns the value of the placeholder that starts at index start,
|
||||||
|
// and the index just past it, when its key is in values.
|
||||||
|
func (s *scanner) resolve(start int) (string, int, bool) {
|
||||||
|
open, closing := marker.ParenthesisOpen, &s.braceClose
|
||||||
|
if strings.HasPrefix(s.template[start:], marker.General) {
|
||||||
|
open, closing = marker.General, &s.generalClose
|
||||||
|
}
|
||||||
|
|
||||||
|
keyStart := start + len(open)
|
||||||
|
|
||||||
|
keyEnd := closing.index(s.template, keyStart)
|
||||||
|
if keyEnd < 0 {
|
||||||
|
return "", 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
key := s.template[keyStart:keyEnd]
|
||||||
|
if len(key) > directLookupLimit && len(key) > s.longestKeyLen() {
|
||||||
|
return "", 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
value, ok := s.values[key]
|
||||||
|
if !ok {
|
||||||
|
return "", 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
return types.ToString(value), keyEnd + len(closing.needle), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// longestKeyLen returns the length of the longest key in values.
|
||||||
|
func (s *scanner) longestKeyLen() int {
|
||||||
|
if s.longestKey < 0 {
|
||||||
|
s.longestKey = 0
|
||||||
|
for key := range s.values {
|
||||||
|
s.longestKey = max(s.longestKey, len(key))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.longestKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// finder finds one marker in a template. Its callers pass positions that
|
||||||
|
// never decrease, so it keeps the last match until the scan passes it, and a
|
||||||
|
// failed search answers every later call.
|
||||||
|
type finder struct {
|
||||||
|
needle string
|
||||||
|
next int // last match, or -1 when none is left
|
||||||
|
searched bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFinder(needle string) finder {
|
||||||
|
return finder{needle: needle, next: -1, searched: false}
|
||||||
|
}
|
||||||
|
|
||||||
|
// index returns the index of the first needle at or after from, or -1.
|
||||||
|
func (f *finder) index(template string, from int) int {
|
||||||
|
if f.searched && (f.next < 0 || f.next >= from) {
|
||||||
|
return f.next
|
||||||
|
}
|
||||||
|
|
||||||
|
f.searched = true
|
||||||
|
|
||||||
|
f.next = strings.Index(template[from:], f.needle)
|
||||||
|
if f.next >= 0 {
|
||||||
|
f.next += from
|
||||||
|
}
|
||||||
|
|
||||||
|
return f.next
|
||||||
|
}
|
||||||
|
|
||||||
|
// exhausted reports whether a search found no match left in the template.
|
||||||
|
func (f *finder) exhausted() bool {
|
||||||
|
return f.searched && f.next < 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReplaceOne replaces the first {{key}} and the first §key§ in template with
|
||||||
|
// value.
|
||||||
|
//
|
||||||
|
// Deprecated: ReplaceOne finds the placeholder by searching template, so when
|
||||||
|
// template already holds substituted values it can replace text that a value
|
||||||
|
// introduced instead of the placeholder the caller meant. Render by position
|
||||||
|
// with Replace instead.
|
||||||
func ReplaceOne(template string, key string, value interface{}) string {
|
func ReplaceOne(template string, key string, value interface{}) string {
|
||||||
data := replaceOneWithMarkers(template, key, value, marker.ParenthesisOpen, marker.ParenthesisClose)
|
data := replaceOneWithMarkers(template, key, value, marker.ParenthesisOpen, marker.ParenthesisClose)
|
||||||
return replaceOneWithMarkers(data, key, value, marker.General, marker.General)
|
return replaceOneWithMarkers(data, key, value, marker.General, marker.General)
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package replacer_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/replacer"
|
||||||
|
)
|
||||||
|
|
||||||
|
func BenchmarkReplace(b *testing.B) {
|
||||||
|
// An HTTP request renders with about 60 values, enough that map lookups
|
||||||
|
// hash their keys.
|
||||||
|
values := map[string]any{"Hostname": "example.com", "token": "abc"}
|
||||||
|
for i := range 60 {
|
||||||
|
values["var"+strconv.Itoa(i)] = i
|
||||||
|
}
|
||||||
|
|
||||||
|
header := "POST /?t={{token}} HTTP/1.1\r\nHost: {{Hostname}}\r\n\r\n"
|
||||||
|
|
||||||
|
for _, item := range []struct {
|
||||||
|
name string
|
||||||
|
template string
|
||||||
|
}{
|
||||||
|
{name: "raw request", template: header},
|
||||||
|
{name: "64KB JSON body", template: header + strings.Repeat(`{"k": "v", "n": [1, 2]} `, 2730)},
|
||||||
|
{name: "64KB non-ASCII body", template: header + strings.Repeat("Привет, мир! ", 2730)},
|
||||||
|
{name: "64KB unclosed markers", template: strings.Repeat(`{{`, 32768)},
|
||||||
|
{name: "64KB markers closed at the end", template: strings.Repeat(`{`, 65536) + `}}`},
|
||||||
|
} {
|
||||||
|
b.Run(item.name, func(b *testing.B) {
|
||||||
|
b.ReportAllocs()
|
||||||
|
|
||||||
|
for b.Loop() {
|
||||||
|
replacer.Replace(item.template, values)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,6 +73,24 @@ func TestReplacerReplace(t *testing.T) {
|
|||||||
values: map[string]interface{}{"var1": "first variable", "var2": "second variable"},
|
values: map[string]interface{}{"var1": "first variable", "var2": "second variable"},
|
||||||
expected: "first variable and first variable and another second variable",
|
expected: "first variable and first variable and another second variable",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "Extra braces around a placeholder",
|
||||||
|
template: "${{{num}}*{{{num}}",
|
||||||
|
values: map[string]any{"num": "7"},
|
||||||
|
expected: "${7*{7",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Unknown general marker before a known one",
|
||||||
|
template: "§unknown§var1§",
|
||||||
|
values: map[string]any{"var1": "variable 1"},
|
||||||
|
expected: "§unknownvariable 1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Unclosed markers",
|
||||||
|
template: "{{{{ §§ {{var1}",
|
||||||
|
values: map[string]any{"var1": "variable 1"},
|
||||||
|
expected: "{{{{ §§ {{var1}",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
@@ -82,6 +100,48 @@ func TestReplacerReplace(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestReplacerReplaceDoesNotRescanValues(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
values := map[string]any{
|
||||||
|
"secret": "leaked",
|
||||||
|
"token": "leaked",
|
||||||
|
"general": "§secret§",
|
||||||
|
"parenthesis": "{{secret}}",
|
||||||
|
"prefix": "§",
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
template string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "general marker inside a parenthesis value",
|
||||||
|
template: "{{general}}",
|
||||||
|
expected: "§secret§",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "parenthesis marker inside a general value",
|
||||||
|
template: "§parenthesis§",
|
||||||
|
expected: "{{secret}}",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "value completes a marker with the template text after it",
|
||||||
|
template: "{{prefix}}token§",
|
||||||
|
expected: "§token§",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
require.Equal(t, test.expected, Replace(test.template, values))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestReplacerReplaceOne(t *testing.T) {
|
func TestReplacerReplaceOne(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package dns
|
package dns
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/miekg/dns"
|
"github.com/miekg/dns"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
@@ -15,8 +17,8 @@ import (
|
|||||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/render"
|
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/render"
|
||||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/replacer"
|
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/replacer"
|
||||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/dns/dnsclientpool"
|
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/dns/dnsclientpool"
|
||||||
"github.com/projectdiscovery/retryabledns"
|
|
||||||
fileutil "github.com/projectdiscovery/utils/file"
|
fileutil "github.com/projectdiscovery/utils/file"
|
||||||
|
iputil "github.com/projectdiscovery/utils/ip"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Request contains a DNS protocol request to be made from a template
|
// Request contains a DNS protocol request to be made from a template
|
||||||
@@ -87,7 +89,7 @@ type Request struct {
|
|||||||
generator *generators.PayloadGenerator
|
generator *generators.PayloadGenerator
|
||||||
|
|
||||||
CompiledOperators *operators.Operators `yaml:"-" json:"-"`
|
CompiledOperators *operators.Operators `yaml:"-" json:"-"`
|
||||||
dnsClient *retryabledns.Client
|
dnsClient *dnsclientpool.Client
|
||||||
options *protocols.ExecutorOptions
|
options *protocols.ExecutorOptions
|
||||||
|
|
||||||
// cache any variables that may be needed for operation.
|
// cache any variables that may be needed for operation.
|
||||||
@@ -205,7 +207,49 @@ func resolverHost(resolver string) string {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
func (request *Request) getDnsClient(options *protocols.ExecutorOptions, metadata map[string]interface{}) (*retryabledns.Client, error) {
|
// pinResolverHost replaces a resolver host name with an address that the
|
||||||
|
// network policy allows. The policy check passes when one address of the name
|
||||||
|
// is allowed, and the dns client resolves the name again when it dials, so it
|
||||||
|
// could otherwise reach a denied address of the same name.
|
||||||
|
// The lookup stops after timeout, so a slow system resolver cannot hold the
|
||||||
|
// request.
|
||||||
|
func pinResolverHost(executionID, resolver, host string, timeout time.Duration) (string, error) {
|
||||||
|
// DoH and DoT resolvers dial through the scan dialer, which checks every
|
||||||
|
// address, and DoT verifies the certificate against the name.
|
||||||
|
if iputil.IsIP(host) || strings.HasPrefix(resolver, "doh:") || strings.HasPrefix(resolver, "dot:") {
|
||||||
|
return resolver, nil
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
addrs, err := net.DefaultResolver.LookupHost(ctx, host)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Wrapf(err, "could not resolve dns resolver %s", resolver)
|
||||||
|
}
|
||||||
|
prefix, address := resolver, ""
|
||||||
|
if index := strings.LastIndex(resolver, host); index != -1 {
|
||||||
|
prefix, address = resolver[:index], resolver[index:]
|
||||||
|
}
|
||||||
|
port := "53"
|
||||||
|
if _, p, err := net.SplitHostPort(address); err == nil && p != "" {
|
||||||
|
port = p
|
||||||
|
}
|
||||||
|
for _, addr := range addrs {
|
||||||
|
if protocolstate.IsHostAllowed(executionID, addr) {
|
||||||
|
return prefix + net.JoinHostPort(addr, port), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", errors.Errorf("dns resolver %s is blocked by network policy", resolver)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolverLookupTimeout is the scan timeout, or ten seconds when it is not set.
|
||||||
|
func resolverLookupTimeout(options *protocols.ExecutorOptions) time.Duration {
|
||||||
|
if options.Options.Timeout > 0 {
|
||||||
|
return time.Duration(options.Options.Timeout) * time.Second
|
||||||
|
}
|
||||||
|
return 10 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
func (request *Request) getDnsClient(options *protocols.ExecutorOptions, metadata map[string]interface{}) (*dnsclientpool.Client, error) {
|
||||||
dnsClientOptions := &dnsclientpool.Configuration{
|
dnsClientOptions := &dnsclientpool.Configuration{
|
||||||
Retries: request.Retries,
|
Retries: request.Retries,
|
||||||
Proxy: options.Options.AliveSocksProxy,
|
Proxy: options.Options.AliveSocksProxy,
|
||||||
@@ -238,6 +282,11 @@ func (request *Request) getDnsClient(options *protocols.ExecutorOptions, metadat
|
|||||||
if !protocolstate.IsHostAllowed(options.Options.ExecutionId, host) {
|
if !protocolstate.IsHostAllowed(options.Options.ExecutionId, host) {
|
||||||
return nil, errors.Errorf("dns resolver %s is blocked by network policy", resolver)
|
return nil, errors.Errorf("dns resolver %s is blocked by network policy", resolver)
|
||||||
}
|
}
|
||||||
|
pinned, err := pinResolverHost(options.Options.ExecutionId, resolver, host, resolverLookupTimeout(options))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resolver = pinned
|
||||||
}
|
}
|
||||||
resolvers = append(resolvers, resolver)
|
resolvers = append(resolvers, resolver)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
clientPool *mapsutil.SyncLockMap[string, *retryabledns.Client]
|
clientPool *mapsutil.SyncLockMap[string, *Client]
|
||||||
|
|
||||||
normalClient *retryabledns.Client
|
normalClient *Client
|
||||||
m sync.Mutex
|
m sync.Mutex
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -46,20 +46,21 @@ func resolversFor(options *types.Options) []string {
|
|||||||
return defaultResolvers
|
return defaultResolvers
|
||||||
}
|
}
|
||||||
|
|
||||||
func cachedClient(resolvers []string) (*retryabledns.Client, error) {
|
func cachedClient(resolvers []string) (*Client, error) {
|
||||||
m.Lock()
|
m.Lock()
|
||||||
defer m.Unlock()
|
defer m.Unlock()
|
||||||
if clientPool == nil {
|
if clientPool == nil {
|
||||||
clientPool = mapsutil.NewSyncLockMap[string, *retryabledns.Client]()
|
clientPool = mapsutil.NewSyncLockMap[string, *Client]()
|
||||||
}
|
}
|
||||||
key := strings.Join(resolvers, ",")
|
key := strings.Join(resolvers, ",")
|
||||||
if client, ok := clientPool.Get(key); ok {
|
if client, ok := clientPool.Get(key); ok {
|
||||||
return client, nil
|
return client, nil
|
||||||
}
|
}
|
||||||
client, err := retryabledns.New(resolvers, 1)
|
dnsClient, err := retryabledns.New(resolvers, 1)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
client := &Client{Client: dnsClient}
|
||||||
_ = clientPool.Set(key, client)
|
_ = clientPool.Set(key, client)
|
||||||
return client, nil
|
return client, nil
|
||||||
}
|
}
|
||||||
@@ -88,10 +89,13 @@ func (c *Configuration) Hash() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get creates or gets a client for the protocol based on custom configuration
|
// Get creates or gets a client for the protocol based on custom configuration
|
||||||
func Get(options *types.Options, configuration *Configuration) (*retryabledns.Client, error) {
|
func Get(options *types.Options, configuration *Configuration) (*Client, error) {
|
||||||
if (configuration.Retries <= 1) && len(configuration.Resolvers) == 0 {
|
if (configuration.Retries <= 1) && len(configuration.Resolvers) == 0 {
|
||||||
return cachedClient(resolversFor(options))
|
return cachedClient(resolversFor(options))
|
||||||
}
|
}
|
||||||
|
if len(options.InternalResolversList) == 0 && hasPolicyResolver(configuration.Resolvers) {
|
||||||
|
return getPolicyClient(options, configuration)
|
||||||
|
}
|
||||||
hash := configuration.Hash()
|
hash := configuration.Hash()
|
||||||
if client, ok := clientPool.Get(hash); ok {
|
if client, ok := clientPool.Get(hash); ok {
|
||||||
return client, nil
|
return client, nil
|
||||||
@@ -103,7 +107,7 @@ func Get(options *types.Options, configuration *Configuration) (*retryabledns.Cl
|
|||||||
} else if len(configuration.Resolvers) > 0 {
|
} else if len(configuration.Resolvers) > 0 {
|
||||||
resolvers = configuration.Resolvers
|
resolvers = configuration.Resolvers
|
||||||
}
|
}
|
||||||
client, err := retryabledns.NewWithOptions(retryabledns.Options{
|
dnsClient, err := retryabledns.NewWithOptions(retryabledns.Options{
|
||||||
BaseResolvers: resolvers,
|
BaseResolvers: resolvers,
|
||||||
MaxRetries: configuration.Retries,
|
MaxRetries: configuration.Retries,
|
||||||
Proxy: options.AliveSocksProxy,
|
Proxy: options.AliveSocksProxy,
|
||||||
@@ -111,6 +115,7 @@ func Get(options *types.Options, configuration *Configuration) (*retryabledns.Cl
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(err, "could not create dns client")
|
return nil, errors.Wrap(err, "could not create dns client")
|
||||||
}
|
}
|
||||||
|
client := &Client{Client: dnsClient}
|
||||||
_ = clientPool.Set(hash, client)
|
_ = clientPool.Set(hash, client)
|
||||||
|
|
||||||
return client, nil
|
return client, nil
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
package dnsclientpool
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||||
|
"github.com/projectdiscovery/retryabledns"
|
||||||
|
"github.com/projectdiscovery/retryabledns/doh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client sends the DNS queries of a template. It sends queries for template
|
||||||
|
// DoH and DoT resolvers itself, because the retryabledns clients dial and
|
||||||
|
// follow redirects without the network policy of the scan.
|
||||||
|
type Client struct {
|
||||||
|
*retryabledns.Client
|
||||||
|
|
||||||
|
resolvers []resolver
|
||||||
|
retries int
|
||||||
|
index uint32
|
||||||
|
doh *doh.Client
|
||||||
|
transport *http.Transport
|
||||||
|
dial func(ctx context.Context, network, address string) (net.Conn, error)
|
||||||
|
timeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolver is one template resolver. url is set for a DoH resolver and dot
|
||||||
|
// for a DoT resolver, and both are empty for a resolver that retryabledns
|
||||||
|
// queries over UDP or TCP.
|
||||||
|
type resolver struct {
|
||||||
|
url string
|
||||||
|
method doh.Method
|
||||||
|
dot string
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
// policyClients holds the clients with DoH or DoT resolvers of each execution,
|
||||||
|
// because each one dials with the dialer of its execution.
|
||||||
|
policyClients = map[string]map[string]*Client{}
|
||||||
|
policyMu sync.Mutex
|
||||||
|
|
||||||
|
// dotRootCAs verifies DoT certificates, and nil uses the system roots.
|
||||||
|
dotRootCAs *x509.CertPool
|
||||||
|
)
|
||||||
|
|
||||||
|
func hasPolicyResolver(resolvers []string) bool {
|
||||||
|
for _, r := range resolvers {
|
||||||
|
if strings.HasPrefix(r, "doh:") || strings.HasPrefix(r, "dot:") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDotResolver returns the host and port of a resolver such as
|
||||||
|
// dot:dns.example or dot:dns.example:853, in the same way as retryabledns.
|
||||||
|
func parseDotResolver(r string) (resolver, bool) {
|
||||||
|
if !strings.HasPrefix(r, "dot:") {
|
||||||
|
return resolver{}, false
|
||||||
|
}
|
||||||
|
address := strings.TrimPrefix(r, "dot:")
|
||||||
|
if _, _, err := net.SplitHostPort(address); err != nil {
|
||||||
|
address = net.JoinHostPort(address, "853")
|
||||||
|
}
|
||||||
|
return resolver{dot: address}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDohResolver returns the URL and method of a resolver such as
|
||||||
|
// doh:https://dns.example/dns-query:get, in the same way as retryabledns.
|
||||||
|
func parseDohResolver(r string) (resolver, bool) {
|
||||||
|
if !strings.HasPrefix(r, "doh:") {
|
||||||
|
return resolver{}, false
|
||||||
|
}
|
||||||
|
u := strings.TrimPrefix(r, "doh:")
|
||||||
|
method := doh.MethodPost
|
||||||
|
for _, suffix := range []string{":get", ":post", ":jsonapi"} {
|
||||||
|
if strings.HasSuffix(u, suffix) {
|
||||||
|
u = strings.TrimSuffix(u, suffix)
|
||||||
|
if suffix == ":get" {
|
||||||
|
method = doh.MethodGet
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resolver{url: u, method: method}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPolicyClient(options *types.Options, configuration *Configuration) (*Client, error) {
|
||||||
|
executionID := options.ExecutionId
|
||||||
|
hash := configuration.Hash()
|
||||||
|
|
||||||
|
policyMu.Lock()
|
||||||
|
defer policyMu.Unlock()
|
||||||
|
if client, ok := policyClients[executionID][hash]; ok {
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &Client{retries: configuration.Retries}
|
||||||
|
var networkResolvers []string
|
||||||
|
for _, r := range configuration.Resolvers {
|
||||||
|
if parsed, ok := parseDohResolver(r); ok {
|
||||||
|
client.resolvers = append(client.resolvers, parsed)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if parsed, ok := parseDotResolver(r); ok {
|
||||||
|
client.resolvers = append(client.resolvers, parsed)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
client.resolvers = append(client.resolvers, resolver{})
|
||||||
|
networkResolvers = append(networkResolvers, r)
|
||||||
|
}
|
||||||
|
// Trace and the UDP and TCP resolvers still go through retryabledns.
|
||||||
|
if len(networkResolvers) == 0 {
|
||||||
|
networkResolvers = defaultResolvers
|
||||||
|
}
|
||||||
|
dnsClient, err := retryabledns.NewWithOptions(retryabledns.Options{
|
||||||
|
BaseResolvers: networkResolvers,
|
||||||
|
MaxRetries: 1,
|
||||||
|
Proxy: options.AliveSocksProxy,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(err, "could not create dns client")
|
||||||
|
}
|
||||||
|
client.Client = dnsClient
|
||||||
|
|
||||||
|
// The scan dialer checks every address it connects to, so redirects
|
||||||
|
// and names that resolve to a denied address are refused too. It also
|
||||||
|
// goes through the scan proxy when one is set.
|
||||||
|
client.dial = func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||||
|
dialers := protocolstate.GetDialersWithId(executionID)
|
||||||
|
if dialers == nil || dialers.Fastdialer == nil {
|
||||||
|
return nil, fmt.Errorf("dialers not initialized for %s", executionID)
|
||||||
|
}
|
||||||
|
return dialers.Fastdialer.Dial(ctx, network, address)
|
||||||
|
}
|
||||||
|
client.timeout = options.GetTimeouts().DialTimeout
|
||||||
|
client.transport = &http.Transport{
|
||||||
|
DialContext: client.dial,
|
||||||
|
// same as the retryabledns DoH client
|
||||||
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // #nosec G402
|
||||||
|
}
|
||||||
|
client.doh = doh.NewWithOptions(doh.Options{
|
||||||
|
HttpClient: &http.Client{
|
||||||
|
Transport: client.transport,
|
||||||
|
Timeout: options.GetTimeouts().HttpTimeout,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if policyClients[executionID] == nil {
|
||||||
|
policyClients[executionID] = map[string]*Client{}
|
||||||
|
}
|
||||||
|
policyClients[executionID][hash] = client
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do sends a DNS message to the resolvers in turn until one answers with
|
||||||
|
// NOERROR, like retryabledns.Client.Do.
|
||||||
|
func (c *Client) Do(msg *dns.Msg) (*dns.Msg, error) {
|
||||||
|
if len(c.resolvers) == 0 {
|
||||||
|
return c.Client.Do(msg)
|
||||||
|
}
|
||||||
|
var resp *dns.Msg
|
||||||
|
var err error
|
||||||
|
for i := 0; i < c.retries; i++ {
|
||||||
|
index := atomic.AddUint32(&c.index, 1)
|
||||||
|
r := c.resolvers[index%uint32(len(c.resolvers))]
|
||||||
|
if r.url != "" {
|
||||||
|
resp, err = c.doh.QueryWithDOHMsg(r.method, doh.Resolver{URL: r.url}, msg)
|
||||||
|
} else if r.dot != "" {
|
||||||
|
resp, err = c.queryDoT(r.dot, msg)
|
||||||
|
} else {
|
||||||
|
resp, err = c.Client.Do(msg)
|
||||||
|
}
|
||||||
|
if err == nil && resp != nil && resp.Rcode == dns.RcodeSuccess {
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resp, retryabledns.ErrRetriesExceeded
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryDoT sends a DNS message over TLS to address. It dials through the scan
|
||||||
|
// dialer and verifies the certificate against the resolver host, as the
|
||||||
|
// retryabledns DoT client does.
|
||||||
|
func (c *Client) queryDoT(address string, msg *dns.Msg) (*dns.Msg, error) {
|
||||||
|
host, _, err := net.SplitHostPort(address)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
timeout := c.timeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = retryabledns.DefaultOptions.Timeout
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
conn, err := c.dial(ctx, "tcp", address)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tlsConn := tls.Client(conn, &tls.Config{ServerName: host, RootCAs: dotRootCAs, MinVersion: tls.VersionTLS12})
|
||||||
|
defer func() { _ = tlsConn.Close() }()
|
||||||
|
if err := tlsConn.HandshakeContext(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dnsClient := &dns.Client{Net: "tcp-tls", Timeout: timeout}
|
||||||
|
resp, _, err := dnsClient.ExchangeWithConn(msg, &dns.Conn{Conn: tlsConn})
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close drops the clients with DoH or DoT resolvers of an execution.
|
||||||
|
func Close(executionID string) {
|
||||||
|
policyMu.Lock()
|
||||||
|
clients := policyClients[executionID]
|
||||||
|
delete(policyClients, executionID)
|
||||||
|
policyMu.Unlock()
|
||||||
|
for _, client := range clients {
|
||||||
|
client.transport.CloseIdleConnections()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package dnsclientpool
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newDohServer answers every DoH query with a TXT record and counts the hits.
|
||||||
|
func newDohServer(t *testing.T, hits *atomic.Int32) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hits.Add(1)
|
||||||
|
m := new(dns.Msg)
|
||||||
|
m.SetQuestion("example.com.", dns.TypeTXT)
|
||||||
|
m.Response = true
|
||||||
|
rr, err := dns.NewRR(`example.com. 60 IN TXT "doh-answer"`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
m.Answer = append(m.Answer, rr)
|
||||||
|
packed, err := m.Pack()
|
||||||
|
require.NoError(t, err)
|
||||||
|
w.Header().Set("Content-Type", "application/dns-message")
|
||||||
|
_, _ = w.Write(packed)
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
return server
|
||||||
|
}
|
||||||
|
|
||||||
|
func dohOptions(t *testing.T, excludeTargets []string) *types.Options {
|
||||||
|
t.Helper()
|
||||||
|
options := types.DefaultOptions()
|
||||||
|
options.ExecutionId = t.Name()
|
||||||
|
options.ExcludeTargets = excludeTargets
|
||||||
|
require.NoError(t, protocolstate.Init(options))
|
||||||
|
t.Cleanup(func() { protocolstate.Close(options.ExecutionId) })
|
||||||
|
require.NoError(t, Init(options))
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
func txtQuery() *dns.Msg {
|
||||||
|
msg := new(dns.Msg)
|
||||||
|
msg.SetQuestion("example.com.", dns.TypeTXT)
|
||||||
|
return msg
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDohResolverObeysNetworkPolicy(t *testing.T) {
|
||||||
|
t.Run("denied address", func(t *testing.T) {
|
||||||
|
var hits atomic.Int32
|
||||||
|
internal := newDohServer(t, &hits)
|
||||||
|
options := dohOptions(t, []string{"127.0.0.1"})
|
||||||
|
|
||||||
|
client, err := Get(options, &Configuration{Retries: 1, Resolvers: []string{"doh:" + internal.URL + "/dns-query:get"}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, _ = client.Do(txtQuery())
|
||||||
|
require.Zero(t, hits.Load(), "DoH query reached a denied address")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("redirect to denied address", func(t *testing.T) {
|
||||||
|
var hits atomic.Int32
|
||||||
|
internal := newDohServer(t, &hits)
|
||||||
|
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, internal.URL+"/internal", http.StatusFound)
|
||||||
|
}))
|
||||||
|
t.Cleanup(redirector.Close)
|
||||||
|
// localhost passes the resolver check through ::1, while 127.0.0.1 is denied
|
||||||
|
_, port, err := net.SplitHostPort(strings.TrimPrefix(redirector.URL, "http://"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
options := dohOptions(t, []string{"127.0.0.1"})
|
||||||
|
require.True(t, protocolstate.IsHostAllowed(options.ExecutionId, "localhost"))
|
||||||
|
|
||||||
|
client, err := Get(options, &Configuration{Retries: 1, Resolvers: []string{"doh:http://localhost:" + port + "/dns-query:get"}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, _ = client.Do(txtQuery())
|
||||||
|
require.Zero(t, hits.Load(), "DoH redirect reached a denied address")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("allowed address", func(t *testing.T) {
|
||||||
|
var hits atomic.Int32
|
||||||
|
server := newDohServer(t, &hits)
|
||||||
|
options := dohOptions(t, nil)
|
||||||
|
|
||||||
|
client, err := Get(options, &Configuration{Retries: 2, Resolvers: []string{"doh:" + server.URL + "/dns-query:get"}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp, err := client.Do(txtQuery())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, resp.Answer, 1)
|
||||||
|
require.Equal(t, int32(1), hits.Load())
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package dnsclientpool
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newDotListener accepts TCP connections on 127.0.0.1, counts them and closes
|
||||||
|
// them, so a test sees each DoT dial that reaches the address.
|
||||||
|
func newDotListener(t *testing.T, hits *atomic.Int32) string {
|
||||||
|
t.Helper()
|
||||||
|
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = l.Close() })
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
conn, err := l.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hits.Add(1)
|
||||||
|
_ = conn.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
_, port, err := net.SplitHostPort(l.Addr().String())
|
||||||
|
require.NoError(t, err)
|
||||||
|
return port
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDotResolverObeysNetworkPolicy(t *testing.T) {
|
||||||
|
for _, host := range []string{"127.0.0.1", "localhost"} {
|
||||||
|
t.Run("denied address "+host, func(t *testing.T) {
|
||||||
|
var hits atomic.Int32
|
||||||
|
port := newDotListener(t, &hits)
|
||||||
|
// localhost passes the resolver check through ::1, while 127.0.0.1 is denied
|
||||||
|
options := dohOptions(t, []string{"127.0.0.1"})
|
||||||
|
|
||||||
|
client, err := Get(options, &Configuration{Retries: 2, Resolvers: []string{"dot:" + net.JoinHostPort(host, port)}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, _ = client.Do(txtQuery())
|
||||||
|
require.Zero(t, hits.Load(), "DoT query reached a denied address")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("allowed address", func(t *testing.T) {
|
||||||
|
var hits atomic.Int32
|
||||||
|
port := newDotListener(t, &hits)
|
||||||
|
options := dohOptions(t, nil)
|
||||||
|
|
||||||
|
client, err := Get(options, &Configuration{Retries: 1, Resolvers: []string{"dot:127.0.0.1:" + port}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, _ = client.Do(txtQuery())
|
||||||
|
require.Equal(t, int32(1), hits.Load())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDotResolverAnswers(t *testing.T) {
|
||||||
|
// httptest gives a certificate for 127.0.0.1 and a pool that trusts it.
|
||||||
|
certs := httptest.NewTLSServer(http.NotFoundHandler())
|
||||||
|
t.Cleanup(certs.Close)
|
||||||
|
previous := dotRootCAs
|
||||||
|
dotRootCAs = certs.Client().Transport.(*http.Transport).TLSClientConfig.RootCAs
|
||||||
|
t.Cleanup(func() { dotRootCAs = previous })
|
||||||
|
|
||||||
|
l, err := tls.Listen("tcp", "127.0.0.1:0", &tls.Config{Certificates: certs.TLS.Certificates, MinVersion: tls.VersionTLS12})
|
||||||
|
require.NoError(t, err)
|
||||||
|
server := &dns.Server{Listener: l, Net: "tcp-tls", Handler: dns.HandlerFunc(func(w dns.ResponseWriter, q *dns.Msg) {
|
||||||
|
m := new(dns.Msg)
|
||||||
|
m.SetReply(q)
|
||||||
|
rr, _ := dns.NewRR(`example.com. 60 IN TXT "dot-answer"`)
|
||||||
|
m.Answer = append(m.Answer, rr)
|
||||||
|
_ = w.WriteMsg(m)
|
||||||
|
})}
|
||||||
|
go func() { _ = server.ActivateAndServe() }()
|
||||||
|
t.Cleanup(func() { _ = server.Shutdown() })
|
||||||
|
|
||||||
|
options := dohOptions(t, nil)
|
||||||
|
client, err := Get(options, &Configuration{Retries: 2, Resolvers: []string{"dot:" + l.Addr().String()}})
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp, err := client.Do(txtQuery())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, resp.Answer, 1)
|
||||||
|
require.Contains(t, resp.Answer[0].String(), "dot-answer")
|
||||||
|
}
|
||||||
@@ -195,7 +195,7 @@ func (request *Request) execute(input *contextargs.Context, domain string, metad
|
|||||||
// perform trace if necessary (excluded from duration — only the query RTT is measured)
|
// perform trace if necessary (excluded from duration — only the query RTT is measured)
|
||||||
var traceData *retryabledns.TraceData
|
var traceData *retryabledns.TraceData
|
||||||
if request.Trace {
|
if request.Trace {
|
||||||
traceData, err = request.dnsClient.Trace(domain, request.question, request.TraceMaxRecursion)
|
traceData, err = trace(request.options.Options.ExecutionId, request.dnsClient, domain, request.question, request.TraceMaxRecursion)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
request.options.Output.Request(request.options.TemplatePath, domain, "dns", err)
|
request.options.Output.Request(request.options.TemplatePath, domain, "dns", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package dns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/internal/tests/testutils"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/model"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/model/types/severity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// startDNSServer answers every query with a TXT record, or with the A records
|
||||||
|
// in addrs when the question is for name, and counts the queries.
|
||||||
|
func startDNSServer(t *testing.T, name string, addrs []string, hits *atomic.Int32) string {
|
||||||
|
t.Helper()
|
||||||
|
pc, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
server := &dns.Server{PacketConn: pc, Handler: dns.HandlerFunc(func(w dns.ResponseWriter, q *dns.Msg) {
|
||||||
|
hits.Add(1)
|
||||||
|
m := new(dns.Msg)
|
||||||
|
m.SetReply(q)
|
||||||
|
question := q.Question[0]
|
||||||
|
switch {
|
||||||
|
case question.Name == name && question.Qtype == dns.TypeA:
|
||||||
|
for _, addr := range addrs {
|
||||||
|
rr, _ := dns.NewRR(name + " 0 IN A " + addr)
|
||||||
|
m.Answer = append(m.Answer, rr)
|
||||||
|
}
|
||||||
|
case question.Name == name:
|
||||||
|
default:
|
||||||
|
rr, _ := dns.NewRR(question.Name + ` 0 IN TXT "answer"`)
|
||||||
|
m.Answer = append(m.Answer, rr)
|
||||||
|
}
|
||||||
|
_ = w.WriteMsg(m)
|
||||||
|
})}
|
||||||
|
go func() { _ = server.ActivateAndServe() }()
|
||||||
|
t.Cleanup(func() { _ = server.Shutdown() })
|
||||||
|
l, err := net.Listen("tcp", pc.LocalAddr().String())
|
||||||
|
require.NoError(t, err)
|
||||||
|
tcpServer := &dns.Server{Listener: l, Handler: server.Handler}
|
||||||
|
go func() { _ = tcpServer.ActivateAndServe() }()
|
||||||
|
t.Cleanup(func() { _ = tcpServer.Shutdown() })
|
||||||
|
return pc.LocalAddr().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// useSystemResolver sends the lookups of the process to the DNS server at address.
|
||||||
|
func useSystemResolver(t *testing.T, address string) {
|
||||||
|
t.Helper()
|
||||||
|
previous := net.DefaultResolver
|
||||||
|
net.DefaultResolver = &net.Resolver{
|
||||||
|
PreferGo: true,
|
||||||
|
Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||||
|
var d net.Dialer
|
||||||
|
return d.DialContext(ctx, "udp", address)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { net.DefaultResolver = previous })
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDNSResolverNameObeysNetworkPolicy(t *testing.T) {
|
||||||
|
// The resolver name has an allowed address and a denied address. The
|
||||||
|
// policy check passes because one address is allowed, so the query must
|
||||||
|
// not go to the denied one.
|
||||||
|
var deniedHits, systemHits atomic.Int32
|
||||||
|
denied := startDNSServer(t, "", nil, &deniedHits)
|
||||||
|
_, port, err := net.SplitHostPort(denied)
|
||||||
|
require.NoError(t, err)
|
||||||
|
system := startDNSServer(t, "resolver.nuclei-test.", []string{"127.0.0.1", "127.0.0.2"}, &systemHits)
|
||||||
|
useSystemResolver(t, system)
|
||||||
|
|
||||||
|
options := testutils.DefaultOptions.Copy()
|
||||||
|
options.SetExecutionID(t.Name())
|
||||||
|
options.ExcludeTargets = []string{"127.0.0.1"}
|
||||||
|
testutils.Init(options)
|
||||||
|
t.Cleanup(func() { testutils.Cleanup(options) })
|
||||||
|
executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
|
||||||
|
ID: "dns-resolver-policy",
|
||||||
|
Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Info}, Name: "test"},
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, resolver := range []string{"resolver.nuclei-test:" + port, "tcp:resolver.nuclei-test:" + port} {
|
||||||
|
t.Run(resolver, func(t *testing.T) {
|
||||||
|
deniedHits.Store(0)
|
||||||
|
request := &Request{
|
||||||
|
RequestType: DNSRequestTypeHolder{DNSRequestType: TXT},
|
||||||
|
Class: "INET",
|
||||||
|
Retries: 2,
|
||||||
|
Name: "{{FQDN}}",
|
||||||
|
Resolvers: []string{resolver},
|
||||||
|
}
|
||||||
|
require.NoError(t, request.Compile(executerOpts))
|
||||||
|
|
||||||
|
msg := new(dns.Msg)
|
||||||
|
msg.SetQuestion("example.com.", dns.TypeTXT)
|
||||||
|
_, _ = request.dnsClient.Do(msg)
|
||||||
|
require.Zero(t, deniedHits.Load(), "dns query reached a denied address")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDNSResolverNameLookupStopsAtTimeout(t *testing.T) {
|
||||||
|
// The system resolver never answers, so the lookup that pins the resolver
|
||||||
|
// name must stop at the timeout.
|
||||||
|
silent, err := net.ListenPacket("udp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = silent.Close() })
|
||||||
|
useSystemResolver(t, silent.LocalAddr().String())
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
_, err = pinResolverHost(t.Name(), "silent.nuclei-test:53", "silent.nuclei-test", time.Second)
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Less(t, time.Since(start), 4*time.Second, "the resolver lookup did not stop at the timeout")
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package dns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"math/rand"
|
||||||
|
"net"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/dns/dnsclientpool"
|
||||||
|
"github.com/projectdiscovery/retryabledns"
|
||||||
|
sliceutil "github.com/projectdiscovery/utils/slice"
|
||||||
|
)
|
||||||
|
|
||||||
|
// traceNameServerPort is the port of the name servers that a trace queries.
|
||||||
|
var traceNameServerPort = "53"
|
||||||
|
|
||||||
|
// trace follows the delegation of host from the root servers in the same way
|
||||||
|
// as retryabledns.Client.Trace, but it skips name server addresses that the
|
||||||
|
// network policy denies. The name servers come from the answers, so the owner
|
||||||
|
// of a zone could otherwise send the queries of a trace to internal addresses.
|
||||||
|
func trace(executionID string, client *dnsclientpool.Client, host string, requestType uint16, maxRecursion int) (*retryabledns.TraceData, error) {
|
||||||
|
var traceData retryabledns.TraceData
|
||||||
|
host = dns.CanonicalName(host)
|
||||||
|
servers := retryabledns.RootDNSServersIPv4
|
||||||
|
seenNS := make(map[string]struct{})
|
||||||
|
seenCName := make(map[string]int)
|
||||||
|
for i := 1; i < maxRecursion; i++ {
|
||||||
|
responses, err := client.QueryParallel(host, requestType, servers)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, server := range servers {
|
||||||
|
seenNS[server] = struct{}{}
|
||||||
|
}
|
||||||
|
if len(responses) == 0 {
|
||||||
|
return &traceData, nil
|
||||||
|
}
|
||||||
|
for _, dnsData := range responses {
|
||||||
|
if dnsData != nil && len(dnsData.Resolver) > 0 {
|
||||||
|
traceData.DNSData = append(traceData.DNSData, dnsData)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var nameServers []string
|
||||||
|
var nextCname string
|
||||||
|
for _, dnsData := range responses {
|
||||||
|
if dnsData == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, ns := range dnsData.NS {
|
||||||
|
ips, err := net.DefaultResolver.LookupIP(context.Background(), "ip4", ns)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, ip := range ips {
|
||||||
|
if !protocolstate.IsHostAllowed(executionID, ip.String()) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
nameServers = append(nameServers, net.JoinHostPort(ip.String(), traceNameServerPort))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if nextCname == "" && len(dnsData.CNAME) > 0 {
|
||||||
|
nextCname = dnsData.CNAME[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
nameServers = sliceutil.Dedupe(nameServers)
|
||||||
|
if len(nameServers) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
server := nameServers[rand.Intn(len(nameServers))] // #nosec G404
|
||||||
|
if _, ok := seenNS[server]; ok && nextCname == "" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
servers = []string{server}
|
||||||
|
|
||||||
|
if nextCname != "" {
|
||||||
|
seenCName[nextCname]++
|
||||||
|
if seenCName[nextCname] > retryabledns.DefaultMaxPerCNAMEFollows {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
host = nextCname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &traceData, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
package dns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/miekg/dns"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/internal/tests/testutils"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/model"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/model/types/severity"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/output"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
|
||||||
|
"github.com/projectdiscovery/retryabledns"
|
||||||
|
)
|
||||||
|
|
||||||
|
func serveTraceDNS(t *testing.T, address string, handler dns.HandlerFunc) string {
|
||||||
|
t.Helper()
|
||||||
|
pc, err := net.ListenPacket("udp", address)
|
||||||
|
require.NoError(t, err)
|
||||||
|
server := &dns.Server{PacketConn: pc, Handler: handler}
|
||||||
|
go func() { _ = server.ActivateAndServe() }()
|
||||||
|
t.Cleanup(func() { _ = server.Shutdown() })
|
||||||
|
return pc.LocalAddr().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDNSTraceObeysNetworkPolicy(t *testing.T) {
|
||||||
|
// The zone of the traced name delegates to a name server on 127.0.0.1.
|
||||||
|
// The trace must query it when the policy allows the address, so that the
|
||||||
|
// test can see a trace that ignores the policy, and must skip it when the
|
||||||
|
// policy denies the address.
|
||||||
|
t.Run("allowed", func(t *testing.T) {
|
||||||
|
require.NotZero(t, runTraceAgainstLoopbackNameServer(t, nil), "trace did not query the allowed name server")
|
||||||
|
})
|
||||||
|
t.Run("denied", func(t *testing.T) {
|
||||||
|
require.Zero(t, runTraceAgainstLoopbackNameServer(t, []string{"127.0.0.1"}), "trace queried a denied name server")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func runTraceAgainstLoopbackNameServer(t *testing.T, excludeTargets []string) int32 {
|
||||||
|
t.Helper()
|
||||||
|
var internalHits atomic.Int32
|
||||||
|
internal := serveTraceDNS(t, "127.0.0.1:0", func(w dns.ResponseWriter, q *dns.Msg) {
|
||||||
|
internalHits.Add(1)
|
||||||
|
m := new(dns.Msg)
|
||||||
|
m.SetReply(q)
|
||||||
|
rr, _ := dns.NewRR(q.Question[0].Name + ` 0 IN TXT "internal"`)
|
||||||
|
m.Answer = append(m.Answer, rr)
|
||||||
|
_ = w.WriteMsg(m)
|
||||||
|
})
|
||||||
|
_, port, err := net.SplitHostPort(internal)
|
||||||
|
require.NoError(t, err)
|
||||||
|
previousPort := traceNameServerPort
|
||||||
|
traceNameServerPort = port
|
||||||
|
t.Cleanup(func() { traceNameServerPort = previousPort })
|
||||||
|
|
||||||
|
// public stands in for the root servers, the scan resolvers and the system resolver
|
||||||
|
public := serveTraceDNS(t, "127.0.0.1:0", func(w dns.ResponseWriter, q *dns.Msg) {
|
||||||
|
m := new(dns.Msg)
|
||||||
|
m.SetReply(q)
|
||||||
|
question := q.Question[0]
|
||||||
|
var rr dns.RR
|
||||||
|
switch {
|
||||||
|
case question.Name == "ns.nuclei-test." && question.Qtype == dns.TypeA:
|
||||||
|
rr, _ = dns.NewRR("ns.nuclei-test. 0 IN A 127.0.0.1")
|
||||||
|
case question.Name == "ns.nuclei-test.":
|
||||||
|
case question.Name == "trace.nuclei-test.":
|
||||||
|
rr, _ = dns.NewRR("trace.nuclei-test. 0 IN NS ns.nuclei-test.")
|
||||||
|
default:
|
||||||
|
rr, _ = dns.NewRR(question.Name + ` 0 IN TXT "public"`)
|
||||||
|
}
|
||||||
|
if rr != nil {
|
||||||
|
m.Answer = append(m.Answer, rr)
|
||||||
|
}
|
||||||
|
_ = w.WriteMsg(m)
|
||||||
|
})
|
||||||
|
previousRoots := retryabledns.RootDNSServersIPv4
|
||||||
|
retryabledns.RootDNSServersIPv4 = []string{public}
|
||||||
|
t.Cleanup(func() { retryabledns.RootDNSServersIPv4 = previousRoots })
|
||||||
|
previousResolver := net.DefaultResolver
|
||||||
|
net.DefaultResolver = &net.Resolver{
|
||||||
|
PreferGo: true,
|
||||||
|
Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
|
||||||
|
var d net.Dialer
|
||||||
|
return d.DialContext(ctx, "udp", public)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { net.DefaultResolver = previousResolver })
|
||||||
|
|
||||||
|
options := testutils.DefaultOptions.Copy()
|
||||||
|
options.SetExecutionID(t.Name())
|
||||||
|
options.ExcludeTargets = excludeTargets
|
||||||
|
options.InternalResolversList = []string{public}
|
||||||
|
testutils.Init(options)
|
||||||
|
t.Cleanup(func() { testutils.Cleanup(options) })
|
||||||
|
executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
|
||||||
|
ID: "dns-trace-policy",
|
||||||
|
Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Info}, Name: "test"},
|
||||||
|
})
|
||||||
|
|
||||||
|
request := &Request{
|
||||||
|
RequestType: DNSRequestTypeHolder{DNSRequestType: TXT},
|
||||||
|
Class: "INET",
|
||||||
|
Name: "{{FQDN}}",
|
||||||
|
// One retry keeps the client keyed by this test's resolvers, so a
|
||||||
|
// client cached by an earlier run cannot point at a closed server.
|
||||||
|
Retries: 1,
|
||||||
|
Trace: true,
|
||||||
|
TraceMaxRecursion: 5,
|
||||||
|
}
|
||||||
|
require.NoError(t, request.Compile(executerOpts))
|
||||||
|
|
||||||
|
var event output.InternalEvent
|
||||||
|
input := contextargs.NewWithInput(context.Background(), "trace.nuclei-test")
|
||||||
|
err = request.ExecuteWithResults(input, nil, nil, func(e *output.InternalWrappedEvent) {
|
||||||
|
event = e.InternalEvent
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, event["trace"], "ns.nuclei-test", "trace did not query the root servers")
|
||||||
|
return internalHits.Load()
|
||||||
|
}
|
||||||
@@ -31,6 +31,11 @@ type Browser struct {
|
|||||||
// use getHTTPClient to get the http client
|
// use getHTTPClient to get the http client
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
httpClientOnce *sync.Once
|
httpClientOnce *sync.Once
|
||||||
|
|
||||||
|
// use getScreenshotDir to get the screenshot directory
|
||||||
|
screenshotDir string
|
||||||
|
screenshotDirErr error
|
||||||
|
screenshotDirOnce sync.Once
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new nuclei headless browser module
|
// New creates a new nuclei headless browser module
|
||||||
@@ -236,6 +241,16 @@ func (b *Browser) getHTTPClient() (*http.Client, error) {
|
|||||||
return b.httpClient, err
|
return b.httpClient, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getScreenshotDir returns the directory that holds the screenshots of
|
||||||
|
// templates when local file access is off. It is created once per browser
|
||||||
|
// and kept after Close, so the screenshots stay available.
|
||||||
|
func (b *Browser) getScreenshotDir() (string, error) {
|
||||||
|
b.screenshotDirOnce.Do(func() {
|
||||||
|
b.screenshotDir, b.screenshotDirErr = os.MkdirTemp("", "nuclei-screenshots-*")
|
||||||
|
})
|
||||||
|
return b.screenshotDir, b.screenshotDirErr
|
||||||
|
}
|
||||||
|
|
||||||
// Close closes the browser engine
|
// Close closes the browser engine
|
||||||
//
|
//
|
||||||
// When connected over CDP, it does NOT close the browsers.
|
// When connected over CDP, it does NOT close the browsers.
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ type Page struct {
|
|||||||
variables map[string]interface{}
|
variables map[string]interface{}
|
||||||
lastActionNavigate *Action
|
lastActionNavigate *Action
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
|
screenshots int
|
||||||
}
|
}
|
||||||
|
|
||||||
// HistoryData contains the page request/response pairs
|
// HistoryData contains the page request/response pairs
|
||||||
|
|||||||
@@ -526,6 +526,13 @@ func (p *Page) RightClickElement(act *Action, out ActionData) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxScreenshotsPerPage and maxScreenshotSize bound the disk space that the
|
||||||
|
// screenshots of one page can use when local file access is off.
|
||||||
|
const (
|
||||||
|
maxScreenshotsPerPage = 10
|
||||||
|
maxScreenshotSize = 10 * 1024 * 1024
|
||||||
|
)
|
||||||
|
|
||||||
// Screenshot executes screenshot action on a page
|
// Screenshot executes screenshot action on a page
|
||||||
func (p *Page) Screenshot(act *Action, out ActionData) error {
|
func (p *Page) Screenshot(act *Action, out ActionData) error {
|
||||||
to, err := p.getActionArg(act, "to")
|
to, err := p.getActionArg(act, "to")
|
||||||
@@ -533,13 +540,52 @@ func (p *Page) Screenshot(act *Action, out ActionData) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
if to == "" {
|
generatedName := to == ""
|
||||||
|
if generatedName {
|
||||||
to = ksuid.New().String()
|
to = ksuid.New().String()
|
||||||
if act.Name != "" {
|
if act.Name != "" {
|
||||||
out[act.Name] = to
|
out[act.Name] = to
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lfaAllowed := protocolstate.IsLfaAllowed(p.options.Options)
|
||||||
|
if !lfaAllowed {
|
||||||
|
if p.screenshots >= maxScreenshotsPerPage {
|
||||||
|
return errkit.Newf("screenshot limit of %d per page reached, use -allow-local-file-access flag to remove it", maxScreenshotsPerPage)
|
||||||
|
}
|
||||||
|
p.screenshots++
|
||||||
|
}
|
||||||
|
|
||||||
|
var filePath string
|
||||||
|
if lfaAllowed {
|
||||||
|
filePath, err = fileutil.CleanPath(to)
|
||||||
|
if err != nil {
|
||||||
|
return errkit.Newf("could not clean output screenshot path %s", to)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(filePath, ".png") {
|
||||||
|
filePath += ".png"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := p.isScreenshotPathAllowed(to); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Without local file access the template only names a file inside a
|
||||||
|
// directory that nuclei creates, so it cannot choose where files land.
|
||||||
|
baseDir, err := p.instance.browser.getScreenshotDir()
|
||||||
|
if err != nil {
|
||||||
|
return errkit.Wrap(err, "could not create screenshot directory")
|
||||||
|
}
|
||||||
|
filePath = filepath.Join(baseDir, filepath.Clean(to))
|
||||||
|
// Add .png before the containment check, so that a name such as "."
|
||||||
|
// cannot turn into a sibling of the directory.
|
||||||
|
if !strings.HasSuffix(filePath, ".png") {
|
||||||
|
filePath += ".png"
|
||||||
|
}
|
||||||
|
if !isScreenshotPathWithinDirectory(filePath, baseDir) {
|
||||||
|
return ErrLFAccessDenied
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var data []byte
|
var data []byte
|
||||||
|
|
||||||
fullpage, err := p.getActionArg(act, "fullpage")
|
fullpage, err := p.getActionArg(act, "fullpage")
|
||||||
@@ -555,23 +601,8 @@ func (p *Page) Screenshot(act *Action, out ActionData) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "could not take screenshot")
|
return errors.Wrap(err, "could not take screenshot")
|
||||||
}
|
}
|
||||||
|
if !lfaAllowed && len(data) > maxScreenshotSize {
|
||||||
to, err = fileutil.CleanPath(to)
|
return errkit.Newf("screenshot of %d bytes is over the limit of %d bytes, use -allow-local-file-access flag to remove it", len(data), maxScreenshotSize)
|
||||||
if err != nil {
|
|
||||||
return errkit.Newf("could not clean output screenshot path %s", to)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build the final write path (with .png) BEFORE running any containment
|
|
||||||
// gate. Otherwise inputs like "." or a bare directory name pass the gate
|
|
||||||
// against `to` and then the .png suffix moves the actual write outside
|
|
||||||
// cwd (e.g. <cwd>.png is a sibling of cwd and not contained by it).
|
|
||||||
filePath := to
|
|
||||||
if !strings.HasSuffix(filePath, ".png") {
|
|
||||||
filePath += ".png"
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := p.isScreenshotPathAllowed(filePath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
mkdir, err := p.getActionArg(act, "mkdir")
|
mkdir, err := p.getActionArg(act, "mkdir")
|
||||||
@@ -596,25 +627,29 @@ func (p *Page) Screenshot(act *Action, out ActionData) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrap(err, "could not write screenshot")
|
return errors.Wrap(err, "could not write screenshot")
|
||||||
}
|
}
|
||||||
|
// The generated name alone does not locate a file inside the screenshot directory.
|
||||||
|
if generatedName && act.Name != "" && !lfaAllowed {
|
||||||
|
out[act.Name] = filePath
|
||||||
|
}
|
||||||
gologger.Info().Msgf("Screenshot successfully saved at %v\n", filePath)
|
gologger.Info().Msgf("Screenshot successfully saved at %v\n", filePath)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isScreenshotPathAllowed checks the path a template gives for a screenshot.
|
||||||
|
// Without local file access it must be relative and must not contain "..".
|
||||||
func (p *Page) isScreenshotPathAllowed(to string) error {
|
func (p *Page) isScreenshotPathAllowed(to string) error {
|
||||||
if protocolstate.IsLfaAllowed(p.options.Options) {
|
if protocolstate.IsLfaAllowed(p.options.Options) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
cwd, err := os.Getwd()
|
if filepath.IsAbs(to) || filepath.VolumeName(to) != "" || strings.HasPrefix(to, "/") || strings.HasPrefix(to, `\`) {
|
||||||
if err != nil {
|
|
||||||
return errkit.Wrap(err, "could not get current working directory")
|
|
||||||
}
|
|
||||||
|
|
||||||
if !isScreenshotPathWithinDirectory(to, cwd) {
|
|
||||||
// writing outside of cwd requires -lfa flag
|
|
||||||
return ErrLFAccessDenied
|
return ErrLFAccessDenied
|
||||||
}
|
}
|
||||||
|
for _, part := range strings.FieldsFunc(to, func(r rune) bool { return r == '/' || r == '\\' }) {
|
||||||
|
if part == ".." {
|
||||||
|
return ErrLFAccessDenied
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -449,7 +449,7 @@ func TestActionScreenshotDeniesSiblingPrefixPathWithoutLFA(t *testing.T) {
|
|||||||
err = page.isScreenshotPathAllowed(filePath)
|
err = page.isScreenshotPathAllowed(filePath)
|
||||||
require.ErrorIs(t, err, ErrLFAccessDenied)
|
require.ErrorIs(t, err, ErrLFAccessDenied)
|
||||||
|
|
||||||
err = page.isScreenshotPathAllowed(filepath.Join(cwd, "test.png"))
|
err = page.isScreenshotPathAllowed("test.png")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -541,8 +541,8 @@ func TestActionScreenshotDeniesPostExtensionEscape(t *testing.T) {
|
|||||||
"a sibling .png in cwd's parent must be rejected")
|
"a sibling .png in cwd's parent must be rejected")
|
||||||
|
|
||||||
// Sanity: a path inside cwd remains allowed.
|
// Sanity: a path inside cwd remains allowed.
|
||||||
require.NoError(t, page.isScreenshotPathAllowed(filepath.Join(cwd, "ok.png")),
|
require.NoError(t, page.isScreenshotPathAllowed("ok.png"),
|
||||||
"a path inside cwd must still be allowed")
|
"a relative path must still be allowed")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestActionScreenshotDeniesSymlinkedParentOutsideCWDWithoutLFA(t *testing.T) {
|
func TestActionScreenshotDeniesSymlinkedParentOutsideCWDWithoutLFA(t *testing.T) {
|
||||||
@@ -1051,6 +1051,10 @@ func TestMain(m *testing.M) {
|
|||||||
code := m.Run()
|
code := m.Run()
|
||||||
|
|
||||||
browser.Close()
|
browser.Close()
|
||||||
|
// screenshots of tests without local file access land here
|
||||||
|
if browser.screenshotDir != "" {
|
||||||
|
_ = os.RemoveAll(browser.screenshotDir)
|
||||||
|
}
|
||||||
os.Exit(code)
|
os.Exit(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"net/http/cookiejar"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// chdirTemp moves the test into a fresh working directory and returns it.
|
||||||
|
func chdirTemp(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
cwd := t.TempDir()
|
||||||
|
originalWd, err := os.Getwd()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, os.Chdir(cwd))
|
||||||
|
t.Cleanup(func() { require.NoError(t, os.Chdir(originalWd)) })
|
||||||
|
return cwd
|
||||||
|
}
|
||||||
|
|
||||||
|
// runWithoutLFA opens a test page with local file access off and runs the
|
||||||
|
// actions on it.
|
||||||
|
func runWithoutLFA(t *testing.T, actions []*Action) error {
|
||||||
|
t.Helper()
|
||||||
|
_, err := runWithoutLFAData(t, actions)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// runWithoutLFAData is runWithoutLFA that also returns the action data.
|
||||||
|
func runWithoutLFAData(t *testing.T, actions []*Action) (ActionData, error) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// the proxy of the shared browser dials with the empty execution id
|
||||||
|
require.NoError(t, protocolstate.Init(&types.Options{}))
|
||||||
|
opts := &types.Options{ExecutionId: t.Name(), AllowLocalFileAccess: false}
|
||||||
|
require.NoError(t, protocolstate.Init(opts))
|
||||||
|
t.Cleanup(func() { protocolstate.Close(opts.ExecutionId) })
|
||||||
|
|
||||||
|
instance, err := sharedBrowser.NewInstance()
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = instance.Close() })
|
||||||
|
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(`<html><body>Nuclei Test Page</body></html>`))
|
||||||
|
}))
|
||||||
|
t.Cleanup(ts.Close)
|
||||||
|
|
||||||
|
input := contextargs.NewWithInput(context.Background(), ts.URL)
|
||||||
|
input.CookieJar, err = cookiejar.New(nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
actions = append([]*Action{
|
||||||
|
{ActionType: ActionTypeHolder{ActionType: ActionNavigate}, Data: map[string]string{"url": "{{BaseURL}}"}},
|
||||||
|
{ActionType: ActionTypeHolder{ActionType: ActionWaitLoad}},
|
||||||
|
}, actions...)
|
||||||
|
data, page, err := instance.Run(input, actions, nil, &Options{Timeout: 20 * time.Second, Options: opts})
|
||||||
|
if page != nil {
|
||||||
|
page.Close()
|
||||||
|
}
|
||||||
|
return data, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func screenshotAction(args map[string]string) *Action {
|
||||||
|
return &Action{ActionType: ActionTypeHolder{ActionType: ActionScreenshot}, Data: args}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireNoFiles(t *testing.T, dir string) {
|
||||||
|
t.Helper()
|
||||||
|
var files []string
|
||||||
|
require.NoError(t, filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
||||||
|
if err == nil && !d.IsDir() {
|
||||||
|
files = append(files, path)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}))
|
||||||
|
require.Empty(t, files, "template wrote into the working directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScreenshotWithoutLFAWritesOnlyToScreenshotDir(t *testing.T) {
|
||||||
|
cwd := chdirTemp(t)
|
||||||
|
name := "shot-" + strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||||
|
err := runWithoutLFA(t, []*Action{
|
||||||
|
screenshotAction(map[string]string{"to": name + "/deep/dir/marker", "mkdir": "true"}),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
requireNoFiles(t, cwd)
|
||||||
|
|
||||||
|
matches, err := filepath.Glob(filepath.Join(os.TempDir(), "nuclei-screenshots-*", name, "deep", "dir", "marker.png"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, matches, 1, "screenshot is not in the nuclei screenshot directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScreenshotWithoutLFANamedResultIsFullPath(t *testing.T) {
|
||||||
|
chdirTemp(t)
|
||||||
|
action := screenshotAction(map[string]string{})
|
||||||
|
action.Name = "shot"
|
||||||
|
data, err := runWithoutLFAData(t, []*Action{action})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
path, ok := data["shot"].(string)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.True(t, filepath.IsAbs(path), "named result %q does not locate the screenshot", path)
|
||||||
|
require.FileExists(t, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScreenshotWithoutLFARejectsPaths(t *testing.T) {
|
||||||
|
cases := map[string]func(cwd string) string{
|
||||||
|
"absolute inside working directory": func(cwd string) string { return filepath.Join(cwd, "abs") },
|
||||||
|
"parent": func(string) string { return "../outside" },
|
||||||
|
"parent after folder": func(string) string { return "a/../../outside" },
|
||||||
|
}
|
||||||
|
for name, to := range cases {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
cwd := filepath.Join(chdirTemp(t), "work")
|
||||||
|
require.NoError(t, os.Mkdir(cwd, 0700))
|
||||||
|
require.NoError(t, os.Chdir(cwd))
|
||||||
|
|
||||||
|
err := runWithoutLFA(t, []*Action{screenshotAction(map[string]string{"to": to(cwd), "mkdir": "true"})})
|
||||||
|
require.ErrorIs(t, err, ErrLFAccessDenied)
|
||||||
|
requireNoFiles(t, filepath.Dir(cwd))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScreenshotWithoutLFAIsLimitedPerPage(t *testing.T) {
|
||||||
|
chdirTemp(t)
|
||||||
|
// one more than the limit of 10 screenshots per page
|
||||||
|
actions := make([]*Action, 0, 11)
|
||||||
|
for range 11 {
|
||||||
|
actions = append(actions, screenshotAction(nil))
|
||||||
|
}
|
||||||
|
require.ErrorContains(t, runWithoutLFA(t, actions), "screenshot limit", "more screenshots than the limit were written for one page")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestScreenshotWithoutLFAIsLimitedInSize(t *testing.T) {
|
||||||
|
chdirTemp(t)
|
||||||
|
noise := `() => { document.body.style.margin='0'; const c=document.createElement('canvas'); c.width=2000; c.height=2000; document.body.appendChild(c); const x=c.getContext('2d'); const d=x.createImageData(2000,2000); for(let i=0;i<d.data.length;i++){d.data[i]=(Math.random()*256)|0; if(i%4==3)d.data[i]=255;} x.putImageData(d,0,0); return 'ok'; }`
|
||||||
|
err := runWithoutLFA(t, []*Action{
|
||||||
|
{ActionType: ActionTypeHolder{ActionType: ActionScript}, Data: map[string]string{"code": noise}},
|
||||||
|
screenshotAction(map[string]string{"fullpage": "true"}),
|
||||||
|
})
|
||||||
|
require.ErrorContains(t, err, "over the limit", "a screenshot over the size limit was written")
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/utils/httpbody"
|
||||||
|
)
|
||||||
|
|
||||||
|
// bodySizeEstimate is the largest request body a template has built so far.
|
||||||
|
type bodySizeEstimate struct {
|
||||||
|
known atomic.Bool
|
||||||
|
max atomic.Int64
|
||||||
|
learning sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
type bodyReservation struct {
|
||||||
|
budget *httpbody.Budget
|
||||||
|
size int64
|
||||||
|
release func()
|
||||||
|
unlock func()
|
||||||
|
}
|
||||||
|
|
||||||
|
func noop() {}
|
||||||
|
|
||||||
|
// reserveBody reserves the template's largest body size before the body is
|
||||||
|
// built. Until a size is known, builds run one at a time.
|
||||||
|
func (request *Request) reserveBody(ctx context.Context) (*bodyReservation, error) {
|
||||||
|
r := &bodyReservation{budget: request.options.RequestBodyBudget, release: noop, unlock: noop}
|
||||||
|
estimate := request.bodySize
|
||||||
|
if r.budget == nil || estimate == nil {
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
if !estimate.known.Load() {
|
||||||
|
estimate.learning.Lock()
|
||||||
|
if !estimate.known.Load() {
|
||||||
|
r.unlock = estimate.learning.Unlock
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
estimate.learning.Unlock()
|
||||||
|
}
|
||||||
|
r.size = estimate.max.Load()
|
||||||
|
release, err := r.budget.Acquire(ctx, r.size)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r.release = release
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeReserved builds the next request inside a body reservation, waiting
|
||||||
|
// for budget until waitCtx ends. On success the caller owns the reservation
|
||||||
|
// and calls done once the send ends.
|
||||||
|
func (request *Request) makeReserved(waitCtx context.Context, generator *requestGenerator, input *contextargs.Context, data string, payloads, dynamicValues map[string]interface{}) (*generatedRequest, *bodyReservation, error) {
|
||||||
|
reservation, err := request.reserveBody(waitCtx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
generated, err := generator.Make(input.Context(), input, data, payloads, dynamicValues)
|
||||||
|
if err == nil {
|
||||||
|
err = reservation.built(waitCtx, request, generated.bodyLen())
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
reservation.done()
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
generated.bodyReserved = true
|
||||||
|
return generated, reservation, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// built grows the reservation to the body's real size. It releases before
|
||||||
|
// acquiring again: waiting while holding part of the budget can deadlock.
|
||||||
|
func (r *bodyReservation) built(ctx context.Context, request *Request, size int64) error {
|
||||||
|
if estimate := request.bodySize; estimate != nil {
|
||||||
|
for {
|
||||||
|
seen := estimate.max.Load()
|
||||||
|
if size <= seen || estimate.max.CompareAndSwap(seen, size) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
estimate.known.Store(true)
|
||||||
|
}
|
||||||
|
r.unlock()
|
||||||
|
r.unlock = noop
|
||||||
|
if size <= r.size {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
r.release()
|
||||||
|
r.release = noop
|
||||||
|
release, err := r.budget.Acquire(ctx, size)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r.release, r.size = release, size
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *bodyReservation) done() {
|
||||||
|
r.unlock()
|
||||||
|
r.release()
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
//go:build !race
|
||||||
|
|
||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"runtime"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/internal/tests/testutils"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/model"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/model/types/severity"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/output"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/utils/httpbody"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Threads and pipeline build bodies ahead of the send. Built bodies must stay
|
||||||
|
// within the budget while the server holds the first request.
|
||||||
|
func TestRequestBodyBudgetBoundsBodiesBuiltAhead(t *testing.T) {
|
||||||
|
const bodySize = 8 << 20
|
||||||
|
const payloads = 12
|
||||||
|
|
||||||
|
for _, mode := range []string{"threads", "pipeline"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
if mode == "pipeline" {
|
||||||
|
t.Skip("needs projectdiscovery/rawhttp#551: the pipeline client hangs after 10s idle")
|
||||||
|
}
|
||||||
|
arrived := make(chan struct{}, payloads)
|
||||||
|
unblock := make(chan struct{})
|
||||||
|
var received atomic.Int32
|
||||||
|
// net/http drops pipelined connections with large bodies, so serve
|
||||||
|
// Content-Length framed requests in order by hand.
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = listener.Close() }()
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
conn, err := listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
defer func() { _ = conn.Close() }()
|
||||||
|
reader := bufio.NewReader(conn)
|
||||||
|
for {
|
||||||
|
req, err := http.ReadRequest(reader)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = io.Copy(io.Discard, req.Body)
|
||||||
|
received.Add(1)
|
||||||
|
arrived <- struct{}{}
|
||||||
|
<-unblock
|
||||||
|
if _, err := io.WriteString(conn, "HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok"); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
target := "http://" + listener.Addr().String()
|
||||||
|
|
||||||
|
options := testutils.DefaultOptions.Copy()
|
||||||
|
testutils.Init(options)
|
||||||
|
|
||||||
|
values := make([]string, payloads)
|
||||||
|
for i := range values {
|
||||||
|
values[i] = fmt.Sprint(i)
|
||||||
|
}
|
||||||
|
request := &Request{
|
||||||
|
ID: "body-budget-" + mode,
|
||||||
|
Payloads: map[string]interface{}{"v": values},
|
||||||
|
}
|
||||||
|
body := `{{repeat("A", 8388608)}}`
|
||||||
|
if mode == "threads" {
|
||||||
|
request.Threads = 8
|
||||||
|
request.Path = []string{"{{BaseURL}}/?v={{v}}"}
|
||||||
|
request.Method = HTTPMethodTypeHolder{MethodType: HTTPPost}
|
||||||
|
request.Body = body
|
||||||
|
} else {
|
||||||
|
request.Pipeline = true
|
||||||
|
request.PipelineConcurrentConnections = 1
|
||||||
|
request.PipelineRequestsPerConnection = payloads
|
||||||
|
request.Raw = []string{"POST /?v={{v}} HTTP/1.1\nHost: {{Hostname}}\nContent-Length: 8388608\n\n" + body}
|
||||||
|
}
|
||||||
|
executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
|
||||||
|
ID: request.ID,
|
||||||
|
Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Low}, Name: "test"},
|
||||||
|
})
|
||||||
|
executerOpts.RequestBodyBudget = httpbody.NewBudget(bodySize)
|
||||||
|
require.NoError(t, request.Compile(executerOpts))
|
||||||
|
|
||||||
|
var before runtime.MemStats
|
||||||
|
runtime.GC()
|
||||||
|
runtime.ReadMemStats(&before)
|
||||||
|
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- request.ExecuteWithResults(contextargs.NewWithInput(context.Background(), target), make(output.InternalEvent), make(output.InternalEvent), func(*output.InternalWrappedEvent) {})
|
||||||
|
}()
|
||||||
|
<-arrived
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
var held runtime.MemStats
|
||||||
|
runtime.GC()
|
||||||
|
runtime.ReadMemStats(&held)
|
||||||
|
close(unblock)
|
||||||
|
require.NoError(t, <-done)
|
||||||
|
|
||||||
|
grown := int64(held.HeapAlloc) - int64(before.HeapAlloc)
|
||||||
|
t.Logf("heap held: %d MB", grown>>20)
|
||||||
|
require.Less(t, grown, int64(4*bodySize), "bodies built ahead of the send must stay within the budget")
|
||||||
|
require.EqualValues(t, payloads, received.Load())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package http
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/internal/tests/testutils"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/model"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/model/types/severity"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/operators"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/operators/matchers"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/output"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/protocols"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/utils/httpbody"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The third request queues ~2.4s, so it only beats the 3s timeout if the
|
||||||
|
// timeout starts after admission.
|
||||||
|
func TestRequestBodyBudgetQueuesHeavyRequests(t *testing.T) {
|
||||||
|
const bodySize = 1 << 20
|
||||||
|
const requests = 3
|
||||||
|
|
||||||
|
var inFlight, maxInFlight atomic.Int32
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
n := inFlight.Add(1)
|
||||||
|
defer inFlight.Add(-1)
|
||||||
|
for {
|
||||||
|
seen := maxInFlight.Load()
|
||||||
|
if n <= seen || maxInFlight.CompareAndSwap(seen, n) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
time.Sleep(1200 * time.Millisecond)
|
||||||
|
if len(body) == bodySize {
|
||||||
|
_, _ = io.WriteString(w, "full body")
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
options := testutils.DefaultOptions.Copy()
|
||||||
|
options.SetTimeouts(&types.Timeouts{DialTimeout: time.Second, HttpTimeout: 3 * time.Second})
|
||||||
|
testutils.Init(options)
|
||||||
|
|
||||||
|
request := &Request{
|
||||||
|
ID: "body-budget",
|
||||||
|
Path: []string{"{{BaseURL}}"},
|
||||||
|
Method: HTTPMethodTypeHolder{MethodType: HTTPPost},
|
||||||
|
Body: `{{repeat("A", 1048576)}}`,
|
||||||
|
Operators: operators.Operators{
|
||||||
|
Matchers: []*matchers.Matcher{{
|
||||||
|
Part: "body",
|
||||||
|
Type: matchers.MatcherTypeHolder{MatcherType: matchers.WordsMatcher},
|
||||||
|
Words: []string{"full body"},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
|
||||||
|
ID: request.ID,
|
||||||
|
Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Low}, Name: "test"},
|
||||||
|
})
|
||||||
|
executerOpts.RequestBodyBudget = httpbody.NewBudget(bodySize)
|
||||||
|
require.NoError(t, request.Compile(executerOpts))
|
||||||
|
|
||||||
|
var matched atomic.Int32
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for range requests {
|
||||||
|
wg.Go(func() {
|
||||||
|
err := request.ExecuteWithResults(contextargs.NewWithInput(context.Background(), ts.URL), make(output.InternalEvent), make(output.InternalEvent), func(event *output.InternalWrappedEvent) {
|
||||||
|
if event.OperatorsResult != nil && event.OperatorsResult.Matched {
|
||||||
|
matched.Add(1)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
require.EqualValues(t, 1, maxInFlight.Load(), "budget must hold heavy requests to one in flight")
|
||||||
|
require.EqualValues(t, requests, matched.Load(), "queued requests must not time out while waiting")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReserveBodyQueuesBuildsOnLearnedSize(t *testing.T) {
|
||||||
|
const bodySize = 4 << 20
|
||||||
|
request := &Request{
|
||||||
|
options: &protocols.ExecutorOptions{RequestBodyBudget: httpbody.NewBudget(6 << 20)},
|
||||||
|
bodySize: &bodySizeEstimate{},
|
||||||
|
}
|
||||||
|
|
||||||
|
first, err := request.reserveBody(t.Context())
|
||||||
|
require.NoError(t, err)
|
||||||
|
blocked := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
second, err := request.reserveBody(t.Context())
|
||||||
|
require.NoError(t, err)
|
||||||
|
second.done()
|
||||||
|
close(blocked)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case <-blocked:
|
||||||
|
t.Fatal("second build started before the first recorded its size")
|
||||||
|
case <-time.After(20 * time.Millisecond):
|
||||||
|
}
|
||||||
|
require.NoError(t, first.built(t.Context(), request, bodySize))
|
||||||
|
first.done()
|
||||||
|
<-blocked
|
||||||
|
|
||||||
|
held, err := request.reserveBody(t.Context())
|
||||||
|
require.NoError(t, err)
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
_, err = request.reserveBody(ctx)
|
||||||
|
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||||
|
|
||||||
|
held.done()
|
||||||
|
next, err := request.reserveBody(t.Context())
|
||||||
|
require.NoError(t, err)
|
||||||
|
next.done()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReserveBodyGrowsToLargerBody(t *testing.T) {
|
||||||
|
request := &Request{
|
||||||
|
options: &protocols.ExecutorOptions{RequestBodyBudget: httpbody.NewBudget(6 << 20)},
|
||||||
|
bodySize: &bodySizeEstimate{},
|
||||||
|
}
|
||||||
|
learn, err := request.reserveBody(t.Context())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, learn.built(t.Context(), request, 1<<20))
|
||||||
|
learn.done()
|
||||||
|
|
||||||
|
grown, err := request.reserveBody(t.Context())
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, grown.built(t.Context(), request, 5<<20))
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
_, err = request.reserveBody(ctx)
|
||||||
|
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||||
|
grown.done()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop-at-first-match ends the workers with tasks still queued. Their
|
||||||
|
// reservations must go back to the shared budget.
|
||||||
|
func TestRequestBodyBudgetStopAtFirstMatchReleasesQueued(t *testing.T) {
|
||||||
|
const budget = 16 << 20
|
||||||
|
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = io.Copy(io.Discard, r.Body)
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
_, _ = io.WriteString(w, "match")
|
||||||
|
}))
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
options := testutils.DefaultOptions.Copy()
|
||||||
|
testutils.Init(options)
|
||||||
|
|
||||||
|
values := make([]string, 64)
|
||||||
|
for i := range values {
|
||||||
|
values[i] = fmt.Sprint(i)
|
||||||
|
}
|
||||||
|
request := &Request{
|
||||||
|
ID: "body-budget-stop-first",
|
||||||
|
Method: HTTPMethodTypeHolder{MethodType: HTTPPost},
|
||||||
|
Path: []string{"{{BaseURL}}/?v={{v}}"},
|
||||||
|
Body: `{{repeat("A", 1048576)}}`,
|
||||||
|
Threads: 4,
|
||||||
|
Payloads: map[string]interface{}{"v": values},
|
||||||
|
StopAtFirstMatch: true,
|
||||||
|
Operators: operators.Operators{Matchers: []*matchers.Matcher{{
|
||||||
|
Part: "body",
|
||||||
|
Type: matchers.MatcherTypeHolder{MatcherType: matchers.WordsMatcher},
|
||||||
|
Words: []string{"match"},
|
||||||
|
}}},
|
||||||
|
}
|
||||||
|
executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
|
||||||
|
ID: request.ID,
|
||||||
|
Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Low}, Name: "test"},
|
||||||
|
})
|
||||||
|
executerOpts.RequestBodyBudget = httpbody.NewBudget(budget)
|
||||||
|
require.NoError(t, request.Compile(executerOpts))
|
||||||
|
|
||||||
|
err := request.ExecuteWithResults(contextargs.NewWithInput(context.Background(), ts.URL), make(output.InternalEvent), make(output.InternalEvent), func(*output.InternalWrappedEvent) {})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(t.Context(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
release, err := executerOpts.RequestBodyBudget.Acquire(ctx, budget)
|
||||||
|
require.NoError(t, err, "every reservation must be released")
|
||||||
|
release()
|
||||||
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package http
|
package http
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -83,6 +82,9 @@ type generatedRequest struct {
|
|||||||
dynamicValues map[string]interface{}
|
dynamicValues map[string]interface{}
|
||||||
interactshURLs []string
|
interactshURLs []string
|
||||||
customCancelFunction context.CancelFunc
|
customCancelFunction context.CancelFunc
|
||||||
|
// sendTimeout starts after the body budget admits the request.
|
||||||
|
sendTimeout time.Duration
|
||||||
|
bodyReserved bool
|
||||||
// requestURLPattern tracks unmodified request url pattern without values ( it is used for constant vuln_hash)
|
// requestURLPattern tracks unmodified request url pattern without values ( it is used for constant vuln_hash)
|
||||||
// ex: {{BaseURL}}/api/exp?param={{randstr}}
|
// ex: {{BaseURL}}/api/exp?param={{randstr}}
|
||||||
requestURLPattern string
|
requestURLPattern string
|
||||||
@@ -272,98 +274,19 @@ func (r *requestGenerator) validateSelfContainedHost(host string) error {
|
|||||||
// selfContained templates do not need/use target data and all values i.e {{Hostname}} , {{BaseURL}} etc are already available
|
// selfContained templates do not need/use target data and all values i.e {{Hostname}} , {{BaseURL}} etc are already available
|
||||||
// in template . makeSelfContainedRequest parses and creates variables map and then creates corresponding http request or raw request
|
// in template . makeSelfContainedRequest parses and creates variables map and then creates corresponding http request or raw request
|
||||||
func (r *requestGenerator) makeSelfContainedRequest(ctx context.Context, input *contextargs.Context, data string, payloads, dynamicValues map[string]interface{}) (*generatedRequest, error) {
|
func (r *requestGenerator) makeSelfContainedRequest(ctx context.Context, input *contextargs.Context, data string, payloads, dynamicValues map[string]interface{}) (*generatedRequest, error) {
|
||||||
isRawRequest := r.request.isRaw()
|
if r.request.isRaw() {
|
||||||
|
return r.makeSelfContainedRawRequest(ctx, input, data, payloads, dynamicValues)
|
||||||
optionValues := generators.BuildPayloadFromOptions(r.request.options.Options)
|
|
||||||
values := generators.MergeMaps(optionValues)
|
|
||||||
if r.request.options.HasTemplateCtx(input.MetaInput) {
|
|
||||||
values = generators.MergeMaps(values, r.request.options.GetTemplateCtx(input.MetaInput).GetAll())
|
|
||||||
}
|
|
||||||
values = generators.MergeMaps(values, dynamicValues, payloads)
|
|
||||||
// adds all variables from `variables` section in template
|
|
||||||
scope := r.request.options.NewVariablesScope(optionValues)
|
|
||||||
r.request.options.AddTemplateCtxToVariablesScope(input.MetaInput, scope)
|
|
||||||
scope.AddData(dynamicValues, payloads, r.options.Constants)
|
|
||||||
evaluation := r.request.options.Variables.EvaluateWithInteractshScope(scope, r.request.options.Interactsh)
|
|
||||||
|
|
||||||
variablesMap, interactURLs := evaluation.Values, evaluation.InteractURLs
|
|
||||||
if len(interactURLs) > 0 {
|
|
||||||
r.interactshURLs = append(r.interactshURLs, interactURLs...)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
values = generators.MergeMaps(variablesMap, values)
|
values, _, err := r.selfContainedValues(input, payloads, dynamicValues, nil)
|
||||||
signerVars := GetDefaultSignerVars(r.request.Signature.Value)
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
// this will ensure that default signer variables are overwritten by other variables
|
|
||||||
values = generators.MergeMaps(signerVars, values, r.options.Constants)
|
|
||||||
|
|
||||||
for payloadName, payloadValue := range payloads {
|
|
||||||
renderedPayload, err := r.renderText(types.ToString(payloadValue), values)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errkit.Wrap(err, "could not evaluate payload helper expressions")
|
|
||||||
}
|
|
||||||
|
|
||||||
payloads[payloadName] = renderedPayload
|
|
||||||
}
|
}
|
||||||
|
|
||||||
values = generators.MergeMaps(values, payloads, r.options.Constants)
|
data, err = r.renderText(data, values)
|
||||||
|
|
||||||
// priority of variables is as follows (from low to high) for self contained templates
|
|
||||||
// default signer vars < variables < cli vars < dynamic values < payload < constants
|
|
||||||
|
|
||||||
// evaluate request
|
|
||||||
data, err := r.renderText(data, values)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errkit.Wrap(err, "could not evaluate helper expressions")
|
return nil, errkit.Wrap(err, "could not evaluate helper expressions")
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the request is a raw request, get the URL from the request
|
|
||||||
// header and use it to make the request.
|
|
||||||
if isRawRequest {
|
|
||||||
// Get the hostname from the URL section to build the request.
|
|
||||||
reader := bufio.NewReader(strings.NewReader(data))
|
|
||||||
read_line:
|
|
||||||
s, err := reader.ReadString('\n')
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("could not read request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ignore all annotations
|
|
||||||
if stringsutil.HasPrefixAny(s, "@") {
|
|
||||||
goto read_line
|
|
||||||
}
|
|
||||||
|
|
||||||
parts := strings.Split(s, " ")
|
|
||||||
if len(parts) < 3 {
|
|
||||||
return nil, fmt.Errorf("malformed request supplied")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := expressions.ContainsUnresolvedVariables(parts[1]); err != nil && !r.request.SkipVariablesCheck {
|
|
||||||
return nil, errkit.Newf("unresolved variables `%v` found in request", parts[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
parsed, err := urlutil.ParseURL(parts[1], true)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("could not parse request URL: %w", err)
|
|
||||||
}
|
|
||||||
// self-contained requests choose their own destination URL, so validate
|
|
||||||
// that host against the network policy.
|
|
||||||
if err := r.validateSelfContainedHost(parsed.Hostname()); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
values = generators.MergeMaps(
|
|
||||||
generators.MergeMaps(dynamicValues, protocolutils.GenerateVariables(parsed, false, nil)),
|
|
||||||
values,
|
|
||||||
)
|
|
||||||
|
|
||||||
// Evaluate (replace) variable with final values
|
|
||||||
data, err = r.renderText(data, values)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errkit.Wrap(err, "could not evaluate helper expressions")
|
|
||||||
}
|
|
||||||
|
|
||||||
return r.generateRawRequest(ctx, data, parsed, values, payloads)
|
|
||||||
}
|
|
||||||
if err := expressions.ContainsUnresolvedVariables(data); err != nil && !r.request.SkipVariablesCheck {
|
if err := expressions.ContainsUnresolvedVariables(data); err != nil && !r.request.SkipVariablesCheck {
|
||||||
// early exit: if there are any unresolved variables in `path` after evaluation
|
// early exit: if there are any unresolved variables in `path` after evaluation
|
||||||
// then return early since this will definitely fail
|
// then return early since this will definitely fail
|
||||||
@@ -381,6 +304,227 @@ func (r *requestGenerator) makeSelfContainedRequest(ctx context.Context, input *
|
|||||||
return r.generateHttpRequest(ctx, urlx, values, payloads)
|
return r.generateHttpRequest(ctx, urlx, values, payloads)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// makeSelfContainedRawRequest makes a self-contained raw request. Its request
|
||||||
|
// line names the URL, and that URL provides variables such as {{Hostname}} to
|
||||||
|
// the rest of the request. Each part of the template text is rendered once,
|
||||||
|
// so inserted values such as response data are sent unchanged:
|
||||||
|
//
|
||||||
|
// 1. Render the request line and check its host against the network policy.
|
||||||
|
// 2. Evaluate again the template variables and payloads whose template text
|
||||||
|
// names URL variables. The others keep their values, so a random value
|
||||||
|
// is the same in the request line and in the rest of the request.
|
||||||
|
// 3. Render the annotations and the rest of the request.
|
||||||
|
func (r *requestGenerator) makeSelfContainedRawRequest(ctx context.Context, input *contextargs.Context, data string, payloads, dynamicValues map[string]interface{}) (*generatedRequest, error) {
|
||||||
|
annotations, requestLine, rest, err := splitRequestLine(data)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payloads are rendered in place, so keep their template text for step 2.
|
||||||
|
payloadText := generators.CopyMap(payloads)
|
||||||
|
|
||||||
|
values, authored, err := r.selfContainedValues(input, payloads, dynamicValues, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
requestLine, err = r.renderText(requestLine, values)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errkit.Wrap(err, "could not evaluate helper expressions")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the line that raw.ParseRawRequest will read, even if a value
|
||||||
|
// added a line break.
|
||||||
|
firstLine, _, _ := strings.Cut(requestLine, "\n")
|
||||||
|
|
||||||
|
parsed, err := r.requestLineURL(firstLine)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL variables only fill in names that have no other value.
|
||||||
|
fixed := make(map[string]interface{})
|
||||||
|
|
||||||
|
for name, value := range protocolutils.GenerateVariables(parsed, false, nil) {
|
||||||
|
if _, ok := values[name]; !ok {
|
||||||
|
fixed[name] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stale := r.staleValues(authored, payloadText, fixed)
|
||||||
|
if len(stale) == 0 {
|
||||||
|
values = generators.MergeMaps(fixed, values)
|
||||||
|
} else {
|
||||||
|
// Keep every other value, and evaluate the stale template variables
|
||||||
|
// and payloads again from their template text.
|
||||||
|
for name, value := range values {
|
||||||
|
if _, ok := stale[name]; !ok {
|
||||||
|
fixed[name] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for name := range stale {
|
||||||
|
if text, ok := payloadText[name]; ok {
|
||||||
|
payloads[name] = text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
values, _, err = r.selfContainedValues(input, payloads, dynamicValues, fixed)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
annotations, err = r.renderText(annotations, values)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errkit.Wrap(err, "could not evaluate helper expressions")
|
||||||
|
}
|
||||||
|
|
||||||
|
rest, err = r.renderText(rest, values)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errkit.Wrap(err, "could not evaluate helper expressions")
|
||||||
|
}
|
||||||
|
|
||||||
|
data = annotations + requestLine + rest
|
||||||
|
|
||||||
|
// A value with a line break must not change which line is the request
|
||||||
|
// line, or the request would go to a host that was not checked.
|
||||||
|
if _, line, _, err := splitRequestLine(data); err != nil || line != firstLine {
|
||||||
|
return nil, errkit.New("malformed request supplied: a value changed the request line")
|
||||||
|
}
|
||||||
|
|
||||||
|
return r.generateRawRequest(ctx, data, parsed, values, payloads)
|
||||||
|
}
|
||||||
|
|
||||||
|
// staleValues returns the names of the authored values whose template text
|
||||||
|
// names one of urlValues, directly or through another stale value. It reads
|
||||||
|
// template text only, so data inserted into a value cannot mark it stale.
|
||||||
|
// Variables are checked in evaluation order, then payloads, which are
|
||||||
|
// rendered after the variables.
|
||||||
|
func (r *requestGenerator) staleValues(authored, payloadText, urlValues map[string]interface{}) map[string]struct{} {
|
||||||
|
stale := make(map[string]struct{})
|
||||||
|
names := generators.CopyMap(urlValues)
|
||||||
|
|
||||||
|
mark := func(name string, text interface{}) {
|
||||||
|
if _, ok := authored[name]; !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if expressions.ReferencesVariables(types.ToString(text), names) {
|
||||||
|
stale[name] = struct{}{}
|
||||||
|
names[name] = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
r.request.options.Variables.ForEach(mark)
|
||||||
|
|
||||||
|
for name, text := range payloadText {
|
||||||
|
mark(name, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
return stale
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestLineURL returns the URL named by a rendered raw request line after
|
||||||
|
// checking its host against the network policy.
|
||||||
|
func (r *requestGenerator) requestLineURL(line string) (*urlutil.URL, error) {
|
||||||
|
parts := strings.Split(line, " ")
|
||||||
|
if len(parts) < 3 {
|
||||||
|
return nil, fmt.Errorf("malformed request supplied")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := expressions.ContainsUnresolvedVariables(parts[1]); err != nil && !r.request.SkipVariablesCheck {
|
||||||
|
return nil, errkit.Newf("unresolved variables `%v` found in request", parts[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed, err := urlutil.ParseURL(parts[1], true)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("could not parse request URL: %w", err)
|
||||||
|
}
|
||||||
|
// self-contained requests choose their own destination URL, so validate
|
||||||
|
// that host against the network policy.
|
||||||
|
if err := r.validateSelfContainedHost(parsed.Hostname()); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// selfContainedValues evaluates the template variables and renders the
|
||||||
|
// payloads of a self-contained request, and returns the values to render the
|
||||||
|
// request with. Payloads are rendered in place. Values in fixed are data:
|
||||||
|
// they fill in names that have no other value, and a template variable or
|
||||||
|
// payload with the same name keeps that value instead of being evaluated.
|
||||||
|
// It also returns the values it produced from template text, that is the
|
||||||
|
// evaluated template variables and the rendered payloads.
|
||||||
|
func (r *requestGenerator) selfContainedValues(input *contextargs.Context, payloads, dynamicValues, fixed map[string]interface{}) (map[string]interface{}, map[string]interface{}, error) {
|
||||||
|
optionValues := generators.BuildPayloadFromOptions(r.request.options.Options)
|
||||||
|
values := generators.MergeMaps(fixed, optionValues)
|
||||||
|
|
||||||
|
if r.request.options.HasTemplateCtx(input.MetaInput) {
|
||||||
|
values = generators.MergeMaps(values, r.request.options.GetTemplateCtx(input.MetaInput).GetAll())
|
||||||
|
}
|
||||||
|
values = generators.MergeMaps(values, dynamicValues, payloads)
|
||||||
|
// adds all variables from `variables` section in template
|
||||||
|
scope := r.request.options.NewVariablesScope(optionValues)
|
||||||
|
r.request.options.AddTemplateCtxToVariablesScope(input.MetaInput, scope)
|
||||||
|
scope.AddData(dynamicValues, payloads, r.options.Constants, fixed)
|
||||||
|
evaluation := r.request.options.Variables.EvaluateWithInteractshScope(scope, r.request.options.Interactsh)
|
||||||
|
|
||||||
|
variablesMap, interactURLs := evaluation.Values, evaluation.InteractURLs
|
||||||
|
if len(interactURLs) > 0 {
|
||||||
|
r.interactshURLs = append(r.interactshURLs, interactURLs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
values = generators.MergeMaps(variablesMap, values)
|
||||||
|
signerVars := GetDefaultSignerVars(r.request.Signature.Value)
|
||||||
|
|
||||||
|
// this will ensure that default signer variables are overwritten by other variables
|
||||||
|
values = generators.MergeMaps(signerVars, values, r.options.Constants)
|
||||||
|
|
||||||
|
// The evaluation owns TemplateValues, so add the rendered payloads to it.
|
||||||
|
authored := evaluation.TemplateValues
|
||||||
|
|
||||||
|
for payloadName, payloadValue := range payloads {
|
||||||
|
if _, ok := fixed[payloadName]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
renderedPayload, err := r.renderText(types.ToString(payloadValue), values)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, errkit.Wrap(err, "could not evaluate payload helper expressions")
|
||||||
|
}
|
||||||
|
|
||||||
|
payloads[payloadName] = renderedPayload
|
||||||
|
authored[payloadName] = renderedPayload
|
||||||
|
}
|
||||||
|
|
||||||
|
// priority of variables is as follows (from low to high) for self contained templates
|
||||||
|
// default signer vars < variables < cli vars < dynamic values < payload < constants
|
||||||
|
return generators.MergeMaps(values, payloads, r.options.Constants), authored, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitRequestLine splits raw request template text at its request line, the
|
||||||
|
// first line that is not an annotation. It returns the annotation lines, the
|
||||||
|
// request line without its line break, and the remaining text starting with
|
||||||
|
// that line break. The three parts concatenate back to raw.
|
||||||
|
func splitRequestLine(raw string) (annotations, requestLine, rest string, err error) {
|
||||||
|
start := 0
|
||||||
|
|
||||||
|
for {
|
||||||
|
line, _, found := strings.Cut(raw[start:], "\n")
|
||||||
|
if !found {
|
||||||
|
return "", "", "", errkit.New("could not read request: no request line found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.HasPrefix(line, "@") {
|
||||||
|
return raw[:start], line, raw[start+len(line):], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
start += len(line) + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// generateHttpRequest generates http request from request data from template and variables
|
// generateHttpRequest generates http request from request data from template and variables
|
||||||
// finalVars = contains all variables including generator and protocol specific variables
|
// finalVars = contains all variables including generator and protocol specific variables
|
||||||
// generatorValues = contains variables used in fuzzing or other generator specific values
|
// generatorValues = contains variables used in fuzzing or other generator specific values
|
||||||
|
|||||||
@@ -14,10 +14,14 @@ import (
|
|||||||
"github.com/projectdiscovery/nuclei/v3/internal/tests/testutils"
|
"github.com/projectdiscovery/nuclei/v3/internal/tests/testutils"
|
||||||
"github.com/projectdiscovery/nuclei/v3/pkg/model"
|
"github.com/projectdiscovery/nuclei/v3/pkg/model"
|
||||||
"github.com/projectdiscovery/nuclei/v3/pkg/model/types/severity"
|
"github.com/projectdiscovery/nuclei/v3/pkg/model/types/severity"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/operators"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/operators/extractors"
|
||||||
"github.com/projectdiscovery/nuclei/v3/pkg/output"
|
"github.com/projectdiscovery/nuclei/v3/pkg/output"
|
||||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
|
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
|
||||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/generators"
|
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/generators"
|
||||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/interactsh"
|
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/interactsh"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/variables"
|
||||||
|
"github.com/projectdiscovery/nuclei/v3/pkg/utils"
|
||||||
"github.com/projectdiscovery/nuclei/v3/pkg/utils/httpbody"
|
"github.com/projectdiscovery/nuclei/v3/pkg/utils/httpbody"
|
||||||
urlutil "github.com/projectdiscovery/utils/url"
|
urlutil "github.com/projectdiscovery/utils/url"
|
||||||
)
|
)
|
||||||
@@ -451,6 +455,211 @@ func TestMakeSelfContainedRequestDoesNotAllocateRuntimeInteractshMarkerFromPaylo
|
|||||||
require.Equal(t, "{{interactsh-url}}", string(body))
|
require.Equal(t, "{{interactsh-url}}", string(body))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSelfContainedRawRequestSendsResponseDataLiterally(t *testing.T) {
|
||||||
|
const envName = "NUCLEI_TEST_SELF_CONTAINED_ENV"
|
||||||
|
|
||||||
|
t.Setenv(envName, "ENV-VALUE")
|
||||||
|
|
||||||
|
// A fresh Options pointer, because option payloads are cached per pointer.
|
||||||
|
options := testutils.DefaultOptions.Copy()
|
||||||
|
options.EnvironmentVariables = true
|
||||||
|
testutils.Init(options)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{name: "template variable", body: "{{secret}}"},
|
||||||
|
{name: "helper expression", body: "{{to_upper(seed)}}"},
|
||||||
|
{name: "environment variable", body: "{{" + envName + "}}"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
probes := make(chan http.Header, 1)
|
||||||
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/source":
|
||||||
|
_, _ = io.WriteString(w, tt.body)
|
||||||
|
case "/probe":
|
||||||
|
header := r.Header.Clone()
|
||||||
|
header.Set("Host", r.Host)
|
||||||
|
|
||||||
|
probes <- header
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(server.Close)
|
||||||
|
|
||||||
|
request := &Request{
|
||||||
|
ID: "testing-self-contained-raw-response-data",
|
||||||
|
SelfContained: true,
|
||||||
|
// Send the probe even though it holds a literal {{...}}.
|
||||||
|
SkipVariablesCheck: true,
|
||||||
|
Raw: []string{
|
||||||
|
"GET " + server.URL + "/source HTTP/1.1\nHost: {{Hostname}}\n\n",
|
||||||
|
"GET " + server.URL + "/probe HTTP/1.1\nHost: {{Hostname}}\n" +
|
||||||
|
"Probe: [{{tpl}}]\nAuthored: [{{base64(seed)}}-{{" + envName + "}}]\n\n",
|
||||||
|
},
|
||||||
|
Operators: operators.Operators{
|
||||||
|
Extractors: []*extractors.Extractor{{
|
||||||
|
Name: "tpl",
|
||||||
|
Part: "body",
|
||||||
|
Type: extractors.ExtractorTypeHolder{ExtractorType: extractors.RegexExtractor},
|
||||||
|
Regex: []string{".+"},
|
||||||
|
Internal: true,
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
templateVars := variables.Variable{
|
||||||
|
InsertionOrderedStringMap: *utils.NewEmptyInsertionOrderedStringMap(2),
|
||||||
|
}
|
||||||
|
templateVars.Set("seed", "hello")
|
||||||
|
templateVars.Set("secret", "TOP-SECRET")
|
||||||
|
executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
|
||||||
|
ID: request.ID,
|
||||||
|
Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Low}, Name: "test"},
|
||||||
|
})
|
||||||
|
executerOpts.Variables = templateVars
|
||||||
|
require.NoError(t, request.Compile(executerOpts))
|
||||||
|
|
||||||
|
err := request.ExecuteWithResults(
|
||||||
|
contextargs.NewWithInput(context.Background(), ""),
|
||||||
|
make(output.InternalEvent),
|
||||||
|
make(output.InternalEvent),
|
||||||
|
func(*output.InternalWrappedEvent) {},
|
||||||
|
)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var probe http.Header
|
||||||
|
select {
|
||||||
|
case probe = <-probes:
|
||||||
|
default:
|
||||||
|
t.Fatal("probe request was not sent")
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Equal(t, "["+tt.body+"]", probe.Get("Probe"), "response data must be sent unchanged")
|
||||||
|
// Template-authored text still renders, including variables derived
|
||||||
|
// from the request URL.
|
||||||
|
require.Equal(t, "[aGVsbG8=-ENV-VALUE]", probe.Get("Authored"))
|
||||||
|
require.Equal(t, strings.TrimPrefix(server.URL, "http://"), probe.Get("Host"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeSelfContainedRawRequest compiles request as a self-contained raw request
|
||||||
|
// with the given template variables and makes its first request.
|
||||||
|
func makeSelfContainedRawRequest(t *testing.T, request *Request, vars [][2]string, dynamicValues map[string]interface{}) (*generatedRequest, error) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
options := testutils.DefaultOptions
|
||||||
|
testutils.Init(options)
|
||||||
|
|
||||||
|
request.ID = "testing-self-contained-raw"
|
||||||
|
request.SelfContained = true
|
||||||
|
templateVars := variables.Variable{
|
||||||
|
InsertionOrderedStringMap: *utils.NewEmptyInsertionOrderedStringMap(len(vars)),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, v := range vars {
|
||||||
|
templateVars.Set(v[0], v[1])
|
||||||
|
}
|
||||||
|
executerOpts := testutils.NewMockExecuterOptions(options, &testutils.TemplateInfo{
|
||||||
|
ID: request.ID,
|
||||||
|
Info: model.Info{SeverityHolder: severity.Holder{Severity: severity.Low}, Name: "test"},
|
||||||
|
})
|
||||||
|
executerOpts.Variables = templateVars
|
||||||
|
require.NoError(t, request.Compile(executerOpts))
|
||||||
|
|
||||||
|
generator := request.newGenerator(false)
|
||||||
|
inputData, payloads, ok := generator.nextValue()
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
return generator.Make(context.Background(), contextargs.NewWithInput(context.Background(), ""), inputData, payloads, dynamicValues)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMakeSelfContainedRawRequestResolvesURLVariables(t *testing.T) {
|
||||||
|
request := &Request{
|
||||||
|
Raw: []string{"GET http://198.51.100.7/?t={{token}}&d={{data_token}} HTTP/1.1\nHost: {{Hostname}}\n" +
|
||||||
|
"X-Var: {{host_var}}\nX-Upper: {{upper_host}}\nX-Payload: {{user}}\n" +
|
||||||
|
"X-Token: {{token}}\nX-Data-Token: {{data_token}}\nX-Data: {{data}}\n\n"},
|
||||||
|
Payloads: map[string]interface{}{"user": []string{"admin@{{Hostname}}"}},
|
||||||
|
// Send values that hold a literal {{...}} from data.
|
||||||
|
SkipVariablesCheck: true,
|
||||||
|
}
|
||||||
|
got, err := makeSelfContainedRawRequest(t, request, [][2]string{
|
||||||
|
{"token", "{{rand_base(8)}}"},
|
||||||
|
{"data_token", "{{rand_base(8)}}-{{tpl}}"},
|
||||||
|
{"data", "{{tpl}}"},
|
||||||
|
{"host_var", "{{Hostname}}"},
|
||||||
|
{"upper_host", "{{to_upper(host_var)}}"},
|
||||||
|
}, map[string]interface{}{"tpl": "{{Hostname}}"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
require.Equal(t, "198.51.100.7", got.request.Header.Get("X-Var"))
|
||||||
|
require.Equal(t, "198.51.100.7", got.request.Header.Get("X-Upper"))
|
||||||
|
require.Equal(t, "[email protected]", got.request.Header.Get("X-Payload"))
|
||||||
|
// Data that names a URL variable is sent unchanged.
|
||||||
|
require.Equal(t, "{{Hostname}}", got.request.Header.Get("X-Data"))
|
||||||
|
// Values whose template text names no URL variable are not evaluated
|
||||||
|
// again, even when data inserted into them does.
|
||||||
|
require.Equal(t, got.request.Params.Get("t"), got.request.Header.Get("X-Token"))
|
||||||
|
require.Equal(t, got.request.Params.Get("d"), got.request.Header.Get("X-Data-Token"))
|
||||||
|
require.True(t, strings.HasSuffix(got.request.Header.Get("X-Data-Token"), "-{{Hostname}}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMakeSelfContainedRawRequestRejectsMovedRequestLine(t *testing.T) {
|
||||||
|
request := &Request{
|
||||||
|
Raw: []string{"@timeout: {{wait}}\nGET http://198.51.100.7/ HTTP/1.1\nHost: {{Hostname}}\n\n"},
|
||||||
|
}
|
||||||
|
_, err := makeSelfContainedRawRequest(t, request, nil, map[string]interface{}{
|
||||||
|
"wait": "5s\nGET http://192.0.2.1/secret HTTP/1.1",
|
||||||
|
})
|
||||||
|
require.ErrorContains(t, err, "changed the request line")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSplitRequestLine(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
raw string
|
||||||
|
annotations, requestLine, rest string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no annotations",
|
||||||
|
raw: "GET http://a/ HTTP/1.1\r\nHost: a\r\n\r\n",
|
||||||
|
requestLine: "GET http://a/ HTTP/1.1\r",
|
||||||
|
rest: "\nHost: a\r\n\r\n",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "annotations",
|
||||||
|
raw: "@tls-sni: {{Hostname}}\n@timeout: 5s\nGET http://a/ HTTP/1.1\nHost: a\n",
|
||||||
|
annotations: "@tls-sni: {{Hostname}}\n@timeout: 5s\n",
|
||||||
|
requestLine: "GET http://a/ HTTP/1.1",
|
||||||
|
rest: "\nHost: a\n",
|
||||||
|
},
|
||||||
|
{name: "no line break", raw: "GET http://a/ HTTP/1.1", wantErr: true},
|
||||||
|
{name: "annotations only", raw: "@timeout: 5s\n", wantErr: true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
annotations, requestLine, rest, err := splitRequestLine(tt.raw)
|
||||||
|
if tt.wantErr {
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, tt.annotations, annotations)
|
||||||
|
require.Equal(t, tt.requestLine, requestLine)
|
||||||
|
require.Equal(t, tt.rest, rest)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExecuteRequestDoesNotReevaluateGeneratedPayloadMetadata(t *testing.T) {
|
func TestExecuteRequestDoesNotReevaluateGeneratedPayloadMetadata(t *testing.T) {
|
||||||
options := testutils.DefaultOptions
|
options := testutils.DefaultOptions
|
||||||
|
|
||||||
|
|||||||
Loaded 100 of 134 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user