mirror of
https://github.com/projectdiscovery/nuclei.git
synced 2026-11-08 02:08:33 -10:00
Compare commits
70
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8c88feb4a | ||
|
|
5bbca25bea | ||
|
|
64bc83e004 | ||
|
|
37bba07662 | ||
|
|
9de96e4dda | ||
|
|
4a6f7c5a86 | ||
|
|
890147c20e | ||
|
|
89c2054bc1 | ||
|
|
938b19d470 | ||
|
|
ba05210f16 | ||
|
|
e557207b41 | ||
|
|
4c7e1687ad | ||
|
|
479c8187a5 | ||
|
|
7936ec36d8 | ||
|
|
e6e558dc23 | ||
|
|
9d9012ce29 | ||
|
|
671b875d23 | ||
|
|
367dfdb436 | ||
|
|
ad1447b329 | ||
|
|
1f39f73e93 | ||
|
|
437897d054 | ||
|
|
31636218ee | ||
|
|
eb1bb88ce5 | ||
|
|
6f5a8ec5d4 | ||
|
|
dd78cd5959 | ||
|
|
4d4b88f6ca | ||
|
|
e2e3574f59 | ||
|
|
250c2a5fd7 | ||
|
|
06ff996374 | ||
|
|
62d6adde14 | ||
|
|
16e4a7acc9 | ||
|
|
c6ef995875 | ||
|
|
89d43000cf | ||
|
|
63bfe19f40 | ||
|
|
f239d6e7c6 | ||
|
|
6e990151b4 | ||
|
|
6b66f91147 | ||
|
|
60f73840e5 | ||
|
|
6ecdb94740 | ||
|
|
7cab28d225 | ||
|
|
da2e6910d2 | ||
|
|
a12f7fb110 | ||
|
|
35a87ba411 | ||
|
|
bcf20899ec | ||
|
|
44e6f5445a | ||
|
|
6d81cc3b62 | ||
|
|
2710ba232b | ||
|
|
ce470c32f8 | ||
|
|
17092671a8 | ||
|
|
679ee5ef3a | ||
|
|
be21943ee4 | ||
|
|
f447c23a66 | ||
|
|
6d7a0fa435 | ||
|
|
4e422a9308 | ||
|
|
a360efc658 | ||
|
|
435ae4c042 | ||
|
|
0888e6244d | ||
|
|
c928dcc652 | ||
|
|
8171606055 | ||
|
|
a1291fa1ab | ||
|
|
bcd580703d | ||
|
|
9c47e6c122 | ||
|
|
23e7624f04 | ||
|
|
85723be139 | ||
|
|
57db383502 | ||
|
|
f9cf234c12 | ||
|
|
ffbb05b254 | ||
|
|
aa52d85991 | ||
|
|
d7dea30cd5 | ||
|
|
4a8b83f977 |
No files matched your search
@@ -13,7 +13,7 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@v10
|
||||
- uses: actions/stale@v11
|
||||
with:
|
||||
days-before-stale: 90
|
||||
days-before-close: 7
|
||||
|
||||
@@ -17,4 +17,4 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: crate-ci/[email protected]7.2
|
||||
- uses: crate-ci/[email protected]8.0
|
||||
@@ -140,6 +140,7 @@ Flags:
|
||||
TARGET:
|
||||
-u, -target string[] target URLs/hosts to scan
|
||||
-l, -list string path to file containing a list of target URLs/hosts to scan (one per line)
|
||||
-targets-inline string inline multiline target list (for use in template profiles)
|
||||
-eh, -exclude-hosts string[] hosts to exclude to scan from the input list (ip, cidr, hostname)
|
||||
-resume string resume scan from and save to specified file (clustering will be disabled)
|
||||
-sa, -scan-all-ips scan all the IP's associated with dns record
|
||||
@@ -149,6 +150,8 @@ TARGET-FORMAT:
|
||||
-im, -input-mode string mode of input file (list, burp, jsonl, yaml, openapi, swagger) (default "list")
|
||||
-ro, -required-only use only required fields in input format when generating requests
|
||||
-sfv, -skip-format-validation skip format validation (like missing vars) when parsing input file
|
||||
-vtt, -vars-text-templating enable text templating for vars in input file (only for yaml input mode)
|
||||
-vfp, -var-file-paths string[] list of yaml file contained vars to inject into yaml input
|
||||
|
||||
TEMPLATES:
|
||||
-nt, -new-templates run only new templates added in latest nuclei-templates release
|
||||
@@ -205,6 +208,7 @@ OUTPUT:
|
||||
-se, -sarif-export string file to export results in SARIF format
|
||||
-je, -json-export string file to export results in JSON format
|
||||
-jle, -jsonl-export string file to export results in JSONL(ine) format
|
||||
-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
|
||||
|
||||
CONFIGURATIONS:
|
||||
@@ -277,6 +281,7 @@ UNCOVER:
|
||||
RATE-LIMIT:
|
||||
-rl, -rate-limit int maximum number of requests to send per second (default 150)
|
||||
-rld, -rate-limit-duration value maximum number of requests to send per second (default 1s)
|
||||
-per-host-rate-limit enable per-host rate limiting (global rate limit becomes unlimited when enabled)
|
||||
-rlm, -rate-limit-minute int maximum number of requests to send per minute (DEPRECATED)
|
||||
-bs, -bulk-size int maximum number of hosts to be analyzed in parallel per template (default 25)
|
||||
-c, -concurrency int maximum number of templates to be executed in parallel (default 25)
|
||||
@@ -301,6 +306,7 @@ OPTIMIZATIONS:
|
||||
-ss, -scan-strategy value strategy to use while scanning(auto/host-spray/template-spray) (default auto)
|
||||
-irt, -input-read-timeout value timeout on input read (default 3m0s)
|
||||
-nh, -no-httpx disable httpx probing for non-url input
|
||||
-preflight-portscan run preflight resolve + TCP portscan and filter targets before scanning (disabled by default)
|
||||
-no-stdin disable stdin processing
|
||||
|
||||
HEADLESS:
|
||||
|
||||
@@ -140,6 +140,7 @@ Flags:
|
||||
TARGET:
|
||||
-u, -target string[] target URLs/hosts to scan
|
||||
-l, -list string path to file containing a list of target URLs/hosts to scan (one per line)
|
||||
-targets-inline string inline multiline target list (for use in template profiles)
|
||||
-eh, -exclude-hosts string[] hosts to exclude to scan from the input list (ip, cidr, hostname)
|
||||
-resume string resume scan from and save to specified file (clustering will be disabled)
|
||||
-sa, -scan-all-ips scan all the IP's associated with dns record
|
||||
@@ -149,6 +150,8 @@ TARGET-FORMAT:
|
||||
-im, -input-mode string mode of input file (list, burp, jsonl, yaml, openapi, swagger) (default "list")
|
||||
-ro, -required-only use only required fields in input format when generating requests
|
||||
-sfv, -skip-format-validation skip format validation (like missing vars) when parsing input file
|
||||
-vtt, -vars-text-templating enable text templating for vars in input file (only for yaml input mode)
|
||||
-vfp, -var-file-paths string[] list of yaml file contained vars to inject into yaml input
|
||||
|
||||
TEMPLATES:
|
||||
-nt, -new-templates run only new templates added in latest nuclei-templates release
|
||||
@@ -205,6 +208,7 @@ OUTPUT:
|
||||
-se, -sarif-export string file to export results in SARIF format
|
||||
-je, -json-export string file to export results in JSON format
|
||||
-jle, -jsonl-export string file to export results in JSONL(ine) format
|
||||
-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
|
||||
|
||||
CONFIGURATIONS:
|
||||
@@ -277,6 +281,7 @@ UNCOVER:
|
||||
RATE-LIMIT:
|
||||
-rl, -rate-limit int maximum number of requests to send per second (default 150)
|
||||
-rld, -rate-limit-duration value maximum number of requests to send per second (default 1s)
|
||||
-per-host-rate-limit enable per-host rate limiting (global rate limit becomes unlimited when enabled)
|
||||
-rlm, -rate-limit-minute int maximum number of requests to send per minute (DEPRECATED)
|
||||
-bs, -bulk-size int maximum number of hosts to be analyzed in parallel per template (default 25)
|
||||
-c, -concurrency int maximum number of templates to be executed in parallel (default 25)
|
||||
@@ -301,6 +306,7 @@ OPTIMIZATIONS:
|
||||
-ss, -scan-strategy value strategy to use while scanning(auto/host-spray/template-spray) (default auto)
|
||||
-irt, -input-read-timeout value timeout on input read (default 3m0s)
|
||||
-nh, -no-httpx disable httpx probing for non-url input
|
||||
-preflight-portscan run preflight resolve + TCP portscan and filter targets before scanning (disabled by default)
|
||||
-no-stdin disable stdin processing
|
||||
|
||||
HEADLESS:
|
||||
|
||||
@@ -140,6 +140,7 @@ Flags:
|
||||
TARGET:
|
||||
-u, -target string[] target URLs/hosts to scan
|
||||
-l, -list string path to file containing a list of target URLs/hosts to scan (one per line)
|
||||
-targets-inline string inline multiline target list (for use in template profiles)
|
||||
-eh, -exclude-hosts string[] hosts to exclude to scan from the input list (ip, cidr, hostname)
|
||||
-resume string resume scan from and save to specified file (clustering will be disabled)
|
||||
-sa, -scan-all-ips scan all the IP's associated with dns record
|
||||
@@ -149,6 +150,8 @@ TARGET-FORMAT:
|
||||
-im, -input-mode string mode of input file (list, burp, jsonl, yaml, openapi, swagger) (default "list")
|
||||
-ro, -required-only use only required fields in input format when generating requests
|
||||
-sfv, -skip-format-validation skip format validation (like missing vars) when parsing input file
|
||||
-vtt, -vars-text-templating enable text templating for vars in input file (only for yaml input mode)
|
||||
-vfp, -var-file-paths string[] list of yaml file contained vars to inject into yaml input
|
||||
|
||||
TEMPLATES:
|
||||
-nt, -new-templates run only new templates added in latest nuclei-templates release
|
||||
@@ -205,6 +208,7 @@ OUTPUT:
|
||||
-se, -sarif-export string file to export results in SARIF format
|
||||
-je, -json-export string file to export results in JSON format
|
||||
-jle, -jsonl-export string file to export results in JSONL(ine) format
|
||||
-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
|
||||
|
||||
CONFIGURATIONS:
|
||||
@@ -277,6 +281,7 @@ UNCOVER:
|
||||
RATE-LIMIT:
|
||||
-rl, -rate-limit int maximum number of requests to send per second (default 150)
|
||||
-rld, -rate-limit-duration value maximum number of requests to send per second (default 1s)
|
||||
-per-host-rate-limit enable per-host rate limiting (global rate limit becomes unlimited when enabled)
|
||||
-rlm, -rate-limit-minute int maximum number of requests to send per minute (DEPRECATED)
|
||||
-bs, -bulk-size int maximum number of hosts to be analyzed in parallel per template (default 25)
|
||||
-c, -concurrency int maximum number of templates to be executed in parallel (default 25)
|
||||
@@ -301,6 +306,7 @@ OPTIMIZATIONS:
|
||||
-ss, -scan-strategy value strategy to use while scanning(auto/host-spray/template-spray) (default auto)
|
||||
-irt, -input-read-timeout value timeout on input read (default 3m0s)
|
||||
-nh, -no-httpx disable httpx probing for non-url input
|
||||
-preflight-portscan run preflight resolve + TCP portscan and filter targets before scanning (disabled by default)
|
||||
-no-stdin disable stdin processing
|
||||
|
||||
HEADLESS:
|
||||
|
||||
@@ -140,6 +140,7 @@ Flags:
|
||||
TARGET:
|
||||
-u, -target string[] target URLs/hosts to scan
|
||||
-l, -list string path to file containing a list of target URLs/hosts to scan (one per line)
|
||||
-targets-inline string inline multiline target list (for use in template profiles)
|
||||
-eh, -exclude-hosts string[] hosts to exclude to scan from the input list (ip, cidr, hostname)
|
||||
-resume string resume scan from and save to specified file (clustering will be disabled)
|
||||
-sa, -scan-all-ips scan all the IP's associated with dns record
|
||||
@@ -149,6 +150,8 @@ TARGET-FORMAT:
|
||||
-im, -input-mode string mode of input file (list, burp, jsonl, yaml, openapi, swagger) (default "list")
|
||||
-ro, -required-only use only required fields in input format when generating requests
|
||||
-sfv, -skip-format-validation skip format validation (like missing vars) when parsing input file
|
||||
-vtt, -vars-text-templating enable text templating for vars in input file (only for yaml input mode)
|
||||
-vfp, -var-file-paths string[] list of yaml file contained vars to inject into yaml input
|
||||
|
||||
TEMPLATES:
|
||||
-nt, -new-templates run only new templates added in latest nuclei-templates release
|
||||
@@ -205,6 +208,7 @@ OUTPUT:
|
||||
-se, -sarif-export string file to export results in SARIF format
|
||||
-je, -json-export string file to export results in JSON format
|
||||
-jle, -jsonl-export string file to export results in JSONL(ine) format
|
||||
-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
|
||||
|
||||
CONFIGURATIONS:
|
||||
@@ -277,6 +281,7 @@ UNCOVER:
|
||||
RATE-LIMIT:
|
||||
-rl, -rate-limit int maximum number of requests to send per second (default 150)
|
||||
-rld, -rate-limit-duration value maximum number of requests to send per second (default 1s)
|
||||
-per-host-rate-limit enable per-host rate limiting (global rate limit becomes unlimited when enabled)
|
||||
-rlm, -rate-limit-minute int maximum number of requests to send per minute (DEPRECATED)
|
||||
-bs, -bulk-size int maximum number of hosts to be analyzed in parallel per template (default 25)
|
||||
-c, -concurrency int maximum number of templates to be executed in parallel (default 25)
|
||||
@@ -301,6 +306,7 @@ OPTIMIZATIONS:
|
||||
-ss, -scan-strategy value strategy to use while scanning(auto/host-spray/template-spray) (default auto)
|
||||
-irt, -input-read-timeout value timeout on input read (default 3m0s)
|
||||
-nh, -no-httpx disable httpx probing for non-url input
|
||||
-preflight-portscan run preflight resolve + TCP portscan and filter targets before scanning (disabled by default)
|
||||
-no-stdin disable stdin processing
|
||||
|
||||
HEADLESS:
|
||||
|
||||
@@ -140,6 +140,7 @@ Flags:
|
||||
TARGET:
|
||||
-u, -target string[] target URLs/hosts to scan
|
||||
-l, -list string path to file containing a list of target URLs/hosts to scan (one per line)
|
||||
-targets-inline string inline multiline target list (for use in template profiles)
|
||||
-eh, -exclude-hosts string[] hosts to exclude to scan from the input list (ip, cidr, hostname)
|
||||
-resume string resume scan from and save to specified file (clustering will be disabled)
|
||||
-sa, -scan-all-ips scan all the IP's associated with dns record
|
||||
@@ -149,6 +150,8 @@ TARGET-FORMAT:
|
||||
-im, -input-mode string mode of input file (list, burp, jsonl, yaml, openapi, swagger) (default "list")
|
||||
-ro, -required-only use only required fields in input format when generating requests
|
||||
-sfv, -skip-format-validation skip format validation (like missing vars) when parsing input file
|
||||
-vtt, -vars-text-templating enable text templating for vars in input file (only for yaml input mode)
|
||||
-vfp, -var-file-paths string[] list of yaml file contained vars to inject into yaml input
|
||||
|
||||
TEMPLATES:
|
||||
-nt, -new-templates run only new templates added in latest nuclei-templates release
|
||||
@@ -205,6 +208,7 @@ OUTPUT:
|
||||
-se, -sarif-export string file to export results in SARIF format
|
||||
-je, -json-export string file to export results in JSON format
|
||||
-jle, -jsonl-export string file to export results in JSONL(ine) format
|
||||
-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
|
||||
|
||||
CONFIGURATIONS:
|
||||
@@ -277,6 +281,7 @@ UNCOVER:
|
||||
RATE-LIMIT:
|
||||
-rl, -rate-limit int maximum number of requests to send per second (default 150)
|
||||
-rld, -rate-limit-duration value maximum number of requests to send per second (default 1s)
|
||||
-per-host-rate-limit enable per-host rate limiting (global rate limit becomes unlimited when enabled)
|
||||
-rlm, -rate-limit-minute int maximum number of requests to send per minute (DEPRECATED)
|
||||
-bs, -bulk-size int maximum number of hosts to be analyzed in parallel per template (default 25)
|
||||
-c, -concurrency int maximum number of templates to be executed in parallel (default 25)
|
||||
@@ -301,6 +306,7 @@ OPTIMIZATIONS:
|
||||
-ss, -scan-strategy value strategy to use while scanning(auto/host-spray/template-spray) (default auto)
|
||||
-irt, -input-read-timeout value timeout on input read (default 3m0s)
|
||||
-nh, -no-httpx disable httpx probing for non-url input
|
||||
-preflight-portscan run preflight resolve + TCP portscan and filter targets before scanning (disabled by default)
|
||||
-no-stdin disable stdin processing
|
||||
|
||||
HEADLESS:
|
||||
|
||||
@@ -140,6 +140,7 @@ Flags:
|
||||
TARGET:
|
||||
-u, -target string[] target URLs/hosts to scan
|
||||
-l, -list string path to file containing a list of target URLs/hosts to scan (one per line)
|
||||
-targets-inline string inline multiline target list (for use in template profiles)
|
||||
-eh, -exclude-hosts string[] hosts to exclude to scan from the input list (ip, cidr, hostname)
|
||||
-resume string resume scan from and save to specified file (clustering will be disabled)
|
||||
-sa, -scan-all-ips scan all the IP's associated with dns record
|
||||
@@ -149,6 +150,8 @@ TARGET-FORMAT:
|
||||
-im, -input-mode string mode of input file (list, burp, jsonl, yaml, openapi, swagger) (default "list")
|
||||
-ro, -required-only use only required fields in input format when generating requests
|
||||
-sfv, -skip-format-validation skip format validation (like missing vars) when parsing input file
|
||||
-vtt, -vars-text-templating enable text templating for vars in input file (only for yaml input mode)
|
||||
-vfp, -var-file-paths string[] list of yaml file contained vars to inject into yaml input
|
||||
|
||||
TEMPLATES:
|
||||
-nt, -new-templates run only new templates added in latest nuclei-templates release
|
||||
@@ -205,6 +208,7 @@ OUTPUT:
|
||||
-se, -sarif-export string file to export results in SARIF format
|
||||
-je, -json-export string file to export results in JSON format
|
||||
-jle, -jsonl-export string file to export results in JSONL(ine) format
|
||||
-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
|
||||
|
||||
CONFIGURATIONS:
|
||||
@@ -277,6 +281,7 @@ UNCOVER:
|
||||
RATE-LIMIT:
|
||||
-rl, -rate-limit int maximum number of requests to send per second (default 150)
|
||||
-rld, -rate-limit-duration value maximum number of requests to send per second (default 1s)
|
||||
-per-host-rate-limit enable per-host rate limiting (global rate limit becomes unlimited when enabled)
|
||||
-rlm, -rate-limit-minute int maximum number of requests to send per minute (DEPRECATED)
|
||||
-bs, -bulk-size int maximum number of hosts to be analyzed in parallel per template (default 25)
|
||||
-c, -concurrency int maximum number of templates to be executed in parallel (default 25)
|
||||
@@ -301,6 +306,7 @@ OPTIMIZATIONS:
|
||||
-ss, -scan-strategy value strategy to use while scanning(auto/host-spray/template-spray) (default auto)
|
||||
-irt, -input-read-timeout value timeout on input read (default 3m0s)
|
||||
-nh, -no-httpx disable httpx probing for non-url input
|
||||
-preflight-portscan run preflight resolve + TCP portscan and filter targets before scanning (disabled by default)
|
||||
-no-stdin disable stdin processing
|
||||
|
||||
HEADLESS:
|
||||
|
||||
@@ -140,6 +140,7 @@ Flags:
|
||||
TARGET:
|
||||
-u, -target string[] target URLs/hosts to scan
|
||||
-l, -list string path to file containing a list of target URLs/hosts to scan (one per line)
|
||||
-targets-inline string inline multiline target list (for use in template profiles)
|
||||
-eh, -exclude-hosts string[] hosts to exclude to scan from the input list (ip, cidr, hostname)
|
||||
-resume string resume scan from and save to specified file (clustering will be disabled)
|
||||
-sa, -scan-all-ips scan all the IP's associated with dns record
|
||||
@@ -149,6 +150,8 @@ TARGET-FORMAT:
|
||||
-im, -input-mode string mode of input file (list, burp, jsonl, yaml, openapi, swagger) (default "list")
|
||||
-ro, -required-only use only required fields in input format when generating requests
|
||||
-sfv, -skip-format-validation skip format validation (like missing vars) when parsing input file
|
||||
-vtt, -vars-text-templating enable text templating for vars in input file (only for yaml input mode)
|
||||
-vfp, -var-file-paths string[] list of yaml file contained vars to inject into yaml input
|
||||
|
||||
TEMPLATES:
|
||||
-nt, -new-templates run only new templates added in latest nuclei-templates release
|
||||
@@ -205,6 +208,7 @@ OUTPUT:
|
||||
-se, -sarif-export string file to export results in SARIF format
|
||||
-je, -json-export string file to export results in JSON format
|
||||
-jle, -jsonl-export string file to export results in JSONL(ine) format
|
||||
-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
|
||||
|
||||
CONFIGURATIONS:
|
||||
@@ -277,6 +281,7 @@ UNCOVER:
|
||||
RATE-LIMIT:
|
||||
-rl, -rate-limit int maximum number of requests to send per second (default 150)
|
||||
-rld, -rate-limit-duration value maximum number of requests to send per second (default 1s)
|
||||
-per-host-rate-limit enable per-host rate limiting (global rate limit becomes unlimited when enabled)
|
||||
-rlm, -rate-limit-minute int maximum number of requests to send per minute (DEPRECATED)
|
||||
-bs, -bulk-size int maximum number of hosts to be analyzed in parallel per template (default 25)
|
||||
-c, -concurrency int maximum number of templates to be executed in parallel (default 25)
|
||||
@@ -301,6 +306,7 @@ OPTIMIZATIONS:
|
||||
-ss, -scan-strategy value strategy to use while scanning(auto/host-spray/template-spray) (default auto)
|
||||
-irt, -input-read-timeout value timeout on input read (default 3m0s)
|
||||
-nh, -no-httpx disable httpx probing for non-url input
|
||||
-preflight-portscan run preflight resolve + TCP portscan and filter targets before scanning (disabled by default)
|
||||
-no-stdin disable stdin processing
|
||||
|
||||
HEADLESS:
|
||||
|
||||
@@ -140,6 +140,7 @@ Flags:
|
||||
TARGET:
|
||||
-u, -target string[] target URLs/hosts to scan
|
||||
-l, -list string path to file containing a list of target URLs/hosts to scan (one per line)
|
||||
-targets-inline string inline multiline target list (for use in template profiles)
|
||||
-eh, -exclude-hosts string[] hosts to exclude to scan from the input list (ip, cidr, hostname)
|
||||
-resume string resume scan from and save to specified file (clustering will be disabled)
|
||||
-sa, -scan-all-ips scan all the IP's associated with dns record
|
||||
@@ -149,6 +150,8 @@ TARGET-FORMAT:
|
||||
-im, -input-mode string mode of input file (list, burp, jsonl, yaml, openapi, swagger) (default "list")
|
||||
-ro, -required-only use only required fields in input format when generating requests
|
||||
-sfv, -skip-format-validation skip format validation (like missing vars) when parsing input file
|
||||
-vtt, -vars-text-templating enable text templating for vars in input file (only for yaml input mode)
|
||||
-vfp, -var-file-paths string[] list of yaml file contained vars to inject into yaml input
|
||||
|
||||
TEMPLATES:
|
||||
-nt, -new-templates run only new templates added in latest nuclei-templates release
|
||||
@@ -205,6 +208,7 @@ OUTPUT:
|
||||
-se, -sarif-export string file to export results in SARIF format
|
||||
-je, -json-export string file to export results in JSON format
|
||||
-jle, -jsonl-export string file to export results in JSONL(ine) format
|
||||
-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
|
||||
|
||||
CONFIGURATIONS:
|
||||
@@ -277,6 +281,7 @@ UNCOVER:
|
||||
RATE-LIMIT:
|
||||
-rl, -rate-limit int maximum number of requests to send per second (default 150)
|
||||
-rld, -rate-limit-duration value maximum number of requests to send per second (default 1s)
|
||||
-per-host-rate-limit enable per-host rate limiting (global rate limit becomes unlimited when enabled)
|
||||
-rlm, -rate-limit-minute int maximum number of requests to send per minute (DEPRECATED)
|
||||
-bs, -bulk-size int maximum number of hosts to be analyzed in parallel per template (default 25)
|
||||
-c, -concurrency int maximum number of templates to be executed in parallel (default 25)
|
||||
@@ -301,6 +306,7 @@ OPTIMIZATIONS:
|
||||
-ss, -scan-strategy value strategy to use while scanning(auto/host-spray/template-spray) (default auto)
|
||||
-irt, -input-read-timeout value timeout on input read (default 3m0s)
|
||||
-nh, -no-httpx disable httpx probing for non-url input
|
||||
-preflight-portscan run preflight resolve + TCP portscan and filter targets before scanning (disabled by default)
|
||||
-no-stdin disable stdin processing
|
||||
|
||||
HEADLESS:
|
||||
|
||||
@@ -1009,6 +1009,26 @@ Part Definitions:
|
||||
- <code>all</code> - HTTP response body + headers
|
||||
- <code>cookies_from_response</code> - HTTP response cookies in name:value format
|
||||
- <code>headers_from_response</code> - HTTP response headers in name:value format
|
||||
- <code>tls_version</code> - TLS version negotiated for the HTTP connection
|
||||
- <code>cipher</code> - TLS cipher suite negotiated for the HTTP connection
|
||||
- <code>sni</code> - SNI value used in the TLS handshake
|
||||
- <code>subject_cn</code> - Leaf certificate subject common name
|
||||
- <code>subject_dn</code> - Leaf certificate subject distinguished name
|
||||
- <code>subject_an</code> - Leaf certificate subject alternative names
|
||||
- <code>subject_org</code> - Leaf certificate subject organization
|
||||
- <code>issuer_cn</code> - Leaf certificate issuer common name
|
||||
- <code>issuer_dn</code> - Leaf certificate issuer distinguished name
|
||||
- <code>issuer_org</code> - Leaf certificate issuer organization
|
||||
- <code>serial</code> - Leaf certificate serial number
|
||||
- <code>fingerprint_hash</code> - Leaf certificate fingerprint hashes (md5/sha1/sha256)
|
||||
- <code>not_before</code> - Leaf certificate not-before timestamp
|
||||
- <code>not_after</code> - Leaf certificate not-after timestamp
|
||||
- <code>expired</code> - Whether the leaf certificate has expired
|
||||
- <code>self_signed</code> - Whether the leaf certificate is self-signed
|
||||
- <code>mismatched</code> - Whether the leaf certificate does not match the SNI hostname
|
||||
- <code>domains</code> - Deduplicated domains from subject CN and SANs
|
||||
- <code>wildcard_certificate</code> - Whether the leaf certificate is a wildcard certificate
|
||||
- <code>emails</code> - Email addresses embedded in the leaf certificate
|
||||
|
||||
<hr />
|
||||
|
||||
@@ -2606,6 +2626,7 @@ Part Definitions:
|
||||
- <code>host</code> - Host is the input to the template
|
||||
- <code>matched</code> - Matched is the input which was matched upon
|
||||
- <code>request</code> - Request contains the DNS request in text format
|
||||
- <code>duration</code> - Protocol operation duration in seconds
|
||||
- <code>type</code> - Type is the type of request made
|
||||
- <code>rcode</code> - Rcode field returned for the DNS request
|
||||
- <code>question</code> - Question contains the DNS question field
|
||||
@@ -2898,6 +2919,16 @@ Enum Values:
|
||||
- <code>ANY</code>
|
||||
|
||||
- <code>SRV</code>
|
||||
|
||||
- <code>RRSIG</code>
|
||||
|
||||
- <code>NSEC</code>
|
||||
|
||||
- <code>DNSKEY</code>
|
||||
|
||||
- <code>NSEC3</code>
|
||||
|
||||
- <code>NSEC3PARAM</code>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
@@ -3071,6 +3102,82 @@ NoRecursive specifies whether to not do recursive checks if folders are provided
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="dd">
|
||||
|
||||
<code>smb-user</code> <i>string</i>
|
||||
|
||||
</div>
|
||||
<div class="dt">
|
||||
|
||||
SMBUser authenticates to remote SMB shares when the file input is a UNC
|
||||
or smb:// path (issue #6142). Guest/anon: empty password.
|
||||
|
||||
|
||||
|
||||
Examples:
|
||||
|
||||
|
||||
```yaml
|
||||
smb-user: auditor
|
||||
```
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="dd">
|
||||
|
||||
<code>smb-password</code> <i>string</i>
|
||||
|
||||
</div>
|
||||
<div class="dt">
|
||||
|
||||
SMBPassword is the password for SMB file targets.
|
||||
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="dd">
|
||||
|
||||
<code>smb-domain</code> <i>string</i>
|
||||
|
||||
</div>
|
||||
<div class="dt">
|
||||
|
||||
SMBDomain is the optional NTLM domain / workgroup.
|
||||
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="dd">
|
||||
|
||||
<code>smb-hash</code> <i>string</i>
|
||||
|
||||
</div>
|
||||
<div class="dt">
|
||||
|
||||
SMBHash enables pass-the-hash (overrides smb-password when set).
|
||||
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="dd">
|
||||
|
||||
<code>smb-port</code> <i>int</i>
|
||||
|
||||
</div>
|
||||
<div class="dt">
|
||||
|
||||
SMBPort overrides the default SMB port (445) for UNC targets.
|
||||
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -3109,6 +3216,7 @@ Part Definitions:
|
||||
- <code>matched</code> - Matched is the input which was matched upon
|
||||
- <code>type</code> - Type is the type of request made
|
||||
- <code>request</code> - Network request made from the client
|
||||
- <code>duration</code> - Latest measured operation duration in seconds
|
||||
- <code>body,all,data</code> - Network response received from server (default)
|
||||
- <code>raw</code> - Full Network protocol data
|
||||
|
||||
@@ -3501,6 +3609,7 @@ Part Definitions:
|
||||
- <code>matched</code> - Matched is the input which was matched upon
|
||||
- <code>type</code> - Type is the type of request made
|
||||
- <code>req</code> - Headless request made from the client
|
||||
- <code>duration</code> - Latest measured operation duration in seconds
|
||||
- <code>resp,body,data</code> - Headless response received from client (default)
|
||||
|
||||
<hr />
|
||||
@@ -3881,6 +3990,7 @@ Part Definitions:
|
||||
- <code>matched</code> - Matched is the input which was matched upon
|
||||
- <code>type</code> - Type is the type of request made
|
||||
- <code>timestamp</code> - Timestamp is the time when the request was made
|
||||
- <code>duration</code> - Protocol operation duration in seconds
|
||||
- <code>response</code> - JSON SSL protocol handshake details
|
||||
- <code>cipher</code> - Cipher is the encryption algorithm used
|
||||
- <code>domains</code> - Domains are the list of domain names in the certificate
|
||||
@@ -4080,6 +4190,7 @@ Part Definitions:
|
||||
- <code>type</code> - Type is the type of request made
|
||||
- <code>success</code> - Success specifies whether websocket connection was successful
|
||||
- <code>request</code> - Websocket request made to the server
|
||||
- <code>duration</code> - Latest measured operation duration in seconds
|
||||
- <code>response</code> - Websocket response received from the server
|
||||
- <code>host</code> - Host is the input to the template
|
||||
- <code>matched</code> - Matched is the input which was matched upon
|
||||
@@ -4254,8 +4365,14 @@ Appears in:
|
||||
|
||||
|
||||
|
||||
Part Definitions:
|
||||
|
||||
|
||||
- <code>type</code> - Type is the type of request made
|
||||
- <code>host</code> - Host is the input to the template
|
||||
- <code>response</code> - WHOIS response data
|
||||
- <code>duration</code> - Protocol operation duration in seconds
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="dd">
|
||||
|
||||
@@ -25,6 +25,7 @@ extend-exclude = [
|
||||
[default.extend-identifiers]
|
||||
# Go identifiers that cannot be renamed without breaking API
|
||||
MisMatched = "MisMatched"
|
||||
IsMisMatchedCert = "IsMisMatchedCert" # tlsx clients API
|
||||
NoopWriter = "NoopWriter"
|
||||
AllowdTypes = "AllowdTypes"
|
||||
# Deprecated alias kept for backward compatibility
|
||||
|
||||
+4
-13
@@ -260,6 +260,9 @@ func readConfig() *goflags.FlagSet {
|
||||
flagSet.SetDescription(`Nuclei is a fast, template based vulnerability scanner focusing
|
||||
on extensive configurability, massive extensibility and ease of use.`)
|
||||
|
||||
// Shared PD flags (e.g. -max-time soft-kill via interrupt).
|
||||
flagSet.AddCommonFlags()
|
||||
|
||||
/* TODO Important: The defined default values, especially for slice/array types are NOT DEFAULT VALUES, but rather implicit values to which the user input is appended.
|
||||
This can be very confusing and should be addressed
|
||||
*/
|
||||
@@ -516,16 +519,6 @@ on extensive configurability, massive extensibility and ease of use.`)
|
||||
flagSet.CreateGroup("Authentication", "Authentication",
|
||||
flagSet.StringSliceVarP(&options.SecretsFile, "secret-file", "sf", nil, "path to config file containing secrets for nuclei authenticated scan", goflags.CommaSeparatedStringSliceOptions),
|
||||
flagSet.BoolVarP(&options.PreFetchSecrets, "prefetch-secrets", "ps", false, "prefetch secrets from the secrets file"),
|
||||
flagSet.StringVarP(&options.AuthLoginURL, "auth-login-url", "alu", "", "login page url for turnkey auto-login authenticated scan"),
|
||||
flagSet.StringVarP(&options.AuthUsername, "auth-username", "au", "", "username for auto-login (-auth-login-url)"),
|
||||
flagSet.StringVarP(&options.AuthPassword, "auth-password", "ap", "", "password for auto-login (-auth-login-url)"),
|
||||
flagSet.StringVarP(&options.AuthUsernameField, "auth-username-field", "auf", "", "override the detected username form field name for auto-login"),
|
||||
flagSet.StringVarP(&options.AuthPasswordField, "auth-password-field", "apf", "", "override the detected password form field name for auto-login"),
|
||||
flagSet.BoolVarP(&options.AuthHeadless, "auth-headless", "ah", false, "drive auto-login through a real browser (JS/SPA/SSO login pages)"),
|
||||
flagSet.StringVarP(&options.AuthRecording, "auth-recording", "ar", "", "path to a chrome devtools recorder json login flow to replay for auto-login"),
|
||||
flagSet.BoolVarP(&options.AuthCapture, "auth-capture", "ac", false, "open a browser to log in manually once, then capture the session for the scan"),
|
||||
flagSet.StringVarP(&options.AuthReauthStatusCodes, "auth-reauth-status-codes", "arsc", "", "comma-separated status codes (e.g. 401,403) that trigger an auto-login re-authentication during the scan"),
|
||||
flagSet.StringVarP(&options.AuthRefreshInterval, "auth-refresh-interval", "ari", "", "re-run the auto-login once the captured session is older than this duration (e.g. 15m)"),
|
||||
)
|
||||
|
||||
flagSet.SetCustomHelpText(`EXAMPLES:
|
||||
@@ -783,9 +776,7 @@ func disableUpdatesCallback() {
|
||||
// printVersion prints the nuclei version and exits.
|
||||
func printVersion() {
|
||||
options.Logger.Info().Msgf("Nuclei Engine Version: %s", config.Version)
|
||||
options.Logger.Info().Msgf("Nuclei Config Directory: %s", config.DefaultConfig.GetConfigDir())
|
||||
options.Logger.Info().Msgf("Nuclei Cache Directory: %s", config.DefaultConfig.GetCacheDir()) // cache dir contains resume files
|
||||
options.Logger.Info().Msgf("PDCP Directory: %s", pdcp.PDCPDir)
|
||||
runner.LogDirectoryInfo(options.Logger)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
|
||||
@@ -34,10 +34,15 @@ func TestMain(m *testing.M) {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
dummyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
targetURL = dummyServer.URL
|
||||
// Only start the shared httptest for benchmarks so unit tests (e.g. goleak)
|
||||
// are not polluted by a long-lived accept loop.
|
||||
var dummyServer *httptest.Server
|
||||
if isBenchmarkRun() {
|
||||
dummyServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
targetURL = dummyServer.URL
|
||||
}
|
||||
|
||||
// Execute tests
|
||||
|
||||
@@ -45,13 +50,24 @@ func TestMain(m *testing.M) {
|
||||
|
||||
// Tear down
|
||||
|
||||
dummyServer.Close()
|
||||
if dummyServer != nil {
|
||||
dummyServer.Close()
|
||||
}
|
||||
_ = os.RemoveAll(projectPath)
|
||||
_ = os.Unsetenv("DISABLE_STDOUT")
|
||||
|
||||
os.Exit(exitCode)
|
||||
}
|
||||
|
||||
func isBenchmarkRun() bool {
|
||||
for _, arg := range os.Args[1:] {
|
||||
if strings.HasPrefix(arg, "-test.bench") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// getUniqFilename generates a unique filename by appending .N if file exists
|
||||
// Similar to wget's behavior: file.cpu.prof, file.cpu.1.prof, file.cpu.2.prof, etc.
|
||||
func getUniqFilename(basePath string) string {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package main_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/projectdiscovery/gologger"
|
||||
"github.com/projectdiscovery/gologger/levels"
|
||||
"github.com/projectdiscovery/nuclei/v3/internal/runner"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
|
||||
"github.com/rs/xid"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tarunKoyalwar/goleak"
|
||||
)
|
||||
|
||||
const cliLeakTestChildEnv = "NUCLEI_CLI_LEAKTEST_CHILD"
|
||||
|
||||
var cliKnownLeaks = []goleak.Option{
|
||||
goleak.Pretty(),
|
||||
goleak.IgnoreAnyFunction("net/http.(*http2ClientConn).readLoop"),
|
||||
goleak.IgnoreAnyFunction("net/http.(*persistConn).readLoop"),
|
||||
goleak.IgnoreAnyFunction("net/http.(*persistConn).writeLoop"),
|
||||
goleak.IgnoreAnyContainingPkg("github.com/hashicorp/golang-lru/v2/expirable"),
|
||||
goleak.IgnoreAnyContainingPkg("github.com/syndtr/goleveldb"),
|
||||
goleak.IgnoreAnyContainingPkg("github.com/projectdiscovery/ratelimit"),
|
||||
goleak.IgnoreAnyFunction("github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate.StartActiveMemGuardian.func1"),
|
||||
}
|
||||
|
||||
// TestCLIRunnerGoroutineLeak runs a minimal CLI-path nuclei scan via internal/runner
|
||||
// and asserts no unexpected goroutine leaks remain after Close.
|
||||
//
|
||||
// The body runs in a child process so shared package state from other tests
|
||||
// does not pollute goleak (same approach as lib/tests SDK leak coverage).
|
||||
func TestCLIRunnerGoroutineLeak(t *testing.T) {
|
||||
if os.Getenv(cliLeakTestChildEnv) == "1" {
|
||||
runCLIRunnerLeakTest(t)
|
||||
return
|
||||
}
|
||||
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^TestCLIRunnerGoroutineLeak$", "-test.count=1", "-test.v")
|
||||
cmd.Env = append(os.Environ(), cliLeakTestChildEnv+"=1")
|
||||
out, err := cmd.CombinedOutput()
|
||||
require.NoError(t, err, "CLI leak test child failed:\n%s", string(out))
|
||||
}
|
||||
|
||||
func runCLIRunnerLeakTest(t *testing.T) {
|
||||
gologger.DefaultLogger.SetMaxLevel(levels.LevelSilent)
|
||||
_ = os.Setenv("DISABLE_STDOUT", "true")
|
||||
t.Cleanup(func() { _ = os.Unsetenv("DISABLE_STDOUT") })
|
||||
config.DefaultConfig.DisableUpdateCheck()
|
||||
|
||||
defer func() {
|
||||
time.Sleep(2 * time.Second)
|
||||
goleak.VerifyNone(t, cliKnownLeaks...)
|
||||
}()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, thisFile, _, ok := runtime.Caller(0)
|
||||
require.True(t, ok)
|
||||
templatePath := filepath.Join(filepath.Dir(thisFile), "testdata", "leaktest", "basic-http.yaml")
|
||||
|
||||
options := getDefaultOptions()
|
||||
options.Targets = []string{server.URL}
|
||||
options.Templates = []string{templatePath}
|
||||
options.NoInteractsh = true
|
||||
options.DisableStdin = true
|
||||
options.BulkSize = 1
|
||||
options.TemplateThreads = 1
|
||||
options.PayloadConcurrency = 1
|
||||
options.ExecutionId = xid.New().String()
|
||||
|
||||
runner.ParseOptions(options)
|
||||
|
||||
nucleiRunner, err := runner.New(options)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, nucleiRunner)
|
||||
defer nucleiRunner.Close()
|
||||
|
||||
require.NoError(t, nucleiRunner.RunEnumeration())
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/projectdiscovery/goflags"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Ensures nuclei's goflags dependency still exposes AddCommonFlags / -max-time,
|
||||
// which is what cmd/nuclei wires via flagSet.AddCommonFlags().
|
||||
func TestMaxTimeCommonFlagAvailable(t *testing.T) {
|
||||
flagSet := goflags.NewFlagSet()
|
||||
common := flagSet.AddCommonFlags()
|
||||
require.NotNil(t, common)
|
||||
|
||||
require.NoError(t, flagSet.Parse("-max-time", "1h30m"))
|
||||
require.Equal(t, 90*time.Minute, common.MaxTime)
|
||||
|
||||
flagSet = goflags.NewFlagSet()
|
||||
common = flagSet.AddCommonFlags()
|
||||
require.NoError(t, flagSet.Parse("-mt", "45s"))
|
||||
require.Equal(t, 45*time.Second, common.MaxTime)
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
id: leaktest-basic-http
|
||||
|
||||
info:
|
||||
name: CLI Leak Test Basic HTTP
|
||||
author: pdteam
|
||||
severity: info
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}/"
|
||||
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 204
|
||||
@@ -1,6 +1,6 @@
|
||||
module github.com/projectdiscovery/nuclei/v3
|
||||
|
||||
go 1.25.7
|
||||
go 1.26
|
||||
|
||||
require (
|
||||
github.com/andygrunwald/go-jira v1.16.1
|
||||
@@ -13,16 +13,16 @@ require (
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/julienschmidt/httprouter v1.3.0
|
||||
github.com/logrusorgru/aurora v2.0.3+incompatible // indirect
|
||||
github.com/miekg/dns v1.1.68
|
||||
github.com/miekg/dns v1.1.72
|
||||
github.com/olekukonko/tablewriter v1.0.8
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/projectdiscovery/clistats v0.1.4
|
||||
github.com/projectdiscovery/fastdialer v0.5.12
|
||||
github.com/projectdiscovery/fastdialer v0.5.14
|
||||
github.com/projectdiscovery/hmap v0.0.101
|
||||
github.com/projectdiscovery/interactsh v1.3.1
|
||||
github.com/projectdiscovery/rawhttp v0.1.90
|
||||
github.com/projectdiscovery/rawhttp v0.1.91
|
||||
github.com/projectdiscovery/retryabledns v1.0.115
|
||||
github.com/projectdiscovery/retryablehttp-go v1.3.17
|
||||
github.com/projectdiscovery/retryablehttp-go v1.3.21
|
||||
github.com/projectdiscovery/yamldoc-go v1.0.6
|
||||
github.com/remeh/sizedwaitgroup v1.0.0
|
||||
github.com/rs/xid v1.6.0
|
||||
@@ -32,9 +32,9 @@ require (
|
||||
github.com/syndtr/goleveldb v1.0.0
|
||||
github.com/weppos/publicsuffix-go v0.50.3 // indirect
|
||||
go.uber.org/multierr v1.11.0
|
||||
golang.org/x/net v0.55.0
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
golang.org/x/text v0.37.0
|
||||
golang.org/x/net v0.56.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/text v0.39.0
|
||||
)
|
||||
|
||||
require (
|
||||
@@ -65,12 +65,13 @@ require (
|
||||
github.com/ditashi/jsbeautifier-go v0.0.0-20141206144643-2520a8026a9c
|
||||
github.com/docker/go-units v0.5.0
|
||||
github.com/fatih/structs v1.1.0
|
||||
github.com/getkin/kin-openapi v0.132.0
|
||||
github.com/fullstorydev/grpcurl v1.9.3
|
||||
github.com/getkin/kin-openapi v0.144.0
|
||||
github.com/go-git/go-git/v5 v5.19.1
|
||||
github.com/go-ldap/ldap/v3 v3.4.12
|
||||
github.com/go-pdf/fpdf v0.9.0
|
||||
github.com/go-pg/pg/v10 v10.15.0
|
||||
github.com/go-sql-driver/mysql v1.9.3
|
||||
github.com/go-sql-driver/mysql v1.10.0
|
||||
github.com/goccy/go-json v0.10.5
|
||||
github.com/google/go-github/v30 v30.1.0
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
|
||||
@@ -78,9 +79,10 @@ require (
|
||||
github.com/h2non/filetype v1.1.3
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4
|
||||
github.com/jhump/protoreflect v1.17.0
|
||||
github.com/kitabisa/go-ci v1.0.3
|
||||
github.com/leslie-qiwa/flat v0.0.0-20230424180412-f9d1cf014baa
|
||||
github.com/lib/pq v1.11.2
|
||||
github.com/lib/pq v1.12.3
|
||||
github.com/logrusorgru/aurora/v4 v4.0.0
|
||||
github.com/mattn/go-sqlite3 v1.14.28
|
||||
github.com/maypok86/otter/v2 v2.2.1
|
||||
@@ -92,18 +94,17 @@ require (
|
||||
github.com/projectdiscovery/dsl v0.8.20
|
||||
github.com/projectdiscovery/fasttemplate v0.0.2
|
||||
github.com/projectdiscovery/gcache v0.0.0-20241015120333-12546c6e3f4c
|
||||
github.com/projectdiscovery/go-smb2 v0.0.0-20240129202741-052cc450c6cb
|
||||
github.com/projectdiscovery/goflags v0.1.74
|
||||
github.com/projectdiscovery/goflags v0.1.75
|
||||
github.com/projectdiscovery/goja v0.0.0-20260618133720-acb73e419534
|
||||
github.com/projectdiscovery/goja_nodejs v0.0.0-20260618132410-8519f75f703d
|
||||
github.com/projectdiscovery/gologger v1.1.71
|
||||
github.com/projectdiscovery/gostruct v0.0.2
|
||||
github.com/projectdiscovery/govaluate v0.0.0-20260615100919-5ee2581bbf7e
|
||||
github.com/projectdiscovery/gozero v0.1.1-0.20260530071156-fa1dad563d76
|
||||
github.com/projectdiscovery/httpx v1.9.0
|
||||
github.com/projectdiscovery/httpx v1.10.0
|
||||
github.com/projectdiscovery/mapcidr v1.1.97
|
||||
github.com/projectdiscovery/n3iwf v0.0.0-20230523120440-b8cd232ff1f5
|
||||
github.com/projectdiscovery/networkpolicy v0.1.42
|
||||
github.com/projectdiscovery/networkpolicy v0.1.44
|
||||
github.com/projectdiscovery/ratelimit v0.0.88
|
||||
github.com/projectdiscovery/rdap v0.9.0
|
||||
github.com/projectdiscovery/sarif v0.1.0
|
||||
@@ -111,7 +112,7 @@ require (
|
||||
github.com/projectdiscovery/uncover v1.2.1
|
||||
github.com/projectdiscovery/useragent v0.0.108
|
||||
github.com/projectdiscovery/utils v0.11.1
|
||||
github.com/projectdiscovery/wappalyzergo v0.2.88
|
||||
github.com/projectdiscovery/wappalyzergo v0.2.91
|
||||
github.com/redis/go-redis/v9 v9.11.0
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466
|
||||
@@ -122,8 +123,9 @@ require (
|
||||
github.com/zmap/zgrab2 v0.1.8
|
||||
gitlab.com/gitlab-org/api/client-go v1.9.1
|
||||
go.mongodb.org/mongo-driver v1.17.9
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/term v0.43.0
|
||||
golang.org/x/sync v0.21.0
|
||||
golang.org/x/term v0.44.0
|
||||
google.golang.org/grpc v1.82.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
moul.io/http2curl v1.0.0
|
||||
)
|
||||
@@ -131,7 +133,7 @@ require (
|
||||
require (
|
||||
aead.dev/minisign v0.3.0 // indirect
|
||||
dario.cat/mergo v1.0.2 // indirect
|
||||
filippo.io/edwards25519 v1.1.1 // indirect
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
git.mills.io/prologic/smtpd v0.0.0-20210710122116-a525b76c287a // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 // indirect
|
||||
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect
|
||||
@@ -143,7 +145,7 @@ require (
|
||||
github.com/Mzack9999/go-http-digest-auth-client v0.6.1-0.20220414142836-eb8883508809 // indirect
|
||||
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.1.6 // indirect
|
||||
github.com/PuerkitoBio/goquery v1.11.0 // indirect
|
||||
github.com/PuerkitoBio/goquery v1.12.0 // indirect
|
||||
github.com/STARRY-S/zip v0.2.3 // indirect
|
||||
github.com/VividCortex/ewma v1.2.0 // indirect
|
||||
github.com/akrylysov/pogreb v0.10.2 // indirect
|
||||
@@ -177,6 +179,7 @@ require (
|
||||
github.com/bodgit/sevenzip v1.6.1 // indirect
|
||||
github.com/bodgit/windows v1.0.1 // indirect
|
||||
github.com/brianvoe/gofakeit/v7 v7.2.1 // indirect
|
||||
github.com/bufbuild/protocompile v0.14.1 // indirect
|
||||
github.com/buger/jsonparser v1.1.2 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
@@ -195,6 +198,7 @@ require (
|
||||
github.com/cloudflare/cfssl v1.6.4 // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect
|
||||
github.com/cnf/structhash v0.0.0-20250313080605-df4c6cc74a9a // indirect
|
||||
github.com/containerd/continuity v0.4.5 // indirect
|
||||
github.com/containerd/errdefs v1.0.0 // indirect
|
||||
@@ -213,13 +217,15 @@ require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/ebitengine/purego v0.10.0 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
|
||||
github.com/ericlagergren/decimal v0.0.0-20240411145413-00de7ca16731 // indirect
|
||||
github.com/fatih/color v1.18.0 // indirect
|
||||
github.com/felixge/fgprof v0.9.5 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/free5gc/util v1.0.5-0.20230511064842-2e120956883b // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gaissmai/bart v0.28.0 // indirect
|
||||
github.com/gaissmai/bart v0.29.0 // indirect
|
||||
github.com/geoffgarside/ber v1.2.0 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/gin-gonic/gin v1.9.1 // indirect
|
||||
@@ -227,11 +233,12 @@ require (
|
||||
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/go-billy/v5 v5.9.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.22.5 // indirect
|
||||
github.com/go-openapi/swag/jsonname v0.25.5 // indirect
|
||||
github.com/go-pg/zerochecker v0.2.0 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
@@ -241,6 +248,7 @@ require (
|
||||
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
|
||||
github.com/golang-sql/sqlexp v0.1.0 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/golang/snappy v1.0.0 // indirect
|
||||
github.com/google/certificate-transparency-go v1.3.2 // indirect
|
||||
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect
|
||||
@@ -252,7 +260,7 @@ require (
|
||||
github.com/hashicorp/go-uuid v1.0.3 // indirect
|
||||
github.com/hashicorp/go-version v1.8.0 // indirect
|
||||
github.com/hbakhtiyor/strsim v0.0.0-20190107154042-4d2bbb273edf // indirect
|
||||
github.com/hdm/jarm-go v0.0.7 // indirect
|
||||
github.com/hdm/jarm-go v0.0.8 // indirect
|
||||
github.com/iangcarroll/cookiemonster v1.6.0 // indirect
|
||||
github.com/imdario/mergo v0.3.16 // indirect
|
||||
github.com/indece-official/go-ebcdic v1.2.0 // indirect
|
||||
@@ -264,12 +272,11 @@ require (
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1 // indirect
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/k14s/starlark-go v0.0.0-20200720175618-3a5c849cc368 // indirect
|
||||
github.com/kaiakz/ubuffer v0.0.0-20200803053910-dd1083087166 // indirect
|
||||
github.com/kataras/jwt v0.1.10 // indirect
|
||||
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/klauspost/compress v1.18.7 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/klauspost/pgzip v1.2.6 // indirect
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
@@ -296,13 +303,12 @@ require (
|
||||
github.com/moby/term v0.5.2 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||
github.com/montanaflynn/stats v0.7.1 // indirect
|
||||
github.com/muesli/reflow v0.3.0 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/nwaples/rardecode/v2 v2.2.2 // indirect
|
||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
|
||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
|
||||
github.com/oasdiff/yaml v0.1.1 // indirect
|
||||
github.com/oasdiff/yaml3 v0.0.14 // indirect
|
||||
github.com/oiweiwei/go-smb2.fork v1.0.0 // indirect
|
||||
github.com/oiweiwei/gokrb5.fork/v9 v9.0.6 // indirect
|
||||
github.com/olekukonko/errors v1.1.0 // indirect
|
||||
@@ -312,19 +318,20 @@ require (
|
||||
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/perimeterx/marshmallow v1.1.5 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.23 // indirect
|
||||
github.com/pjbgf/sha1cd v0.6.0 // indirect
|
||||
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||
github.com/projectdiscovery/asnmap v1.1.1 // indirect
|
||||
github.com/projectdiscovery/blackrock v0.0.1 // indirect
|
||||
github.com/projectdiscovery/cdncheck v1.2.43 // indirect
|
||||
github.com/projectdiscovery/cdncheck v1.2.46 // indirect
|
||||
github.com/projectdiscovery/freeport v0.0.7 // indirect
|
||||
github.com/projectdiscovery/ldapserver v1.0.2-0.20240219154113-dcc758ebc0cb // indirect
|
||||
github.com/projectdiscovery/machineid v0.0.0-20250715113114-c77eb3567582 // indirect
|
||||
github.com/refraction-networking/utls v1.8.2 // indirect
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
|
||||
github.com/sashabaranov/go-openai v1.37.0 // indirect
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
|
||||
github.com/shirou/gopsutil v3.21.11+incompatible // indirect
|
||||
@@ -334,6 +341,7 @@ require (
|
||||
github.com/sorairolake/lzip-go v0.3.8 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
|
||||
github.com/stretchr/objx v0.5.3 // indirect
|
||||
github.com/tidwall/btree v1.8.1 // indirect
|
||||
github.com/tidwall/buntdb v1.3.2 // indirect
|
||||
@@ -368,19 +376,20 @@ require (
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
github.com/ysmood/fetchup v0.2.3 // indirect
|
||||
github.com/ysmood/got v0.40.0 // indirect
|
||||
github.com/yuin/goldmark v1.7.13 // indirect
|
||||
github.com/yuin/goldmark v1.7.17 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
||||
github.com/zcalusic/sysinfo v1.1.3 // indirect
|
||||
github.com/zeebo/blake3 v0.2.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
|
||||
go.opentelemetry.io/otel v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
go.uber.org/zap/exp v0.3.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
go4.org v0.0.0-20230225012048-214862532bf5 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
|
||||
mellium.im/sasl v0.3.2 // indirect
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.2 // indirect
|
||||
)
|
||||
@@ -404,13 +413,13 @@ require (
|
||||
go.etcd.io/bbolt v1.4.3 // indirect
|
||||
go.uber.org/zap v1.27.0 // indirect
|
||||
goftp.io/server/v2 v2.0.1 // indirect
|
||||
golang.org/x/crypto v0.52.0 // indirect
|
||||
golang.org/x/crypto v0.53.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260527015227-08cc5374adb3
|
||||
golang.org/x/mod v0.36.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/mod v0.37.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/time v0.14.0 // indirect
|
||||
golang.org/x/tools v0.45.0
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
golang.org/x/tools v0.47.0
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6 // indirect
|
||||
gopkg.in/corvus-ch/zbase32.v1 v1.0.0 // indirect
|
||||
)
|
||||
|
||||
@@ -41,8 +41,8 @@ code.gitea.io/sdk/gitea v0.17.0/go.mod h1:ndkDk99BnfiUCCYEUhpNzi0lpmApXlwRFqClBl
|
||||
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
|
||||
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw=
|
||||
filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
git.mills.io/prologic/smtpd v0.0.0-20210710122116-a525b76c287a h1:3i+FJ7IpSZHL+VAjtpQeZCRhrpP0odl5XfoLBY4fxJ8=
|
||||
git.mills.io/prologic/smtpd v0.0.0-20210710122116-a525b76c287a/go.mod h1:C7hXLmFmPYPjIDGfQl1clsmQ5TMEQfmzWTrJk475bUs=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U=
|
||||
@@ -97,8 +97,8 @@ github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAE
|
||||
github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g=
|
||||
github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
|
||||
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
|
||||
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
|
||||
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
|
||||
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
|
||||
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
|
||||
github.com/RedTeamPentesting/adauth v0.5.4-0.20260511073005-3d18e8a5a687 h1:HHkHNwakgAFX3qlMm88c090vIYNFWiO5+4WyAr0xJuM=
|
||||
github.com/RedTeamPentesting/adauth v0.5.4-0.20260511073005-3d18e8a5a687/go.mod h1:l6FRaC2TliS/JMNWskO3J1tmrcKJyOaMFhWC6hHeWno=
|
||||
github.com/RumbleDiscovery/rumble-tools v0.0.0-20201105153123-f2adbb3244d2/go.mod h1:jD2+mU+E2SZUuAOHZvZj4xP4frlOo+N/YrXDvASFhkE=
|
||||
@@ -219,6 +219,8 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||
github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw=
|
||||
github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c=
|
||||
github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
|
||||
github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
|
||||
github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
|
||||
@@ -282,6 +284,8 @@ github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJ
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
|
||||
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
|
||||
github.com/cnf/structhash v0.0.0-20250313080605-df4c6cc74a9a h1:Ohw57yVY2dBTt+gsC6aZdteyxwlxfbtgkFEMTEkwgSw=
|
||||
github.com/cnf/structhash v0.0.0-20250313080605-df4c6cc74a9a/go.mod h1:pCxVEbcm3AMg7ejXyorUXi6HQCzOIBf7zEDVPtw0/U4=
|
||||
github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4=
|
||||
@@ -337,7 +341,11 @@ github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FM
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=
|
||||
github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
|
||||
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
|
||||
github.com/ericlagergren/decimal v0.0.0-20240411145413-00de7ca16731 h1:R/ZjJpjQKsZ6L/+Gf9WHbt31GG8NMVcpRqUE+1mMIyo=
|
||||
github.com/ericlagergren/decimal v0.0.0-20240411145413-00de7ca16731/go.mod h1:M9R1FoZ3y//hwwnJtO51ypFGwm8ZfpxPT/ZLtO1mcgQ=
|
||||
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
|
||||
@@ -357,14 +365,16 @@ github.com/free5gc/util v1.0.5-0.20230511064842-2e120956883b/go.mod h1:l2Jrml4vo
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
|
||||
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
|
||||
github.com/fullstorydev/grpcurl v1.9.3 h1:PC1Xi3w+JAvEE2Tg2Gf2RfVgPbf9+tbuQr1ZkyVU3jk=
|
||||
github.com/fullstorydev/grpcurl v1.9.3/go.mod h1:/b4Wxe8bG6ndAjlfSUjwseQReUDUvBJiFEB7UllOlUE=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gaissmai/bart v0.28.0 h1:89yZLo8NmyqD0RYgJ3QO9HhqqGGw+oWhf90cZm69Lko=
|
||||
github.com/gaissmai/bart v0.28.0/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
|
||||
github.com/gaissmai/bart v0.29.0 h1:wO6HGE8g9YE0Wm0bCpYxwRzfQ4+fbJKOhL64e5ACGCI=
|
||||
github.com/gaissmai/bart v0.29.0/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
|
||||
github.com/geoffgarside/ber v1.2.0 h1:/loowoRcs/MWLYmGX9QtIAbA+V/FrnVLsMMPhwiRm64=
|
||||
github.com/geoffgarside/ber v1.2.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc=
|
||||
github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk=
|
||||
github.com/getkin/kin-openapi v0.132.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58=
|
||||
github.com/getkin/kin-openapi v0.144.0 h1:hIRcTH+KjLfkLpYU6bSSfdFpi0fZi1fp+hSPi4aQu9Y=
|
||||
github.com/getkin/kin-openapi v0.144.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
@@ -388,6 +398,8 @@ github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhE
|
||||
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-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.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
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/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
|
||||
@@ -407,10 +419,12 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre
|
||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
||||
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
||||
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
||||
github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
|
||||
github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
|
||||
github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
|
||||
github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
|
||||
github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM=
|
||||
github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
|
||||
github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw=
|
||||
github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y=
|
||||
github.com/go-pg/pg/v10 v10.15.0 h1:6DQwbaxJz/e4wvgzbxBkBLiL/Uuk87MGgHhkURtzx24=
|
||||
@@ -429,11 +443,9 @@ github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA=
|
||||
github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg=
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
|
||||
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
||||
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/goburrow/cache v0.1.4 h1:As4KzO3hgmzPlnaMniZU9+VmoNYseUhuELbxy9mRBfw=
|
||||
@@ -492,6 +504,8 @@ github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
@@ -577,8 +591,9 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/hbakhtiyor/strsim v0.0.0-20190107154042-4d2bbb273edf h1:umfGUaWdFP2s6457fz1+xXYIWDxdGc7HdkLS9aJ1skk=
|
||||
github.com/hbakhtiyor/strsim v0.0.0-20190107154042-4d2bbb273edf/go.mod h1:V99KdStnMHZsvVOwIvhfcUzYgYkRZeQWUtumtL+SKxA=
|
||||
github.com/hdm/jarm-go v0.0.7 h1:Eq0geenHrBSYuKrdVhrBdMMzOmA+CAMLzN2WrF3eL6A=
|
||||
github.com/hdm/jarm-go v0.0.7/go.mod h1:kinGoS0+Sdn1Rr54OtanET5E5n7AlD6T6CrJAKDjJSQ=
|
||||
github.com/hdm/jarm-go v0.0.8 h1:8yDIGrmhEJs+QW1voR3JU0Zaz+AUqs3CJzMAQKzGFIc=
|
||||
github.com/hdm/jarm-go v0.0.8/go.mod h1:memdt/YMMW3811nvANDJsrtc8Fy2e1685aDWdI7Gwto=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
@@ -613,10 +628,11 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||
github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94=
|
||||
github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jlaffaye/ftp v0.0.0-20190624084859-c1312a7102bf/go.mod h1:lli8NYPQOFy3O++YmYbqVgOcQ1JPCwdOy+5zSjKJ9qY=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
|
||||
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||
@@ -647,8 +663,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/go.mod h1:e3wBSzaJbcifXrr/Gw2ZBLn44MmeqP5WySwXyHlCK/U=
|
||||
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw=
|
||||
github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
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/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
@@ -672,8 +688,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/leslie-qiwa/flat v0.0.0-20230424180412-f9d1cf014baa h1:KQKuQDgA3DZX6C396lt3WDYB9Um1gLITLbvficVbqXk=
|
||||
github.com/leslie-qiwa/flat v0.0.0-20230424180412-f9d1cf014baa/go.mod h1:HbwNE4XGwjgtUELkvQaAOjWrpianHYZdQVNqSdYW3UM=
|
||||
github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs=
|
||||
github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U=
|
||||
github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
|
||||
github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8=
|
||||
@@ -716,8 +732,8 @@ github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB
|
||||
github.com/microsoft/go-mssqldb v1.9.2 h1:nY8TmFMQOHpm2qVWo6y4I2mAmVdZqlGiMGAYt64Ibbs=
|
||||
github.com/microsoft/go-mssqldb v1.9.2/go.mod h1:GBbW9ASTiDC+mpgWDGKdm3FnFLTUsLYN3iFL90lQ+PA=
|
||||
github.com/miekg/dns v1.1.35/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
|
||||
github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA=
|
||||
github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps=
|
||||
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0=
|
||||
github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc=
|
||||
github.com/minio/minio-go/v6 v6.0.46/go.mod h1:qD0lajrGW49lKZLtXKtCB4X/qkMf0a5tBvN2PaZg7Gg=
|
||||
@@ -745,8 +761,6 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN
|
||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
||||
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
|
||||
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
|
||||
github.com/mreiferson/go-httpclient v0.0.0-20160630210159-31f0106b4474/go.mod h1:OQA4XLvDbMgS8P0CevmM4m9Q3Jq4phKUzcocxuGJ5m8=
|
||||
@@ -761,10 +775,10 @@ github.com/nwaples/rardecode/v2 v2.2.2 h1:/5oL8dzYivRM/tqX9VcTSWfbpwcbwKG1QtSJr3
|
||||
github.com/nwaples/rardecode/v2 v2.2.2/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
|
||||
github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
|
||||
github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc=
|
||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
|
||||
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
|
||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
|
||||
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
|
||||
github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY=
|
||||
github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU=
|
||||
github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw=
|
||||
github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg=
|
||||
github.com/oiweiwei/go-msrpc v1.2.12 h1:gaxnv1cyX3v9l+NNxyr4ONyvh/mnmh8Egel9r8zxNxE=
|
||||
github.com/oiweiwei/go-msrpc v1.2.12/go.mod h1:T6/ENmAoD1nYCr3NW8PS8AjIX0tZEAL7yO0tsejtK18=
|
||||
github.com/oiweiwei/go-smb2.fork v1.0.0 h1:xHq/eYPM8hQEO/nwCez8YwHWHC8mlcsgw/Neu52fPN4=
|
||||
@@ -798,8 +812,6 @@ github.com/ory/dockertest/v3 v3.12.0 h1:3oV9d0sDzlSQfHtIaB5k6ghUCVMVLpAY8hwrqoCy
|
||||
github.com/ory/dockertest/v3 v3.12.0/go.mod h1:aKNDTva3cp8dwOWwb9cWuX84aH5akkxXRvO7KCwWVjE=
|
||||
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
|
||||
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
|
||||
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
|
||||
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
|
||||
github.com/pierrec/lz4/v4 v4.1.23 h1:oJE7T90aYBGtFNrI8+KbETnPymobAhzRrR8Mu8n1yfU=
|
||||
github.com/pierrec/lz4/v4 v4.1.23/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
|
||||
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
|
||||
@@ -810,6 +822,8 @@ github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
|
||||
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
@@ -821,24 +835,22 @@ github.com/projectdiscovery/asnmap v1.1.1 h1:ImJiKIaACOT7HPx4Pabb5dksolzaFYsD1kI
|
||||
github.com/projectdiscovery/asnmap v1.1.1/go.mod h1:QT7jt9nQanj+Ucjr9BqGr1Q2veCCKSAVyUzLXfEcQ60=
|
||||
github.com/projectdiscovery/blackrock v0.0.1 h1:lHQqhaaEFjgf5WkuItbpeCZv2DUIE45k0VbGJyft6LQ=
|
||||
github.com/projectdiscovery/blackrock v0.0.1/go.mod h1:ANUtjDfaVrqB453bzToU+YB4cUbvBRpLvEwoWIwlTss=
|
||||
github.com/projectdiscovery/cdncheck v1.2.43 h1:Adt5FyMIJy3hT2/X4QKoIcuRxY27ISgEryx8wb8zCRw=
|
||||
github.com/projectdiscovery/cdncheck v1.2.43/go.mod h1:9oE9KKxCSHNvUf0UaMeqqUwWpC38FkNaTll0ScIBT3w=
|
||||
github.com/projectdiscovery/cdncheck v1.2.46 h1:ELtTCaT5dLigd0FGmw6z+JuIpbFAr422hbgAFXMmKvY=
|
||||
github.com/projectdiscovery/cdncheck v1.2.46/go.mod h1:9oE9KKxCSHNvUf0UaMeqqUwWpC38FkNaTll0ScIBT3w=
|
||||
github.com/projectdiscovery/clistats v0.1.4 h1:kDnXoNxIdOvQElOF7k2Mt6XosGa5GbMKPtRXdPHMVzU=
|
||||
github.com/projectdiscovery/clistats v0.1.4/go.mod h1:hjJYNcUubk9T3cuFvA+JkLhZGjzYW50fkC48dqUAtbU=
|
||||
github.com/projectdiscovery/dsl v0.8.20 h1:CxWcKuoHFpOSS1kzqnbJuK5No/6qoRG8IzNDMnZ6c/M=
|
||||
github.com/projectdiscovery/dsl v0.8.20/go.mod h1:e1oHi7mxAxF+UhBhD5gOk90Ga6LQqvFea2voMO1E5D0=
|
||||
github.com/projectdiscovery/fastdialer v0.5.12 h1:VQpx/eJEn226doS38VoLTIsxKjXQ67XFngKL3rjhsuQ=
|
||||
github.com/projectdiscovery/fastdialer v0.5.12/go.mod h1:OYgpbp84/hjp3nVyuKPf/TygvjSo0O9MyNU286/agWU=
|
||||
github.com/projectdiscovery/fastdialer v0.5.14 h1:83hkWNnbWq8IxDM65h1CCmHf/GoVj1fpwbR+wACiBhA=
|
||||
github.com/projectdiscovery/fastdialer v0.5.14/go.mod h1:+QaDuVAsvPk4VPSk9aVQCdZzHVbtI9+2BeeFhQUyebo=
|
||||
github.com/projectdiscovery/fasttemplate v0.0.2 h1:h2cISk5xDhlJEinlBQS6RRx0vOlOirB2y3Yu4PJzpiA=
|
||||
github.com/projectdiscovery/fasttemplate v0.0.2/go.mod h1:XYWWVMxnItd+r0GbjA1GCsUopMw1/XusuQxdyAIHMCw=
|
||||
github.com/projectdiscovery/freeport v0.0.7 h1:Q6uXo/j8SaV/GlAHkEYQi8WQoPXyJWxyspx+aFmz9Qk=
|
||||
github.com/projectdiscovery/freeport v0.0.7/go.mod h1:cOhWKvNBe9xM6dFJ3RrrLvJ5vXx2NQ36SecuwjenV2k=
|
||||
github.com/projectdiscovery/gcache v0.0.0-20241015120333-12546c6e3f4c h1:s+lLAlrOrgwlPZQ9DFqNw+kia2nteKnJZ2Ek313yoUc=
|
||||
github.com/projectdiscovery/gcache v0.0.0-20241015120333-12546c6e3f4c/go.mod h1:rN35/D3lVx2YDeENFFz06uj8j3XIqK1Ym9XcISF5fzg=
|
||||
github.com/projectdiscovery/go-smb2 v0.0.0-20240129202741-052cc450c6cb h1:rutG906Drtbpz4DwU5mhGIeOhRcktDH4cGQitGUMAsg=
|
||||
github.com/projectdiscovery/go-smb2 v0.0.0-20240129202741-052cc450c6cb/go.mod h1:FLjF1DmZ+POoGEiIQdWuYVwS++C/GwpX8YaCsTSm1RY=
|
||||
github.com/projectdiscovery/goflags v0.1.74 h1:n85uTRj5qMosm0PFBfsvOL24I7TdWRcWq/1GynhXS7c=
|
||||
github.com/projectdiscovery/goflags v0.1.74/go.mod h1:UMc9/7dFz2oln+10tv6cy+7WZKTHf9UGhaNkF95emh4=
|
||||
github.com/projectdiscovery/goflags v0.1.75 h1:njEBnyueQaFa2ptWxbyl9zX0OClNdlN2AzZveNHiBOs=
|
||||
github.com/projectdiscovery/goflags v0.1.75/go.mod h1:7nAP1r2Dqgn/rwmOE3EWbZWUCEJKNIhVSBGpuzJAIns=
|
||||
github.com/projectdiscovery/goja v0.0.0-20260618133720-acb73e419534 h1:hYd1zQA/dxO2ASyQ6Re73TcJkW1LjLQvt4+86Hxefz8=
|
||||
github.com/projectdiscovery/goja v0.0.0-20260618133720-acb73e419534/go.mod h1:SO0AP+uKfYeeoR6jyVH/PKRYJE/f5gJrPMAM00iGEMc=
|
||||
github.com/projectdiscovery/goja_nodejs v0.0.0-20260618132410-8519f75f703d h1:fqqH9LHpN2WDz9QuxFrhKNxXSRtzk+Sa6jAhbB7tXcQ=
|
||||
@@ -853,8 +865,8 @@ github.com/projectdiscovery/gozero v0.1.1-0.20260530071156-fa1dad563d76 h1:AN70b
|
||||
github.com/projectdiscovery/gozero v0.1.1-0.20260530071156-fa1dad563d76/go.mod h1:cWHYnRXoYWHtTpOYyAp5laGYX8GH8ITUhgQaP8G/8FA=
|
||||
github.com/projectdiscovery/hmap v0.0.101 h1:zXM6YtLmsn8Q0CUUw8QavhqWmiQYwaw+/U679Rr00pc=
|
||||
github.com/projectdiscovery/hmap v0.0.101/go.mod h1:w6N9/a5H8kvyx53AhtPDUWe5Qq3D6NBDPA23glHpa/Q=
|
||||
github.com/projectdiscovery/httpx v1.9.0 h1:5yn4ik/LqZ+v3MLgU7+CZJQyND9osW9NmZ3squylxsc=
|
||||
github.com/projectdiscovery/httpx v1.9.0/go.mod h1:jGTRyUHddo2WyK4klWIwQXgGF1Lu39XVyzlue4H3pX8=
|
||||
github.com/projectdiscovery/httpx v1.10.0 h1:sZvfeJDeX4eJfB5Oh29My4gOHmLfvpCt2wCqhargJrE=
|
||||
github.com/projectdiscovery/httpx v1.10.0/go.mod h1:gfdNnFznLLlAgAgSQlYjUeEVvYzKrNV0jO5Qyo5w+FI=
|
||||
github.com/projectdiscovery/interactsh v1.3.1 h1:5HzeVGVCAX/cjTguJ+7ClOmML5r97Ty7op9s+/F7BiM=
|
||||
github.com/projectdiscovery/interactsh v1.3.1/go.mod h1:MXQ11EoBPROb4bEw+WP9e4DX4fMhrpS6EwfMfZomBsw=
|
||||
github.com/projectdiscovery/ldapserver v1.0.2-0.20240219154113-dcc758ebc0cb h1:MGtI4oE12ruWv11ZlPXXd7hl/uAaQZrFvrIDYDeVMd8=
|
||||
@@ -865,18 +877,18 @@ github.com/projectdiscovery/mapcidr v1.1.97 h1:7FkxNNVXp+m1rIu5Nv/2SrF9k4+LwP8Qu
|
||||
github.com/projectdiscovery/mapcidr v1.1.97/go.mod h1:9dgTJh1SP02gYZdpzMjm6vtYFkEHQHoTyaVNvaeJ7lA=
|
||||
github.com/projectdiscovery/n3iwf v0.0.0-20230523120440-b8cd232ff1f5 h1:L/e8z8yw1pfT6bg35NiN7yd1XKtJap5Nk6lMwQ0RNi8=
|
||||
github.com/projectdiscovery/n3iwf v0.0.0-20230523120440-b8cd232ff1f5/go.mod h1:pGW2ncnTxTxHtP9wzcIJAB+3/NMp6IiuQWd2NK7K+oc=
|
||||
github.com/projectdiscovery/networkpolicy v0.1.42 h1:/d5vriH8fDMoEaFCSD1nuCWs/ki3dft+CDqQnYrKyLg=
|
||||
github.com/projectdiscovery/networkpolicy v0.1.42/go.mod h1:9ULLaMbdv9UnT0C5rmuK4nIwYs0o776xMnkPUb8TtaE=
|
||||
github.com/projectdiscovery/networkpolicy v0.1.44 h1:+fnpYNQVH9mYZY7VrsTqdL4YJGVZSA8XLAlRHBMl5QE=
|
||||
github.com/projectdiscovery/networkpolicy v0.1.44/go.mod h1:q1KeQiHchXdElScEMWc5mShWNRNoJXI5koRnVaX6Qh8=
|
||||
github.com/projectdiscovery/ratelimit v0.0.88 h1:AcurW9aLRzlEyPe9kSjnOpr3XzLMWTpiWAlW/w73ALU=
|
||||
github.com/projectdiscovery/ratelimit v0.0.88/go.mod h1:CU1s+68UUG2mctSl2wi32/DHLJA6TMg+4rxgP59LfVk=
|
||||
github.com/projectdiscovery/rawhttp v0.1.90 h1:LOSZ6PUH08tnKmWsIwvwv1Z/4zkiYKYOSZ6n+8RFKtw=
|
||||
github.com/projectdiscovery/rawhttp v0.1.90/go.mod h1:VZYAM25UI/wVB3URZ95ZaftgOnsbphxyAw/XnQRRz4Y=
|
||||
github.com/projectdiscovery/rawhttp v0.1.91 h1:6EIhZxCkBn27kJAl47FieCFCe4H2ZdbhoBVj0J6ZCVY=
|
||||
github.com/projectdiscovery/rawhttp v0.1.91/go.mod h1:VZYAM25UI/wVB3URZ95ZaftgOnsbphxyAw/XnQRRz4Y=
|
||||
github.com/projectdiscovery/rdap v0.9.0 h1:wPhHx5pQ2QI+WGhyNb2PjhTl0NtB39Nk7YFZ9cp8ZGA=
|
||||
github.com/projectdiscovery/rdap v0.9.0/go.mod h1:zk4yrJFQ2Hy36Aqk+DvotYQxYAeALaCJ5ORySkff36Q=
|
||||
github.com/projectdiscovery/retryabledns v1.0.115 h1:RKV63FNIznFHUoawg/1hs53pVH3wqPtFhwstCuxVSoA=
|
||||
github.com/projectdiscovery/retryabledns v1.0.115/go.mod h1:+fEMWoPigw+M0lGNKY7AZ+g8FIgj+4sONjsinMmeL3k=
|
||||
github.com/projectdiscovery/retryablehttp-go v1.3.17 h1:wxtD3Cw97czaz1Y+EKQYKkWtBwINikop7J4e1sgBTac=
|
||||
github.com/projectdiscovery/retryablehttp-go v1.3.17/go.mod h1:lcOGgfvP38rnA6bLskzj1dP8n5dw24peLjuKUlHfxVU=
|
||||
github.com/projectdiscovery/retryablehttp-go v1.3.21 h1:HytR9e8AfhIF/4xUo6szqg213a7pOq5tE3kpNZCyYwI=
|
||||
github.com/projectdiscovery/retryablehttp-go v1.3.21/go.mod h1:0SCELxSKpkqHTy9pcZDZ2ry+5djp4geKpEURJ1uSB24=
|
||||
github.com/projectdiscovery/sarif v0.1.0 h1:O541T+a448nSJsmIMnXXSOeDQEzpnCAYvRfe0eG5h74=
|
||||
github.com/projectdiscovery/sarif v0.1.0/go.mod h1:LBC+reM3bkI3qIIhE0rZaINaYX6VG+En6u2hHa5mA7E=
|
||||
github.com/projectdiscovery/stringsutil v0.0.2 h1:uzmw3IVLJSMW1kEg8eCStG/cGbYYZAja8BH3LqqJXMA=
|
||||
@@ -889,8 +901,8 @@ github.com/projectdiscovery/useragent v0.0.108 h1:fb+uLuFJvC+MHZjCtxQJxtvp1X6A8n
|
||||
github.com/projectdiscovery/useragent v0.0.108/go.mod h1:XdNRrlvtDmYfVL1Oybat4uMe+W6cLwsK9S18ond17CI=
|
||||
github.com/projectdiscovery/utils v0.11.1 h1:PWj1KjIASxt8icxommH72C0TQqNOvGkcSODRkiq0SQw=
|
||||
github.com/projectdiscovery/utils v0.11.1/go.mod h1:yktGrHGk2CTjNiccXovnvGrLHX9sV2bqz9nSnbA3V8M=
|
||||
github.com/projectdiscovery/wappalyzergo v0.2.88 h1:E1wZvuvArnmygt8hlF156vnEYBWAc4jdpr/b/4Fi0cY=
|
||||
github.com/projectdiscovery/wappalyzergo v0.2.88/go.mod h1:gMH0o5lBp65sKMwHx/tuUdOtW2RjodC6Ti+9QDsYMkY=
|
||||
github.com/projectdiscovery/wappalyzergo v0.2.91 h1:pjbEOCJKmxfEG5xK3WnUNY4SuYjPoYUuAUxa3F1QN1Y=
|
||||
github.com/projectdiscovery/wappalyzergo v0.2.91/go.mod h1:gMH0o5lBp65sKMwHx/tuUdOtW2RjodC6Ti+9QDsYMkY=
|
||||
github.com/projectdiscovery/yamldoc-go v1.0.6 h1:GCEdIRlQjDux28xTXKszM7n3jlMf152d5nqVpVoetas=
|
||||
github.com/projectdiscovery/yamldoc-go v1.0.6/go.mod h1:R5lWrNzP+7Oyn77NDVPnBsxx2/FyQZBBkIAaSaCQFxw=
|
||||
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
@@ -937,6 +949,8 @@ github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6
|
||||
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
|
||||
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA=
|
||||
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
|
||||
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||
github.com/sashabaranov/go-openai v1.37.0 h1:hQQowgYm4OXJ1Z/wTrE+XZaO20BYsL0R3uRPSpfNZkY=
|
||||
github.com/sashabaranov/go-openai v1.37.0/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
|
||||
github.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c=
|
||||
@@ -978,6 +992,8 @@ github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
|
||||
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
@@ -1113,8 +1129,8 @@ github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
|
||||
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark v1.7.17 h1:p36OVWwRb246iHxA/U4p8OPEpOTESm4n+g+8t0EE5uA=
|
||||
github.com/yuin/goldmark v1.7.17/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
|
||||
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
@@ -1156,16 +1172,16 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||
@@ -1206,8 +1222,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
|
||||
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
@@ -1245,8 +1261,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
|
||||
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -1296,8 +1312,8 @@ 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.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
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-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
@@ -1307,8 +1323,8 @@ golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ
|
||||
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
|
||||
golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I=
|
||||
golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -1325,8 +1341,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.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
|
||||
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
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-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -1393,8 +1409,8 @@ 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.22.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.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
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-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
@@ -1408,8 +1424,8 @@ 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.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
|
||||
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
|
||||
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
|
||||
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
|
||||
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.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
@@ -1427,8 +1443,8 @@ 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.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
|
||||
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
@@ -1480,12 +1496,14 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
|
||||
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
|
||||
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
|
||||
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
|
||||
@@ -1538,6 +1556,8 @@ google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7Fc
|
||||
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
|
||||
@@ -1550,6 +1570,8 @@ google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKa
|
||||
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
|
||||
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
|
||||
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
|
||||
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
|
||||
@@ -1,556 +0,0 @@
|
||||
package fuzzplayground
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// This file turns the playground into a small but realistic "modern app" auth
|
||||
// test bench. It serves a range of login styles seen in the wild — classic
|
||||
// server-rendered forms, JS/SPA-rendered forms, username-first multi-step flows,
|
||||
// SPA logins that stash a JWT in web storage, CSRF-protected forms, a
|
||||
// disabled-until-valid submit button (a real regression class), and an
|
||||
// OAuth-style redirect dance — plus protected endpoints (whoami) and an
|
||||
// authenticated, fuzzable endpoint. It lets us exercise the auto-login /
|
||||
// recording / capture engine and authenticated scanning entirely in-repo,
|
||||
// without external apps.
|
||||
//
|
||||
// Credentials are fixed and shared across every style.
|
||||
const (
|
||||
AuthUsername = "[email protected]"
|
||||
AuthPassword = "nuclei-rocks"
|
||||
authSessionCookie = "PSESSION"
|
||||
)
|
||||
|
||||
// authStore is a tiny in-memory session/CSRF store for the auth playground.
|
||||
type authStore struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]string // session token -> username
|
||||
csrf map[string]struct{}
|
||||
}
|
||||
|
||||
func newAuthStore() *authStore {
|
||||
// Pre-seed the SPA-token style's fixed session so the JWT it stashes in web
|
||||
// storage also authenticates protected APIs (bearer path).
|
||||
return &authStore{
|
||||
sessions: map[string]string{"spa-static-session": AuthUsername},
|
||||
csrf: map[string]struct{}{},
|
||||
}
|
||||
}
|
||||
|
||||
func randToken() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// jwtFor returns a JWT-shaped token (eyJ...) so token-regex extraction has a
|
||||
// realistic target. It is not a real signed JWT; the playground treats the
|
||||
// session token embedded in it as the source of truth.
|
||||
func jwtFor(session string) string {
|
||||
// header.payload.signature where payload carries the opaque session id
|
||||
return "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." +
|
||||
hex.EncodeToString([]byte(session)) + ".c2lnbmF0dXJl"
|
||||
}
|
||||
|
||||
func (s *authStore) issue(username string) (session, jwt string) {
|
||||
session = randToken()
|
||||
s.mu.Lock()
|
||||
s.sessions[session] = username
|
||||
s.mu.Unlock()
|
||||
return session, jwtFor(session)
|
||||
}
|
||||
|
||||
func (s *authStore) userFor(session string) (string, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
u, ok := s.sessions[session]
|
||||
return u, ok
|
||||
}
|
||||
|
||||
func (s *authStore) issueCSRF() string {
|
||||
tok := randToken()
|
||||
s.mu.Lock()
|
||||
s.csrf[tok] = struct{}{}
|
||||
s.mu.Unlock()
|
||||
return tok
|
||||
}
|
||||
|
||||
func (s *authStore) consumeCSRF(tok string) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.csrf[tok]; ok {
|
||||
delete(s.csrf, tok)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// authenticatedUser resolves the caller's identity from either the session
|
||||
// cookie or a bearer token (the JWT carries the hex-encoded session id).
|
||||
func (s *authStore) authenticatedUser(r *http.Request) (string, bool) {
|
||||
if c, err := r.Cookie(authSessionCookie); err == nil && c.Value != "" {
|
||||
if u, ok := s.userFor(c.Value); ok {
|
||||
return u, true
|
||||
}
|
||||
}
|
||||
auth := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(auth, "Bearer ") {
|
||||
if sess := sessionFromJWT(strings.TrimPrefix(auth, "Bearer ")); sess != "" {
|
||||
if u, ok := s.userFor(sess); ok {
|
||||
return u, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// sessionFromJWT extracts the opaque session id embedded in a playground JWT.
|
||||
func sessionFromJWT(token string) string {
|
||||
parts := strings.Split(token, ".")
|
||||
if len(parts) != 3 {
|
||||
return ""
|
||||
}
|
||||
raw, err := hex.DecodeString(parts[1])
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func setSessionCookie(w http.ResponseWriter, session string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: authSessionCookie,
|
||||
Value: session,
|
||||
Path: "/",
|
||||
HttpOnly: true,
|
||||
})
|
||||
}
|
||||
|
||||
// registerAuthRoutes wires every login style and the protected endpoints onto a
|
||||
// per-server auth store.
|
||||
func registerAuthRoutes(mux *http.ServeMux) {
|
||||
st := newAuthStore()
|
||||
|
||||
// 1. Classic server-rendered form login (works with the static HTTP engine).
|
||||
mux.HandleFunc("GET /auth/form-login", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeHTML(w, http.StatusOK, formLoginPage("", ""))
|
||||
})
|
||||
mux.HandleFunc("POST /auth/form-login", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.FormValue("username") == AuthUsername && r.FormValue("password") == AuthPassword {
|
||||
session, _ := st.issue(AuthUsername)
|
||||
setSessionCookie(w, session)
|
||||
http.Redirect(w, r, "/auth/dashboard", http.StatusFound)
|
||||
return
|
||||
}
|
||||
writeHTML(w, http.StatusOK, formLoginPage(r.FormValue("username"), "Invalid credentials"))
|
||||
})
|
||||
|
||||
// 2. CSRF-protected server-rendered form: a hidden token must be echoed back.
|
||||
mux.HandleFunc("GET /auth/csrf-login", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeHTML(w, http.StatusOK, csrfLoginPage(st.issueCSRF(), ""))
|
||||
})
|
||||
mux.HandleFunc("POST /auth/csrf-login", func(w http.ResponseWriter, r *http.Request) {
|
||||
if !st.consumeCSRF(r.FormValue("csrf_token")) {
|
||||
writeHTML(w, http.StatusForbidden, csrfLoginPage(st.issueCSRF(), "Invalid CSRF token"))
|
||||
return
|
||||
}
|
||||
if r.FormValue("username") == AuthUsername && r.FormValue("password") == AuthPassword {
|
||||
session, _ := st.issue(AuthUsername)
|
||||
setSessionCookie(w, session)
|
||||
http.Redirect(w, r, "/auth/dashboard", http.StatusFound)
|
||||
return
|
||||
}
|
||||
writeHTML(w, http.StatusOK, csrfLoginPage(st.issueCSRF(), "Invalid credentials"))
|
||||
})
|
||||
|
||||
// 3. JS/SPA-rendered form: the raw HTML has no <form>; JS builds it and posts
|
||||
// via fetch. Only a real browser can log in here.
|
||||
mux.HandleFunc("GET /auth/spa-login", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeHTML(w, http.StatusOK, spaLoginPage)
|
||||
})
|
||||
mux.HandleFunc("POST /auth/api/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad request"})
|
||||
return
|
||||
}
|
||||
if body.Username == AuthUsername && body.Password == AuthPassword {
|
||||
session, jwt := st.issue(AuthUsername)
|
||||
setSessionCookie(w, session)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"token": jwt})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
|
||||
})
|
||||
|
||||
// 4. Username-first multi-step flow (Google/Microsoft-style): password field
|
||||
// is revealed only after the "Next" button.
|
||||
mux.HandleFunc("GET /auth/multistep-login", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeHTML(w, http.StatusOK, multiStepLoginPage)
|
||||
})
|
||||
|
||||
// 5. Disabled-until-valid submit button — a real regression class: the submit
|
||||
// stays disabled until JS marks the form valid, so a naive immediate click
|
||||
// hangs. The engine must settle/fall back to Enter.
|
||||
mux.HandleFunc("GET /auth/strict-login", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeHTML(w, http.StatusOK, strictLoginPage)
|
||||
})
|
||||
|
||||
// 6. SPA login that stores a JWT in localStorage (no cookie at all).
|
||||
mux.HandleFunc("GET /auth/spa-token-login", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeHTML(w, http.StatusOK, spaTokenLoginPage)
|
||||
})
|
||||
|
||||
// 7. OAuth-style redirect flow: authorize -> (auto consent) -> callback sets
|
||||
// the session and redirects to the dashboard.
|
||||
mux.HandleFunc("GET /auth/oauth/authorize", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/auth/oauth/callback?code="+randToken(), http.StatusFound)
|
||||
})
|
||||
mux.HandleFunc("GET /auth/oauth/callback", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Query().Get("code") == "" {
|
||||
writeString(w, http.StatusBadRequest, "missing code")
|
||||
return
|
||||
}
|
||||
session, _ := st.issue(AuthUsername)
|
||||
setSessionCookie(w, session)
|
||||
http.Redirect(w, r, "/auth/dashboard", http.StatusFound)
|
||||
})
|
||||
|
||||
// 8. Multi-form page: a decoy search form (no password) precedes the real
|
||||
// login form. The detector must score forms and pick the credential one
|
||||
// rather than blindly taking the first <form>.
|
||||
mux.HandleFunc("GET /auth/multiform-login", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeHTML(w, http.StatusOK, multiFormLoginPage)
|
||||
})
|
||||
|
||||
// 9. JS-set cookie: login succeeds via XHR that returns the session id in the
|
||||
// body (no Set-Cookie), and client JS writes document.cookie. The headless
|
||||
// engine must capture cookies from the browser jar, not just Set-Cookie.
|
||||
mux.HandleFunc("GET /auth/jscookie-login", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeHTML(w, http.StatusOK, jsCookieLoginPage)
|
||||
})
|
||||
mux.HandleFunc("POST /auth/api/login-jsbody", func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad request"})
|
||||
return
|
||||
}
|
||||
if body.Username == AuthUsername && body.Password == AuthPassword {
|
||||
session, _ := st.issue(AuthUsername)
|
||||
// Deliberately no Set-Cookie: the client sets the cookie via JS.
|
||||
writeJSON(w, http.StatusOK, map[string]string{"session": session})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
|
||||
})
|
||||
|
||||
// 10. Delayed render: the login form is injected after an async tick, so an
|
||||
// engine that probes for the password field immediately would miss it and
|
||||
// must wait for it to appear.
|
||||
mux.HandleFunc("GET /auth/delayed-login", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeHTML(w, http.StatusOK, delayedLoginPage)
|
||||
})
|
||||
|
||||
// 11. Header-token login: a server-rendered form whose POST returns the
|
||||
// session token in a response header (no cookie), the token-in-header
|
||||
// API style. The engine must read the token from the header.
|
||||
mux.HandleFunc("GET /auth/header-token-login", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeHTML(w, http.StatusOK, headerTokenLoginPage(""))
|
||||
})
|
||||
mux.HandleFunc("POST /auth/header-token-login", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.FormValue("username") == AuthUsername && r.FormValue("password") == AuthPassword {
|
||||
_, jwt := st.issue(AuthUsername)
|
||||
// Token in a header, deliberately no Set-Cookie.
|
||||
w.Header().Set("X-Auth-Token", jwt)
|
||||
writeHTML(w, http.StatusOK, `<html><head><title>Dashboard</title></head><body><h1>Welcome, signed in</h1></body></html>`)
|
||||
return
|
||||
}
|
||||
writeHTML(w, http.StatusOK, headerTokenLoginPage("Invalid credentials"))
|
||||
})
|
||||
|
||||
// 12. SPA whose XHR login returns the token only in a response header (no
|
||||
// cookie, no body/storage token). Exercises the headless engine's
|
||||
// passive response-header interception.
|
||||
mux.HandleFunc("GET /auth/spa-header-token-login", func(w http.ResponseWriter, _ *http.Request) {
|
||||
writeHTML(w, http.StatusOK, spaHeaderTokenLoginPage)
|
||||
})
|
||||
mux.HandleFunc("POST /auth/api/login-header", func(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad request"})
|
||||
return
|
||||
}
|
||||
if body.Username == AuthUsername && body.Password == AuthPassword {
|
||||
_, jwt := st.issue(AuthUsername)
|
||||
w.Header().Set("X-Auth-Token", jwt)
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
|
||||
})
|
||||
|
||||
// Protected landing page: the login-success heuristic relies on the final
|
||||
// page having no password field, which this page satisfies.
|
||||
mux.HandleFunc("GET /auth/dashboard", func(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := st.authenticatedUser(r)
|
||||
if !ok {
|
||||
http.Redirect(w, r, "/auth/form-login", http.StatusFound)
|
||||
return
|
||||
}
|
||||
writeHTML(w, http.StatusOK, fmt.Sprintf(`<html><head><title>Dashboard</title></head>
|
||||
<body><h1>Welcome, %s</h1><a href="/auth/logout">Logout</a></body></html>`, html.EscapeString(user)))
|
||||
})
|
||||
|
||||
// whoami: protected API used to assert a captured session authenticates.
|
||||
mux.HandleFunc("GET /auth/whoami", func(w http.ResponseWriter, r *http.Request) {
|
||||
user, ok := st.authenticatedUser(r)
|
||||
if !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"user": user})
|
||||
})
|
||||
|
||||
// Authenticated, fuzzable endpoint: only reachable with a valid session, and
|
||||
// reflects q without sanitization (reflected XSS) so authenticated fuzzing
|
||||
// has a genuine target behind the login wall.
|
||||
mux.HandleFunc("GET /auth/api/search", func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := st.authenticatedUser(r); !ok {
|
||||
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
|
||||
return
|
||||
}
|
||||
q := r.URL.Query().Get("q")
|
||||
writeHTML(w, http.StatusOK, fmt.Sprintf("<html><body><div>results for: %s</div></body></html>", q))
|
||||
})
|
||||
|
||||
mux.HandleFunc("GET /auth/logout", func(w http.ResponseWriter, r *http.Request) {
|
||||
if ck, err := r.Cookie(authSessionCookie); err == nil {
|
||||
st.mu.Lock()
|
||||
delete(st.sessions, ck.Value)
|
||||
st.mu.Unlock()
|
||||
}
|
||||
setSessionCookie(w, "")
|
||||
http.Redirect(w, r, "/auth/form-login", http.StatusFound)
|
||||
})
|
||||
}
|
||||
|
||||
// --- Login page templates --------------------------------------------------
|
||||
|
||||
func formLoginPage(username, errMsg string) string {
|
||||
var banner string
|
||||
if errMsg != "" {
|
||||
banner = fmt.Sprintf(`<p class="error">%s</p>`, html.EscapeString(errMsg))
|
||||
}
|
||||
return fmt.Sprintf(`<html><head><title>Login</title></head><body>
|
||||
<h1>Sign in</h1>%s
|
||||
<form method="post" action="/auth/form-login">
|
||||
<input type="text" name="username" placeholder="Email" value="%s">
|
||||
<input type="password" name="password" placeholder="Password">
|
||||
<button type="submit">Sign in</button>
|
||||
</form></body></html>`, banner, html.EscapeString(username))
|
||||
}
|
||||
|
||||
func csrfLoginPage(csrf, errMsg string) string {
|
||||
var banner string
|
||||
if errMsg != "" {
|
||||
banner = fmt.Sprintf(`<p class="error">%s</p>`, html.EscapeString(errMsg))
|
||||
}
|
||||
return fmt.Sprintf(`<html><head><title>Login</title></head><body>
|
||||
<h1>Sign in</h1>%s
|
||||
<form method="post" action="/auth/csrf-login">
|
||||
<input type="hidden" name="csrf_token" value="%s">
|
||||
<input type="text" name="username" placeholder="Email">
|
||||
<input type="password" name="password" placeholder="Password">
|
||||
<button type="submit">Sign in</button>
|
||||
</form></body></html>`, banner, html.EscapeString(csrf))
|
||||
}
|
||||
|
||||
// spaLoginPage builds the form entirely in JS (no <form> in the raw HTML) and
|
||||
// submits it via fetch, then redirects on success.
|
||||
const spaLoginPage = `<html><head><title>SPA Login</title></head><body>
|
||||
<div id="app"></div>
|
||||
<script>
|
||||
var app = document.getElementById('app');
|
||||
var email = document.createElement('input'); email.id = 'email'; email.type = 'email';
|
||||
var pass = document.createElement('input'); pass.id = 'password'; pass.type = 'password';
|
||||
var btn = document.createElement('button'); btn.id = 'submit'; btn.textContent = 'Sign in';
|
||||
app.appendChild(email); app.appendChild(pass); app.appendChild(btn);
|
||||
btn.addEventListener('click', function () {
|
||||
fetch('/auth/api/login', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({username: email.value, password: pass.value})
|
||||
}).then(function (r) { return r.json(); }).then(function (d) {
|
||||
if (d.token) { document.body.innerHTML = '<h1>Welcome, signed in</h1>'; }
|
||||
else { document.body.innerHTML += '<p>login failed</p>'; }
|
||||
});
|
||||
});
|
||||
</script></body></html>`
|
||||
|
||||
// multiStepLoginPage hides the password field until "Next" is clicked.
|
||||
const multiStepLoginPage = `<html><head><title>Multi-step Login</title></head><body>
|
||||
<form id="f" method="post" action="/auth/form-login">
|
||||
<input type="text" name="username" id="username" placeholder="Email">
|
||||
<button type="button" id="next">Next</button>
|
||||
<div id="pwwrap" style="display:none">
|
||||
<input type="password" name="password" id="password" placeholder="Password">
|
||||
<button type="submit" id="submit">Sign in</button>
|
||||
</div>
|
||||
</form>
|
||||
<script>
|
||||
document.getElementById('next').addEventListener('click', function () {
|
||||
document.getElementById('pwwrap').style.display = 'block';
|
||||
});
|
||||
</script></body></html>`
|
||||
|
||||
// strictLoginPage keeps the submit button disabled until both fields are
|
||||
// non-empty, reproducing the disabled-until-valid regression class.
|
||||
const strictLoginPage = `<html><head><title>Strict Login</title></head><body>
|
||||
<form method="post" action="/auth/form-login">
|
||||
<input type="text" name="username" id="username" placeholder="Email">
|
||||
<input type="password" name="password" id="password" placeholder="Password">
|
||||
<button type="submit" id="submit" disabled>Sign in</button>
|
||||
</form>
|
||||
<script>
|
||||
var u = document.getElementById('username'), p = document.getElementById('password'), b = document.getElementById('submit');
|
||||
function validate() { b.disabled = !(u.value.length > 0 && p.value.length > 0); }
|
||||
u.addEventListener('input', validate); p.addEventListener('input', validate);
|
||||
</script></body></html>`
|
||||
|
||||
// spaTokenLoginPage validates client-side and stores a JWT in localStorage with
|
||||
// no cookie, the canonical token-in-web-storage case.
|
||||
var spaTokenLoginPage = fmt.Sprintf(`<html><head><title>SPA Token Login</title></head><body>
|
||||
<form id="f">
|
||||
<input type="email" name="username" id="email" placeholder="Email">
|
||||
<input type="password" name="password" id="password" placeholder="Password">
|
||||
<button type="submit" id="submit">Sign in</button>
|
||||
</form>
|
||||
<script>
|
||||
document.getElementById('f').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var u = document.getElementById('email').value;
|
||||
var p = document.getElementById('password').value;
|
||||
if (u === %q && p === %q) {
|
||||
window.localStorage.setItem('access_token', %q);
|
||||
window.sessionStorage.setItem('uid', '1');
|
||||
document.body.innerHTML = '<h1>Welcome, signed in</h1>';
|
||||
} else {
|
||||
document.body.innerHTML += '<p>login failed</p>';
|
||||
}
|
||||
});
|
||||
</script></body></html>`, AuthUsername, AuthPassword, jwtFor("spa-static-session"))
|
||||
|
||||
// multiFormLoginPage places a decoy search form (no password field) before the
|
||||
// real login form to exercise best-form selection over first-form selection.
|
||||
const multiFormLoginPage = `<html><head><title>Multi-form Login</title></head><body>
|
||||
<form id="search" method="get" action="/auth/api/search">
|
||||
<input type="text" name="q" placeholder="Search">
|
||||
<button type="submit">Search</button>
|
||||
</form>
|
||||
<hr>
|
||||
<form id="login" method="post" action="/auth/form-login">
|
||||
<input type="text" name="username" placeholder="Email">
|
||||
<input type="password" name="password" placeholder="Password">
|
||||
<button type="submit">Sign in</button>
|
||||
</form></body></html>`
|
||||
|
||||
// jsCookieLoginPage logs in via XHR and sets the session cookie from JS
|
||||
// (document.cookie) rather than relying on a Set-Cookie response header.
|
||||
const jsCookieLoginPage = `<html><head><title>JS Cookie Login</title></head><body>
|
||||
<form id="f">
|
||||
<input type="email" name="username" id="email" placeholder="Email">
|
||||
<input type="password" name="password" id="password" placeholder="Password">
|
||||
<button type="submit" id="submit">Sign in</button>
|
||||
</form>
|
||||
<script>
|
||||
document.getElementById('f').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
fetch('/auth/api/login-jsbody', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
username: document.getElementById('email').value,
|
||||
password: document.getElementById('password').value
|
||||
})
|
||||
}).then(function (r) { return r.json(); }).then(function (d) {
|
||||
if (d.session) {
|
||||
document.cookie = 'PSESSION=' + d.session + '; path=/';
|
||||
document.body.innerHTML = '<h1>Welcome, signed in</h1>';
|
||||
} else {
|
||||
document.body.innerHTML += '<p>login failed</p>';
|
||||
}
|
||||
});
|
||||
});
|
||||
</script></body></html>`
|
||||
|
||||
// headerTokenLoginPage is a classic server-rendered form whose POST returns the
|
||||
// session token in a response header rather than a cookie or body.
|
||||
func headerTokenLoginPage(errMsg string) string {
|
||||
var banner string
|
||||
if errMsg != "" {
|
||||
banner = fmt.Sprintf(`<p class="error">%s</p>`, html.EscapeString(errMsg))
|
||||
}
|
||||
return fmt.Sprintf(`<html><head><title>Header Token Login</title></head><body>
|
||||
<h1>Sign in</h1>%s
|
||||
<form method="post" action="/auth/header-token-login">
|
||||
<input type="text" name="username" placeholder="Email">
|
||||
<input type="password" name="password" placeholder="Password">
|
||||
<button type="submit">Sign in</button>
|
||||
</form></body></html>`, banner)
|
||||
}
|
||||
|
||||
// spaHeaderTokenLoginPage logs in via XHR; the token comes back only in a
|
||||
// response header (the page never reads or stores it), so the session is
|
||||
// recoverable only by observing response headers.
|
||||
const spaHeaderTokenLoginPage = `<html><head><title>SPA Header Token Login</title></head><body>
|
||||
<form id="f">
|
||||
<input type="email" name="username" id="email" placeholder="Email">
|
||||
<input type="password" name="password" id="password" placeholder="Password">
|
||||
<button type="submit" id="submit">Sign in</button>
|
||||
</form>
|
||||
<script>
|
||||
document.getElementById('f').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
fetch('/auth/api/login-header', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
username: document.getElementById('email').value,
|
||||
password: document.getElementById('password').value
|
||||
})
|
||||
}).then(function (r) { return r.json(); }).then(function (d) {
|
||||
if (d.ok) { document.body.innerHTML = '<h1>Welcome, signed in</h1>'; }
|
||||
else { document.body.innerHTML += '<p>login failed</p>'; }
|
||||
});
|
||||
});
|
||||
</script></body></html>`
|
||||
|
||||
// delayedLoginPage injects the login form only after an async tick, simulating a
|
||||
// SPA that renders its form after bootstrapping.
|
||||
const delayedLoginPage = `<html><head><title>Delayed Login</title></head><body>
|
||||
<div id="app"><p>loading...</p></div>
|
||||
<script>
|
||||
setTimeout(function () {
|
||||
document.getElementById('app').innerHTML =
|
||||
'<form method="post" action="/auth/form-login">' +
|
||||
'<input type="text" name="username" placeholder="Email">' +
|
||||
'<input type="password" name="password" placeholder="Password">' +
|
||||
'<button type="submit">Sign in</button></form>';
|
||||
}, 700);
|
||||
</script></body></html>`
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -40,9 +39,6 @@ func GetPlaygroundServer() *PlaygroundServer {
|
||||
mux.HandleFunc("POST /user", patchUnsanitizedUserHandler)
|
||||
mux.HandleFunc("GET /blog/posts", getPostsHandler)
|
||||
|
||||
registerAnalyzerRoutes(mux)
|
||||
registerAuthRoutes(mux)
|
||||
|
||||
handler := recoverPlaygroundRequest(logPlaygroundRequest(mux))
|
||||
return &PlaygroundServer{handler: handler}
|
||||
}
|
||||
@@ -69,239 +65,6 @@ func (s *PlaygroundServer) Close() error {
|
||||
return s.server.Close()
|
||||
}
|
||||
|
||||
// registerAnalyzerRoutes wires a dedicated, analyzer-friendly test bench used by
|
||||
// the fuzzer "analyzer" templates. Each endpoint is genuinely vulnerable but
|
||||
// responds purely in-band and deterministically (no external network egress),
|
||||
// so the corresponding analyzer's generic probes reliably trigger detection in
|
||||
// CI. The query parameter is always "q" to keep the templates uniform.
|
||||
func registerAnalyzerRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /analyzer/sqli", analyzerSQLiHandler)
|
||||
mux.HandleFunc("GET /analyzer/ssti", analyzerSSTIHandler)
|
||||
mux.HandleFunc("GET /analyzer/lfi", analyzerLFIHandler)
|
||||
mux.HandleFunc("GET /analyzer/cmdi", analyzerCMDiHandler)
|
||||
mux.HandleFunc("GET /analyzer/ssrf", analyzerSSRFHandler)
|
||||
mux.HandleFunc("GET /analyzer/redirect", analyzerRedirectHandler)
|
||||
mux.HandleFunc("GET /analyzer/crlf", analyzerCRLFHandler)
|
||||
mux.HandleFunc("GET /analyzer/cors", analyzerCORSHandler)
|
||||
mux.HandleFunc("GET /analyzer/host-header", analyzerHostHeaderHandler)
|
||||
|
||||
// Benign counterparts: these reflect or echo input but are NOT vulnerable,
|
||||
// so the analyzers must NOT raise a finding against them (false-positive
|
||||
// guard at the CLI level).
|
||||
mux.HandleFunc("GET /analyzer/safe/reflect", analyzerSafeReflectHandler)
|
||||
mux.HandleFunc("GET /analyzer/safe/redirect", analyzerSafeRedirectHandler)
|
||||
mux.HandleFunc("GET /analyzer/safe/cors", analyzerSafeCORSHandler)
|
||||
mux.HandleFunc("GET /analyzer/safe/headers", analyzerSafeHeadersHandler)
|
||||
mux.HandleFunc("GET /analyzer/safe/host", analyzerSafeHostHandler)
|
||||
|
||||
// Non-query positions: prove the analyzers fuzz path / header / cookie / body
|
||||
// components through the real pipeline, not just query parameters.
|
||||
mux.HandleFunc("GET /analyzer/path/sqli/{id}", analyzerPathSQLiHandler)
|
||||
mux.HandleFunc("GET /analyzer/header/sqli", analyzerHeaderSQLiHandler)
|
||||
mux.HandleFunc("POST /analyzer/body/sqli", analyzerBodySQLiHandler)
|
||||
mux.HandleFunc("GET /analyzer/cookie/ssti", analyzerCookieSSTIHandler)
|
||||
}
|
||||
|
||||
// reArithmeticTemplate emulates a real template engine: it matches an arithmetic
|
||||
// multiplication wrapped in a delimiter pair (EL ${}, Jinja {{}}, #{}, *{},
|
||||
// Razor @(), ERB <%= %>, Smarty {}) and replaces the WHOLE delimited expression
|
||||
// with its product, preserving any surrounding text (e.g. the analyzer's
|
||||
// sentinels), exactly as a vulnerable engine would.
|
||||
var reArithmeticTemplate = regexp.MustCompile(`(?:\$\{|\{\{|#\{|\*\{|@\(|<%=|\{)\s*(\d+)\s*\*\s*(\d+)\s*(?:\}\}|%>|\}|\))`)
|
||||
|
||||
// analyzerCmdiSeparators are shell metacharacter sequences followed by the `id`
|
||||
// command that the cmdi analyzer injects; presence of any indicates the input
|
||||
// reached a shell.
|
||||
var analyzerCmdiSeparators = []string{";id", "|id", "||id", "&&id", "&id", "`id`", "$(id)", "\nid"}
|
||||
|
||||
// analyzerSQLiHandler is vulnerable to error-based SQLi via the real sqlite DB:
|
||||
// a quote in q breaks the query and surfaces a genuine "unrecognized token"
|
||||
// sqlite error, which the sqli_error analyzer fingerprints.
|
||||
func analyzerSQLiHandler(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query().Get("q")
|
||||
posts, err := getUnsanitizedPostsByLang(db, q)
|
||||
if err != nil {
|
||||
writeString(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, posts)
|
||||
}
|
||||
|
||||
// analyzerSSTIHandler evaluates arithmetic template expressions in q.
|
||||
func analyzerSSTIHandler(w http.ResponseWriter, r *http.Request) {
|
||||
out := reArithmeticTemplate.ReplaceAllStringFunc(r.URL.Query().Get("q"), func(m string) string {
|
||||
sub := reArithmeticTemplate.FindStringSubmatch(m)
|
||||
a, _ := strconv.Atoi(sub[1])
|
||||
b, _ := strconv.Atoi(sub[2])
|
||||
return strconv.Itoa(a * b)
|
||||
})
|
||||
writeHTML(w, http.StatusOK, fmt.Sprintf(bodyTemplate, out))
|
||||
}
|
||||
|
||||
// analyzerLFIHandler returns file contents for path-traversal payloads in q.
|
||||
func analyzerLFIHandler(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query().Get("q")
|
||||
switch {
|
||||
case strings.Contains(q, "etc/passwd"):
|
||||
writeString(w, http.StatusOK, "root:x:0:0:root:/root:/bin/bash\ndaemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\n")
|
||||
case strings.Contains(strings.ToLower(q), "win.ini"):
|
||||
writeString(w, http.StatusOK, "; for 16-bit app support\r\n[fonts]\r\n")
|
||||
default:
|
||||
writeString(w, http.StatusOK, "file not found")
|
||||
}
|
||||
}
|
||||
|
||||
// analyzerCMDiHandler simulates a shell that concatenates q: an injected
|
||||
// separator followed by `id` yields command output.
|
||||
func analyzerCMDiHandler(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query().Get("q")
|
||||
for _, sep := range analyzerCmdiSeparators {
|
||||
if strings.Contains(q, sep) {
|
||||
writeString(w, http.StatusOK, "uid=0(root) gid=0(root) groups=0(root)")
|
||||
return
|
||||
}
|
||||
}
|
||||
writeString(w, http.StatusOK, fmt.Sprintf("ping output for %s", q))
|
||||
}
|
||||
|
||||
// analyzerSSRFHandler simulates an in-band SSRF: requesting a cloud metadata
|
||||
// endpoint returns the (mock) instance identity document. It does NOT perform a
|
||||
// real outbound request, keeping the test hermetic.
|
||||
func analyzerSSRFHandler(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query().Get("q")
|
||||
if strings.Contains(q, "169.254.169.254") || strings.Contains(q, "metadata.google.internal") {
|
||||
writeString(w, http.StatusOK, `{"accountId":"123456789012","imageId":"ami-0abcd1234ef567890","instanceId":"i-0abcd1234ef567890","region":"us-east-1"}`)
|
||||
return
|
||||
}
|
||||
writeString(w, http.StatusOK, "fetched: nothing interesting")
|
||||
}
|
||||
|
||||
// analyzerRedirectHandler reflects q straight into the Location header.
|
||||
func analyzerRedirectHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, r.URL.Query().Get("q"), http.StatusFound)
|
||||
}
|
||||
|
||||
// analyzerCRLFHandler naively builds response headers from q, splitting on
|
||||
// newlines — a textbook response-splitting bug.
|
||||
func analyzerCRLFHandler(w http.ResponseWriter, r *http.Request) {
|
||||
for _, line := range strings.Split(r.URL.Query().Get("q"), "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
idx := strings.Index(line, ": ")
|
||||
if idx <= 0 || strings.ContainsAny(line[:idx], " \t") {
|
||||
continue
|
||||
}
|
||||
name, val := line[:idx], line[idx+2:]
|
||||
if strings.EqualFold(name, "Set-Cookie") {
|
||||
w.Header().Add("Set-Cookie", val)
|
||||
} else {
|
||||
w.Header().Set(name, val)
|
||||
}
|
||||
}
|
||||
writeString(w, http.StatusOK, "ok")
|
||||
}
|
||||
|
||||
// analyzerCORSHandler reflects an arbitrary Origin and allows credentials.
|
||||
func analyzerCORSHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if origin := r.Header.Get("Origin"); origin != "" {
|
||||
w.Header().Set("Access-Control-Allow-Origin", origin)
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
}
|
||||
writeString(w, http.StatusOK, "ok")
|
||||
}
|
||||
|
||||
// analyzerHostHeaderHandler reflects the (attacker-controlled) X-Forwarded-Host
|
||||
// into an absolute link in the body, without performing any outbound request.
|
||||
func analyzerHostHeaderHandler(w http.ResponseWriter, r *http.Request) {
|
||||
host := r.Header.Get("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = r.Host
|
||||
}
|
||||
writeHTML(w, http.StatusOK, fmt.Sprintf(`<a href="https://%s/reset?token=abc">reset</a>`, host))
|
||||
}
|
||||
|
||||
// --- Benign handlers (must not trigger any analyzer) -----------------------
|
||||
|
||||
// analyzerSafeReflectHandler reflects q verbatim with no evaluation, no DB, no
|
||||
// command execution and no file access; it is the benign counterpart for the
|
||||
// ssti, sqli, cmdi, lfi and ssrf analyzers.
|
||||
func analyzerSafeReflectHandler(w http.ResponseWriter, r *http.Request) {
|
||||
writeHTML(w, http.StatusOK, fmt.Sprintf(bodyTemplate, "you searched for: "+r.URL.Query().Get("q")))
|
||||
}
|
||||
|
||||
// analyzerSafeRedirectHandler always redirects to a fixed trusted location,
|
||||
// ignoring user input.
|
||||
func analyzerSafeRedirectHandler(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/home", http.StatusFound)
|
||||
}
|
||||
|
||||
// analyzerSafeCORSHandler only ever allows a single trusted origin.
|
||||
func analyzerSafeCORSHandler(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", "https://trusted.example.com")
|
||||
writeString(w, http.StatusOK, "ok")
|
||||
}
|
||||
|
||||
// analyzerSafeHeadersHandler returns static headers and never reflects input.
|
||||
func analyzerSafeHeadersHandler(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("X-Static", "constant")
|
||||
writeString(w, http.StatusOK, "ok")
|
||||
}
|
||||
|
||||
// analyzerSafeHostHandler always builds links from a fixed, trusted host.
|
||||
func analyzerSafeHostHandler(w http.ResponseWriter, _ *http.Request) {
|
||||
writeHTML(w, http.StatusOK, `<a href="https://app.example.com/reset?token=abc">reset</a>`)
|
||||
}
|
||||
|
||||
// --- Non-query position handlers -------------------------------------------
|
||||
|
||||
// sqliFromValue runs the value through the real sqlite query so a quote yields a
|
||||
// genuine "unrecognized token" error that the sqli_error analyzer fingerprints.
|
||||
func sqliFromValue(w http.ResponseWriter, value string) {
|
||||
posts, err := getUnsanitizedPostsByLang(db, value)
|
||||
if err != nil {
|
||||
writeString(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, posts)
|
||||
}
|
||||
|
||||
// analyzerPathSQLiHandler is error-based SQLi on a path segment ({id}).
|
||||
func analyzerPathSQLiHandler(w http.ResponseWriter, r *http.Request) {
|
||||
sqliFromValue(w, r.PathValue("id"))
|
||||
}
|
||||
|
||||
// analyzerHeaderSQLiHandler is error-based SQLi on the X-Search request header.
|
||||
func analyzerHeaderSQLiHandler(w http.ResponseWriter, r *http.Request) {
|
||||
sqliFromValue(w, r.Header.Get("X-Search"))
|
||||
}
|
||||
|
||||
// analyzerBodySQLiHandler is error-based SQLi on the JSON body "name" field.
|
||||
func analyzerBodySQLiHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var payload struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(body, &payload)
|
||||
sqliFromValue(w, payload.Name)
|
||||
}
|
||||
|
||||
// analyzerCookieSSTIHandler evaluates arithmetic template expressions found in
|
||||
// the "lang" cookie. SSTI (not SQLi) is used here because Go's cookie value
|
||||
// sanitization strips the quotes SQLi relies on, whereas SSTI payload
|
||||
// characters ({ } * $) are cookie-legal.
|
||||
func analyzerCookieSSTIHandler(w http.ResponseWriter, r *http.Request) {
|
||||
val := "en"
|
||||
if c, err := r.Cookie("lang"); err == nil {
|
||||
val = c.Value
|
||||
}
|
||||
out := reArithmeticTemplate.ReplaceAllStringFunc(val, func(m string) string {
|
||||
sub := reArithmeticTemplate.FindStringSubmatch(m)
|
||||
a, _ := strconv.Atoi(sub[1])
|
||||
b, _ := strconv.Atoi(sub[2])
|
||||
return strconv.Itoa(a * b)
|
||||
})
|
||||
writeHTML(w, http.StatusOK, fmt.Sprintf(bodyTemplate, "lang="+out))
|
||||
}
|
||||
|
||||
var bodyTemplate = `<html>
|
||||
<head>
|
||||
<title>Fuzz Playground</title>
|
||||
@@ -325,16 +88,6 @@ func indexHandler(w http.ResponseWriter, _ *http.Request) {
|
||||
<li><a href="/user/75/profile">User Profile Page SQLI (path parameter)</a></li>
|
||||
<li><a href="/user">POST on /user SQLI (body parameter)</a></li>
|
||||
<li><a href="/blog/posts">SQLI in cookie lang parameter value (eg. lang=en)</a></li>
|
||||
|
||||
<li><a href="/analyzer/sqli?q=en">Analyzer bench: SQLi (query)</a></li>
|
||||
<li><a href="/analyzer/ssti?q=test">Analyzer bench: SSTI (query)</a></li>
|
||||
<li><a href="/analyzer/lfi?q=home.txt">Analyzer bench: LFI (query)</a></li>
|
||||
<li><a href="/analyzer/cmdi?q=127.0.0.1">Analyzer bench: CMDi (query)</a></li>
|
||||
<li><a href="/analyzer/ssrf?q=https://example.com">Analyzer bench: SSRF (query)</a></li>
|
||||
<li><a href="/analyzer/redirect?q=/dashboard">Analyzer bench: Open Redirect (query)</a></li>
|
||||
<li><a href="/analyzer/crlf?q=/home">Analyzer bench: CRLF (query)</a></li>
|
||||
<li><a href="/analyzer/cors?q=x">Analyzer bench: CORS (query)</a></li>
|
||||
<li><a href="/analyzer/host-header?q=x">Analyzer bench: Host Header Injection (query)</a></li>
|
||||
|
||||
</ul>
|
||||
`))
|
||||
@@ -531,12 +284,6 @@ func writeJSON(w http.ResponseWriter, statusCode int, value interface{}) {
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
func writeString(w http.ResponseWriter, statusCode int, value string) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=UTF-8")
|
||||
w.WriteHeader(statusCode)
|
||||
_, _ = io.WriteString(w, value)
|
||||
}
|
||||
|
||||
func recoverPlaygroundRequest(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/projectdiscovery/gologger"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
|
||||
pdcpauth "github.com/projectdiscovery/utils/auth/pdcp"
|
||||
)
|
||||
|
||||
// AppendDirectoryInfo writes nuclei config/cache/PDCP directory paths.
|
||||
func AppendDirectoryInfo(w io.Writer) {
|
||||
_, _ = fmt.Fprintf(w, "Nuclei Config Directory: %s\n", config.DefaultConfig.GetConfigDir())
|
||||
_, _ = fmt.Fprintf(w, "Nuclei Cache Directory: %s\n", config.DefaultConfig.GetCacheDir())
|
||||
_, _ = fmt.Fprintf(w, "PDCP Directory: %s\n", pdcpauth.PDCPDir)
|
||||
}
|
||||
|
||||
// DirectoryInfo returns nuclei config/cache/PDCP directory paths as a string.
|
||||
func DirectoryInfo() string {
|
||||
var b strings.Builder
|
||||
AppendDirectoryInfo(&b)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// LogDirectoryInfo logs nuclei config/cache/PDCP directory paths at info level.
|
||||
func LogDirectoryInfo(logger *gologger.Logger) {
|
||||
if logger == nil {
|
||||
return
|
||||
}
|
||||
logger.Info().Msgf("Nuclei Config Directory: %s", config.DefaultConfig.GetConfigDir())
|
||||
logger.Info().Msgf("Nuclei Cache Directory: %s", config.DefaultConfig.GetCacheDir())
|
||||
logger.Info().Msgf("PDCP Directory: %s", pdcpauth.PDCPDir)
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
|
||||
pdcpauth "github.com/projectdiscovery/utils/auth/pdcp"
|
||||
)
|
||||
|
||||
func TestDirectoryInfoContainsKnownLabels(t *testing.T) {
|
||||
info := DirectoryInfo()
|
||||
cfg := config.DefaultConfig
|
||||
|
||||
require.Equal(t, strings.Join([]string{
|
||||
fmt.Sprintf("Nuclei Config Directory: %s", cfg.GetConfigDir()),
|
||||
fmt.Sprintf("Nuclei Cache Directory: %s", cfg.GetCacheDir()),
|
||||
fmt.Sprintf("PDCP Directory: %s", pdcpauth.PDCPDir),
|
||||
"",
|
||||
}, "\n"), info)
|
||||
require.Equal(t, 3, strings.Count(info, "\n"))
|
||||
}
|
||||
@@ -20,6 +20,7 @@ func DoHealthCheck(options *types.Options) string {
|
||||
fmt.Fprintf(&test, "Architecture: %s\n", runtime.GOARCH)
|
||||
fmt.Fprintf(&test, "Go Version: %s\n", runtime.Version())
|
||||
fmt.Fprintf(&test, "Compiler: %s\n", runtime.Compiler)
|
||||
AppendDirectoryInfo(&test)
|
||||
|
||||
var testResult string
|
||||
cfg := config.DefaultConfig
|
||||
|
||||
@@ -1,16 +1,11 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/authprovider/authx"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/authprovider/autologin"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/catalog"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/disk"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/loader"
|
||||
@@ -69,208 +64,6 @@ func GetAuthTmplStore(opts *types.Options, catalog catalog.Catalog, execOpts *pr
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// captureOnceStoreFromOptions launches a visible browser at -auth-login-url,
|
||||
// lets the user log in manually (pressing Enter in the terminal when done) and
|
||||
// builds an in-memory static auth store from the captured session (cookies and
|
||||
// an optional token), scoped to the login host. Unlike auto-login this captures
|
||||
// a point-in-time session with no automated re-authentication.
|
||||
func captureOnceStoreFromOptions(opts *types.Options) (*authx.Authx, error) {
|
||||
u, err := url.Parse(opts.AuthLoginURL)
|
||||
if err != nil {
|
||||
return nil, errkit.Wrap(err, "invalid -auth-login-url")
|
||||
}
|
||||
if u.Host == "" {
|
||||
return nil, errkit.New("capture-once: -auth-login-url is required and must include a host")
|
||||
}
|
||||
|
||||
rt := buildAutoLoginRuntimeOptions(opts)
|
||||
cfg := autologin.Config{
|
||||
LoginURL: opts.AuthLoginURL,
|
||||
Proxy: rt.Proxy,
|
||||
CDPEndpoint: rt.CDPEndpoint,
|
||||
UseInstalledChrome: rt.UseInstalledChrome,
|
||||
UserAgent: rt.UserAgent,
|
||||
CustomHeaders: rt.CustomHeaders,
|
||||
}
|
||||
|
||||
ready := func() error {
|
||||
fmt.Fprintf(os.Stderr, "[auth-capture] Log in to %s in the opened browser window, then press Enter here to capture the session...", opts.AuthLoginURL)
|
||||
reader := bufio.NewReader(os.Stdin)
|
||||
_, rerr := reader.ReadString('\n')
|
||||
return rerr
|
||||
}
|
||||
|
||||
session, err := autologin.CaptureOnce(context.Background(), cfg, ready)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return captureSessionToStore(session, u.Host)
|
||||
}
|
||||
|
||||
// captureSessionToStore converts a captured login session into a static auth
|
||||
// store scoped to host: cookies as a Cookie secret, an extracted token as a
|
||||
// BearerToken secret, and any web storage as a WebStorage secret (replayed into
|
||||
// headless scan pages). It is split out from captureOnceStoreFromOptions so the
|
||||
// session->store mapping is unit-testable without driving a browser.
|
||||
func captureSessionToStore(session *autologin.Session, host string) (*authx.Authx, error) {
|
||||
if session == nil {
|
||||
return nil, errkit.New("capture-once: nil session")
|
||||
}
|
||||
var secrets []authx.Secret
|
||||
if len(session.Cookies) > 0 {
|
||||
cookies := make([]authx.Cookie, 0, len(session.Cookies))
|
||||
for _, c := range session.Cookies {
|
||||
cookies = append(cookies, authx.Cookie{Key: c.Name, Value: c.Value})
|
||||
}
|
||||
secrets = append(secrets, authx.Secret{
|
||||
Type: string(authx.CookiesAuth),
|
||||
Domains: []string{host},
|
||||
Cookies: cookies,
|
||||
})
|
||||
}
|
||||
if session.Token != "" {
|
||||
secrets = append(secrets, authx.Secret{
|
||||
Type: string(authx.BearerTokenAuth),
|
||||
Domains: []string{host},
|
||||
Token: session.Token,
|
||||
})
|
||||
}
|
||||
if len(session.LocalStorage) > 0 || len(session.SessionStorage) > 0 {
|
||||
// Carry captured web storage so a token-in-web-storage SPA session
|
||||
// replays into headless scan pages (HTTP scans ignore this secret).
|
||||
secrets = append(secrets, authx.Secret{
|
||||
Type: string(authx.WebStorageAuth),
|
||||
Domains: []string{host},
|
||||
LocalStorage: session.LocalStorage,
|
||||
SessionStorage: session.SessionStorage,
|
||||
})
|
||||
}
|
||||
if len(secrets) == 0 {
|
||||
return nil, errkit.New("capture-once: captured session had no replayable cookies, token or web storage")
|
||||
}
|
||||
for i := range secrets {
|
||||
if err := secrets[i].Validate(); err != nil {
|
||||
return nil, errkit.Wrap(err, "capture-once: invalid captured secret")
|
||||
}
|
||||
}
|
||||
return &authx.Authx{ID: "cli-capture-once", Secrets: secrets}, nil
|
||||
}
|
||||
|
||||
// buildAutoLoginRuntimeOptions maps scan-level options into the auto-login
|
||||
// runtime options so a (headless) auto-login uses the same identity and network
|
||||
// path as the scan: user-agent and custom headers (-H), proxy, CDP endpoint and
|
||||
// Chrome settings.
|
||||
func buildAutoLoginRuntimeOptions(opts *types.Options) *authx.AutoLoginRuntimeOptions {
|
||||
// Mirror the proxy precedence used everywhere else in the codebase (HTTP
|
||||
// proxy first, SOCKS as fallback) so a SOCKS-proxied scan does not silently
|
||||
// bypass the proxy during auto-login.
|
||||
proxy := opts.AliveHttpProxy
|
||||
if proxy == "" {
|
||||
proxy = opts.AliveSocksProxy
|
||||
}
|
||||
rt := &authx.AutoLoginRuntimeOptions{
|
||||
Proxy: proxy,
|
||||
CDPEndpoint: opts.CDPEndpoint,
|
||||
UseInstalledChrome: opts.UseInstalledChrome,
|
||||
ShowBrowser: opts.ShowBrowser,
|
||||
}
|
||||
for _, header := range opts.CustomHeaders {
|
||||
parts := strings.SplitN(header, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
key := strings.TrimSpace(parts[0])
|
||||
value := strings.TrimSpace(parts[1])
|
||||
if key == "" || value == "" {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(key, "User-Agent") {
|
||||
rt.UserAgent = value
|
||||
continue
|
||||
}
|
||||
if rt.CustomHeaders == nil {
|
||||
rt.CustomHeaders = map[string]string{}
|
||||
}
|
||||
rt.CustomHeaders[key] = value
|
||||
}
|
||||
return rt
|
||||
}
|
||||
|
||||
// autoLoginStoreFromOptions assembles an in-memory Authx store containing a
|
||||
// single auto-login dynamic secret built from the -auth-login-url flag set. The
|
||||
// captured session is scoped to the login URL's host.
|
||||
func autoLoginStoreFromOptions(opts *types.Options) (*authx.Authx, error) {
|
||||
// The login URL may be supplied directly (-auth-login-url) or derived from a
|
||||
// recording's first navigate step; resolve the host scope from whichever is
|
||||
// available.
|
||||
loginURL := opts.AuthLoginURL
|
||||
if loginURL == "" && opts.AuthRecording != "" {
|
||||
steps, err := autologin.StepsFromRecordingFile(opts.AuthRecording, opts.AuthUsername, opts.AuthPassword)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
loginURL = autologin.FirstNavigateURL(steps)
|
||||
}
|
||||
u, err := url.Parse(loginURL)
|
||||
if err != nil {
|
||||
return nil, errkit.Wrap(err, "invalid auto-login url")
|
||||
}
|
||||
if u.Host == "" {
|
||||
return nil, errkit.New("auto-login: could not determine host (set -auth-login-url or provide a recording with a navigate step)")
|
||||
}
|
||||
reauthCodes, err := parseStatusCodes(opts.AuthReauthStatusCodes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &authx.Authx{
|
||||
ID: "cli-auto-login",
|
||||
Dynamic: []authx.Dynamic{
|
||||
{
|
||||
Secret: &authx.Secret{Domains: []string{u.Host}},
|
||||
// Session lifecycle: re-run the login on the configured expiry
|
||||
// status codes and/or once the session ages past the interval, so
|
||||
// a long scan does not silently continue with a dead session.
|
||||
RefreshInterval: opts.AuthRefreshInterval,
|
||||
ReauthStatusCodes: reauthCodes,
|
||||
AutoLogin: &authx.AutoLoginConfig{
|
||||
LoginURL: loginURL,
|
||||
Username: opts.AuthUsername,
|
||||
Password: opts.AuthPassword,
|
||||
UsernameField: opts.AuthUsernameField,
|
||||
PasswordField: opts.AuthPasswordField,
|
||||
Headless: opts.AuthHeadless,
|
||||
Recording: opts.AuthRecording,
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseStatusCodes parses a comma-separated list of HTTP status codes (e.g.
|
||||
// "401,403") into a slice of ints. An empty string yields a nil slice.
|
||||
func parseStatusCodes(raw string) ([]int, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var codes []int
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
code, err := strconv.Atoi(part)
|
||||
if err != nil {
|
||||
return nil, errkit.Wrapf(err, "invalid -auth-reauth-status-codes value %q", part)
|
||||
}
|
||||
if code < 100 || code > 599 {
|
||||
return nil, errkit.New("invalid -auth-reauth-status-codes value %q: must be a valid HTTP status code", part)
|
||||
}
|
||||
codes = append(codes, code)
|
||||
}
|
||||
return codes, nil
|
||||
}
|
||||
|
||||
// GetLazyAuthFetchCallback returns a lazy fetch callback for auth secrets
|
||||
func GetLazyAuthFetchCallback(opts *AuthLazyFetchOptions) authx.LazyFetchSecret {
|
||||
return func(d *authx.Dynamic) error {
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/authprovider/authx"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/authprovider/autologin"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCaptureSessionToStore(t *testing.T) {
|
||||
session := &autologin.Session{
|
||||
Cookies: []*http.Cookie{{Name: "session", Value: "sess-dave"}},
|
||||
Token: "eyJ.payload.sig",
|
||||
LocalStorage: map[string]string{"jwt": "eyJ.payload.sig"},
|
||||
SessionStorage: map[string]string{"csrf": "tok"},
|
||||
}
|
||||
store, err := captureSessionToStore(session, "app.example.com")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, store.Secrets, 3, "cookies, token and web storage must each map to a secret")
|
||||
|
||||
byType := map[string]authx.Secret{}
|
||||
for _, s := range store.Secrets {
|
||||
byType[s.Type] = s
|
||||
require.Equal(t, []string{"app.example.com"}, s.Domains, "every secret is scoped to the login host")
|
||||
require.NoError(t, s.Validate())
|
||||
}
|
||||
|
||||
require.Equal(t, "session", byType[string(authx.CookiesAuth)].Cookies[0].Key)
|
||||
require.Equal(t, "eyJ.payload.sig", byType[string(authx.BearerTokenAuth)].Token)
|
||||
|
||||
ws := byType[string(authx.WebStorageAuth)]
|
||||
require.Equal(t, "eyJ.payload.sig", ws.LocalStorage["jwt"])
|
||||
require.Equal(t, "tok", ws.SessionStorage["csrf"])
|
||||
|
||||
// The web storage secret must surface as a BrowserStorageProvider so the
|
||||
// headless engine can seed it into scanned pages.
|
||||
bsp, ok := ws.GetStrategy().(authx.BrowserStorageProvider)
|
||||
require.True(t, ok)
|
||||
local, sess := bsp.WebStorage()
|
||||
require.Equal(t, "eyJ.payload.sig", local["jwt"])
|
||||
require.Equal(t, "tok", sess["csrf"])
|
||||
}
|
||||
|
||||
func TestCaptureSessionToStore_StorageOnly(t *testing.T) {
|
||||
// A pure token-in-localStorage SPA: no cookies, no extracted token, only
|
||||
// web storage — it must still produce a usable store.
|
||||
session := &autologin.Session{LocalStorage: map[string]string{"jwt": "x"}}
|
||||
store, err := captureSessionToStore(session, "spa.example.com")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, store.Secrets, 1)
|
||||
require.Equal(t, string(authx.WebStorageAuth), store.Secrets[0].Type)
|
||||
}
|
||||
|
||||
func TestCaptureSessionToStore_Empty(t *testing.T) {
|
||||
_, err := captureSessionToStore(&autologin.Session{}, "app.example.com")
|
||||
require.Error(t, err, "an empty session must not produce a store")
|
||||
}
|
||||
|
||||
func TestAutoLoginStoreFromOptions_RecordingDerivesHost(t *testing.T) {
|
||||
recording := `{"steps": [
|
||||
{"type": "navigate", "url": "https://recorded.example.com/signin"},
|
||||
{"type": "change", "value": "bob", "selectors": [["#user"]]}
|
||||
]}`
|
||||
path := filepath.Join(t.TempDir(), "flow.json")
|
||||
require.NoError(t, os.WriteFile(path, []byte(recording), 0o600))
|
||||
|
||||
// No -auth-login-url: the host scope must come from the recording.
|
||||
opts := &types.Options{AuthRecording: path, AuthUsername: "bob", AuthPassword: "p"}
|
||||
store, err := autoLoginStoreFromOptions(opts)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, store.Dynamic, 1)
|
||||
require.Equal(t, []string{"recorded.example.com"}, store.Dynamic[0].Domains)
|
||||
require.Equal(t, path, store.Dynamic[0].AutoLogin.Recording)
|
||||
}
|
||||
|
||||
func TestAutoLoginStoreFromOptions(t *testing.T) {
|
||||
opts := &types.Options{
|
||||
AuthLoginURL: "https://app.example.com:8443/login",
|
||||
AuthUsername: "alice",
|
||||
AuthPassword: "s3cr3t",
|
||||
AuthUsernameField: "email",
|
||||
AuthHeadless: true,
|
||||
}
|
||||
store, err := autoLoginStoreFromOptions(opts)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, store.Dynamic, 1)
|
||||
|
||||
dyn := store.Dynamic[0]
|
||||
require.NotNil(t, dyn.Secret)
|
||||
require.Equal(t, []string{"app.example.com:8443"}, dyn.Domains, "session should be scoped to the login host")
|
||||
require.NotNil(t, dyn.AutoLogin)
|
||||
require.Equal(t, "https://app.example.com:8443/login", dyn.AutoLogin.LoginURL)
|
||||
require.Equal(t, "alice", dyn.AutoLogin.Username)
|
||||
require.Equal(t, "email", dyn.AutoLogin.UsernameField)
|
||||
require.True(t, dyn.AutoLogin.Headless)
|
||||
|
||||
// The assembled store must validate as a real auto-login dynamic.
|
||||
require.NoError(t, dyn.Validate())
|
||||
}
|
||||
|
||||
func TestAutoLoginStoreFromOptions_InvalidURL(t *testing.T) {
|
||||
_, err := autoLoginStoreFromOptions(&types.Options{AuthLoginURL: "://nohost"})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestAutoLoginStoreFromOptions_SessionLifecycle(t *testing.T) {
|
||||
opts := &types.Options{
|
||||
AuthLoginURL: "https://app.example.com/login",
|
||||
AuthUsername: "alice",
|
||||
AuthPassword: "s3cr3t",
|
||||
AuthReauthStatusCodes: "401, 403",
|
||||
AuthRefreshInterval: "15m",
|
||||
}
|
||||
store, err := autoLoginStoreFromOptions(opts)
|
||||
require.NoError(t, err)
|
||||
dyn := store.Dynamic[0]
|
||||
require.Equal(t, []int{401, 403}, dyn.ReauthStatusCodes, "reauth status codes must be threaded onto the dynamic")
|
||||
require.Equal(t, "15m", dyn.RefreshInterval)
|
||||
// The lifecycle-configured store must validate (refresh-interval is parsed).
|
||||
require.NoError(t, dyn.Validate())
|
||||
}
|
||||
|
||||
func TestAutoLoginStoreFromOptions_InvalidReauthCodes(t *testing.T) {
|
||||
_, err := autoLoginStoreFromOptions(&types.Options{
|
||||
AuthLoginURL: "https://app.example.com/login",
|
||||
AuthPassword: "s3cr3t",
|
||||
AuthReauthStatusCodes: "401,nope",
|
||||
})
|
||||
require.Error(t, err, "non-numeric reauth status code must be rejected")
|
||||
}
|
||||
|
||||
func TestParseStatusCodes(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want []int
|
||||
wantErr bool
|
||||
}{
|
||||
{"", nil, false},
|
||||
{" ", nil, false},
|
||||
{"401", []int{401}, false},
|
||||
{"401,403, 419", []int{401, 403, 419}, false},
|
||||
{"401,,403", []int{401, 403}, false},
|
||||
{"abc", nil, true},
|
||||
{"99", nil, true},
|
||||
{"600", nil, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, err := parseStatusCodes(tc.in)
|
||||
if tc.wantErr {
|
||||
require.Error(t, err, "input %q", tc.in)
|
||||
continue
|
||||
}
|
||||
require.NoError(t, err, "input %q", tc.in)
|
||||
require.Equal(t, tc.want, got, "input %q", tc.in)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAutoLoginRuntimeOptions(t *testing.T) {
|
||||
opts := &types.Options{
|
||||
CustomHeaders: []string{"User-Agent: NucleiScan/1.0", "X-Env: staging", "malformed-header"},
|
||||
AliveHttpProxy: "http://127.0.0.1:8080",
|
||||
CDPEndpoint: "ws://127.0.0.1:9222",
|
||||
UseInstalledChrome: true,
|
||||
ShowBrowser: true,
|
||||
}
|
||||
rt := buildAutoLoginRuntimeOptions(opts)
|
||||
require.Equal(t, "NucleiScan/1.0", rt.UserAgent, "User-Agent should be split out of custom headers")
|
||||
require.Equal(t, "staging", rt.CustomHeaders["X-Env"])
|
||||
require.NotContains(t, rt.CustomHeaders, "User-Agent", "UA must not be duplicated into custom headers")
|
||||
require.Equal(t, "http://127.0.0.1:8080", rt.Proxy)
|
||||
require.Equal(t, "ws://127.0.0.1:9222", rt.CDPEndpoint)
|
||||
require.True(t, rt.UseInstalledChrome)
|
||||
require.True(t, rt.ShowBrowser)
|
||||
}
|
||||
|
||||
func TestBuildAutoLoginRuntimeOptions_SocksProxyFallback(t *testing.T) {
|
||||
// With only a SOCKS proxy configured, the auto-login must still pick it up
|
||||
// (regression: previously only AliveHttpProxy was threaded).
|
||||
rt := buildAutoLoginRuntimeOptions(&types.Options{
|
||||
AliveSocksProxy: "socks5://127.0.0.1:1080",
|
||||
})
|
||||
require.Equal(t, "socks5://127.0.0.1:1080", rt.Proxy)
|
||||
|
||||
// HTTP proxy takes precedence when both are set.
|
||||
rt = buildAutoLoginRuntimeOptions(&types.Options{
|
||||
AliveHttpProxy: "http://127.0.0.1:8080",
|
||||
AliveSocksProxy: "socks5://127.0.0.1:1080",
|
||||
})
|
||||
require.Equal(t, "http://127.0.0.1:8080", rt.Proxy)
|
||||
}
|
||||
@@ -49,7 +49,7 @@ func TestParseHeadlessOptionalArguments(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
strsl := goflags.StringSlice{}
|
||||
for _, v := range strings.Split(tt.input, ",") {
|
||||
for v := range strings.SplitSeq(tt.input, ",") {
|
||||
//nolint
|
||||
strsl.Set(v)
|
||||
}
|
||||
|
||||
@@ -543,9 +543,8 @@ func splitPorts(s string) []string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
var out []string
|
||||
for p := range strings.SplitSeq(s, ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
if p != "" {
|
||||
out = append(out, p)
|
||||
|
||||
+20
-77
@@ -108,7 +108,6 @@ type Runner struct {
|
||||
httpApiEndpoint *httpapi.Server
|
||||
fuzzStats *fuzzStats.Tracker
|
||||
dastServer *server.DASTServer
|
||||
authProvider authprovider.AuthProvider
|
||||
}
|
||||
|
||||
// New creates a new client for running the enumeration process.
|
||||
@@ -123,6 +122,10 @@ func New(options *types.Options) (*Runner, error) {
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if options.Verbose || options.VerboseVerbose {
|
||||
LogDirectoryInfo(runner.Logger)
|
||||
}
|
||||
|
||||
// Version check by default
|
||||
if config.DefaultConfig.CanCheckForUpdates() {
|
||||
if err := installer.NucleiVersionCheck(); err != nil {
|
||||
@@ -490,11 +493,6 @@ func (r *Runner) Close() {
|
||||
if r.browser != nil {
|
||||
r.browser.Close()
|
||||
}
|
||||
// Drop any in-memory auto-login session so nuclei does not retain live
|
||||
// session material after the scan (server-side sessions expire on their own).
|
||||
if r.authProvider != nil {
|
||||
r.authProvider.Close()
|
||||
}
|
||||
if r.tmpDir != "" {
|
||||
_ = os.RemoveAll(r.tmpDir)
|
||||
}
|
||||
@@ -635,79 +633,24 @@ func (r *Runner) RunEnumeration() error {
|
||||
executorOpts.ExportReqURLPattern = true
|
||||
}
|
||||
|
||||
if (len(r.options.SecretsFile) > 0 || r.options.AuthLoginURL != "" || r.options.AuthRecording != "" || r.options.AuthCapture) && !r.options.Validate {
|
||||
autoLoginOpts := buildAutoLoginRuntimeOptions(r.options)
|
||||
var providers []authprovider.AuthProvider
|
||||
|
||||
if len(r.options.SecretsFile) > 0 {
|
||||
// Clone options so GetAuthTmplStore can modify them without affecting the original
|
||||
authOptions := r.options.Copy()
|
||||
authTmplStore, err := GetAuthTmplStore(authOptions, r.catalog, executorOpts)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to load dynamic auth templates")
|
||||
}
|
||||
authOpts := &authprovider.AuthProviderOptions{SecretsFiles: r.options.SecretsFile}
|
||||
authOpts.LazyFetchSecret = GetLazyAuthFetchCallback(&AuthLazyFetchOptions{
|
||||
TemplateStore: authTmplStore,
|
||||
ExecOpts: executorOpts,
|
||||
})
|
||||
authOpts.AutoLoginOptions = autoLoginOpts
|
||||
fileProvider, err := authprovider.NewAuthProvider(authOpts)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not create auth provider")
|
||||
}
|
||||
providers = append(providers, fileProvider)
|
||||
if len(r.options.SecretsFile) > 0 && !r.options.Validate {
|
||||
// Clone options so GetAuthTmplStore can modify them without affecting the original
|
||||
authOptions := r.options.Copy()
|
||||
authTmplStore, err := GetAuthTmplStore(authOptions, r.catalog, executorOpts)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to load dynamic auth templates")
|
||||
}
|
||||
|
||||
// Turnkey auto-login from CLI flags (-auth-login-url): build an in-memory
|
||||
// auto-login secret scoped to the login host, no secrets file needed.
|
||||
if r.options.AuthLoginURL != "" || r.options.AuthRecording != "" {
|
||||
engine := "http"
|
||||
if r.options.AuthHeadless || r.options.AuthRecording != "" {
|
||||
engine = "headless"
|
||||
}
|
||||
target := r.options.AuthLoginURL
|
||||
if target == "" {
|
||||
target = r.options.AuthRecording
|
||||
}
|
||||
r.Logger.Info().Msgf("Auto-login enabled (%s engine) for %s", engine, target)
|
||||
store, err := autoLoginStoreFromOptions(r.options)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not build auto-login auth provider")
|
||||
}
|
||||
cliProvider, err := authprovider.NewStoreAuthProvider(store, nil, autoLoginOpts)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not create auto-login auth provider")
|
||||
}
|
||||
providers = append(providers, cliProvider)
|
||||
authOpts := &authprovider.AuthProviderOptions{SecretsFiles: r.options.SecretsFile}
|
||||
authOpts.LazyFetchSecret = GetLazyAuthFetchCallback(&AuthLazyFetchOptions{
|
||||
TemplateStore: authTmplStore,
|
||||
ExecOpts: executorOpts,
|
||||
})
|
||||
// initialize auth provider
|
||||
provider, err := authprovider.NewAuthProvider(authOpts)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not create auth provider")
|
||||
}
|
||||
|
||||
// Capture-once: open a visible browser for a one-time manual login and
|
||||
// build a static session store from the captured cookies/token.
|
||||
if r.options.AuthCapture {
|
||||
if r.options.AuthLoginURL == "" {
|
||||
return errors.New("-auth-capture requires -auth-login-url (the page to open for manual login)")
|
||||
}
|
||||
r.Logger.Info().Msgf("Capture-once login enabled for %s", r.options.AuthLoginURL)
|
||||
store, err := captureOnceStoreFromOptions(r.options)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not capture login session")
|
||||
}
|
||||
captureProvider, err := authprovider.NewStoreAuthProvider(store, nil, autoLoginOpts)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not create capture-once auth provider")
|
||||
}
|
||||
providers = append(providers, captureProvider)
|
||||
}
|
||||
|
||||
switch len(providers) {
|
||||
case 1:
|
||||
executorOpts.AuthProvider = providers[0]
|
||||
default:
|
||||
executorOpts.AuthProvider = authprovider.NewMultiAuthProvider(providers...)
|
||||
}
|
||||
// Retained so Runner.Close can drop captured session material on exit.
|
||||
r.authProvider = executorOpts.AuthProvider
|
||||
executorOpts.AuthProvider = provider
|
||||
}
|
||||
|
||||
if r.options.ShouldUseHostError() {
|
||||
|
||||
+4
-1
@@ -24,7 +24,10 @@
|
||||
{{binary}} -tags cve -exclude-templates http/cves/2020/
|
||||
{{binary}} -tags cve -exclude-templates http/cves/2020/CVE-2020-9757.yaml
|
||||
{{binary}} -tags cve -exclude-templates http/cves/2020/CVE-2020-9757.yaml -exclude-templates http/cves/2021/
|
||||
{{binary}} -tags cve -include-tags dos,fuzz
|
||||
# Skipped until the next nuclei release ships #7536: release still force-includes
|
||||
# via -include-tags (bypassing -tags), so release/current load counts diverge by design.
|
||||
# Covered by pkg/catalog/index filter unit tests in the meantime.
|
||||
# {{binary}} -tags cve -include-tags dos,fuzz
|
||||
{{binary}} -tags cve -ntv 8.8.8,8.8.9
|
||||
{{binary}} -tags cve -severity high
|
||||
{{binary}} -tags cve,dos,fuzz
|
||||
|
||||
@@ -24,6 +24,16 @@ func TestDNS(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Duration", func(t *testing.T) {
|
||||
results, err := testutils.RunNucleiTemplateAndGetResults("protocols/dns/duration.yaml", "one.one.one.one", suite.debug)
|
||||
if err != nil {
|
||||
t.Fatalf("dns duration request failed: %v", err)
|
||||
}
|
||||
if err := expectResultsCount(results, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("AAAA", func(t *testing.T) {
|
||||
results, err := testutils.RunNucleiTemplateAndGetResults("protocols/dns/aaaa.yaml", "one.one.one.one", suite.debug)
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
package integration_test
|
||||
|
||||
import "time"
|
||||
|
||||
// Keep duration assertions above the timer granularity of fast local sockets on Windows.
|
||||
const integrationDurationObservationDelay = 10 * time.Millisecond
|
||||
@@ -8,9 +8,6 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"github.com/projectdiscovery/nuclei/v3/internal/tests/testutils"
|
||||
@@ -18,28 +15,6 @@ import (
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// The analyzer-driven DAST integration cases each fire a heavy burst of
|
||||
// differential/heuristic probes through the full CLI against the in-process
|
||||
// playground. On the Windows CI runner this is too slow/flaky and times out
|
||||
// to zero findings (consistently across reruns), while the analyzer logic
|
||||
// itself is already covered cross-platform by the pkg/fuzz/analyzers engine
|
||||
// and e2e tests (the Tests (windows-latest) job runs them and passes). Skip
|
||||
// only the binary-level analyzer cases on Windows to keep CI green without
|
||||
// dropping real coverage.
|
||||
if runtime.GOOS != "windows" {
|
||||
return
|
||||
}
|
||||
filtered := make([]integrationCase, 0, len(fuzzingTestCases))
|
||||
for _, c := range fuzzingTestCases {
|
||||
if strings.HasPrefix(c.Path, "fuzz/analyzer-") {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, c)
|
||||
}
|
||||
fuzzingTestCases = filtered
|
||||
}
|
||||
|
||||
const (
|
||||
targetFile = "fuzz/testData/ginandjuice.proxify.yaml"
|
||||
)
|
||||
@@ -61,145 +36,6 @@ var fuzzingTestCases = []integrationCase{
|
||||
{Path: "fuzz/fuzz-body-params-sqli.yaml", TestCase: &genericFuzzTestCase{expectedResults: 1}},
|
||||
{Path: "fuzz/fuzz-body-xml-sqli.yaml", TestCase: &genericFuzzTestCase{expectedResults: 1}},
|
||||
{Path: "fuzz/fuzz-body-generic-sqli.yaml", TestCase: &genericFuzzTestCase{expectedResults: 4}},
|
||||
|
||||
// Analyzer-driven DAST cases: each runs a real fuzzing template whose
|
||||
// detection is delegated to a built-in analyzer, against the dedicated
|
||||
// analyzer bench in the fuzz playground. A finding is produced only when the
|
||||
// analyzer (not a static matcher) confirms the vulnerability.
|
||||
{Path: "fuzz/analyzer-sqli.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/sqli?q=en", expectedResults: 1}},
|
||||
{Path: "fuzz/analyzer-ssti.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/ssti?q=test", expectedResults: 1}},
|
||||
{Path: "fuzz/analyzer-lfi.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/lfi?q=home.txt", expectedResults: 1}},
|
||||
{Path: "fuzz/analyzer-cmdi.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/cmdi?q=127.0.0.1", expectedResults: 1}},
|
||||
{Path: "fuzz/analyzer-ssrf.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/ssrf?q=https://example.com/a.png", expectedResults: 1}},
|
||||
{Path: "fuzz/analyzer-open-redirect.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/redirect?q=/dashboard", expectedResults: 1}},
|
||||
{Path: "fuzz/analyzer-crlf.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/crlf?q=/home", expectedResults: 1}},
|
||||
{Path: "fuzz/analyzer-cors.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/cors?q=x", expectedResults: 1}},
|
||||
{Path: "fuzz/analyzer-host-header.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/host-header?q=x", expectedResults: 1}},
|
||||
|
||||
// Negative cases: the same analyzer templates against benign routes must
|
||||
// produce zero findings (false-positive guard at the CLI level).
|
||||
{Path: "fuzz/analyzer-sqli.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/safe/reflect?q=en", expectedResults: 0}},
|
||||
{Path: "fuzz/analyzer-ssti.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/safe/reflect?q=test", expectedResults: 0}},
|
||||
{Path: "fuzz/analyzer-lfi.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/safe/reflect?q=home.txt", expectedResults: 0}},
|
||||
{Path: "fuzz/analyzer-cmdi.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/safe/reflect?q=127.0.0.1", expectedResults: 0}},
|
||||
{Path: "fuzz/analyzer-ssrf.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/safe/reflect?q=https://example.com/a.png", expectedResults: 0}},
|
||||
{Path: "fuzz/analyzer-open-redirect.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/safe/redirect?q=/dashboard", expectedResults: 0}},
|
||||
{Path: "fuzz/analyzer-crlf.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/safe/headers?q=/home", expectedResults: 0}},
|
||||
{Path: "fuzz/analyzer-cors.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/safe/cors?q=x", expectedResults: 0}},
|
||||
{Path: "fuzz/analyzer-host-header.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/safe/host?q=x", expectedResults: 0}},
|
||||
|
||||
// Non-query positions: each template fuzzes a different request component
|
||||
// (path / header / body / cookie) of a captured request and relies on an
|
||||
// analyzer for detection. Driven via the proxify-format capture file.
|
||||
{Path: "fuzz/analyzer-path-sqli.yaml", TestCase: &analyzerPositionTestCase{expectedResults: 1}},
|
||||
{Path: "fuzz/analyzer-header-sqli.yaml", TestCase: &analyzerPositionTestCase{expectedResults: 1}},
|
||||
{Path: "fuzz/analyzer-body-sqli.yaml", TestCase: &analyzerPositionTestCase{expectedResults: 1}},
|
||||
{Path: "fuzz/analyzer-cookie-ssti.yaml", TestCase: &analyzerPositionTestCase{expectedResults: 1}},
|
||||
|
||||
// Crawl-to-fuzz via the katana input format: each analyzer template is driven
|
||||
// against a one-line katana JSONL crawl entry for the matching playground
|
||||
// endpoint, exercising the full crawl -> input-format -> fuzz -> analyzer
|
||||
// pipeline through the CLI (-im katana ... -dast) across GET-query,
|
||||
// GET-with-cookie and POST-JSON-body crawl shapes. (nuclei DAST scopes
|
||||
// fuzzing to one request per host, so each case uses its own crawl entry.)
|
||||
{Path: "fuzz/analyzer-sqli.yaml", TestCase: &analyzerKatanaTestCase{method: "GET", endpoint: "http://localhost:8082/analyzer/sqli?q=en", expectedResults: 1}},
|
||||
{Path: "fuzz/analyzer-ssti.yaml", TestCase: &analyzerKatanaTestCase{method: "GET", endpoint: "http://localhost:8082/analyzer/ssti?q=test", expectedResults: 1}},
|
||||
{Path: "fuzz/analyzer-lfi.yaml", TestCase: &analyzerKatanaTestCase{method: "GET", endpoint: "http://localhost:8082/analyzer/lfi?q=home.txt", expectedResults: 1}},
|
||||
{Path: "fuzz/analyzer-cmdi.yaml", TestCase: &analyzerKatanaTestCase{method: "GET", endpoint: "http://localhost:8082/analyzer/cmdi?q=127.0.0.1", expectedResults: 1}},
|
||||
{Path: "fuzz/analyzer-ssrf.yaml", TestCase: &analyzerKatanaTestCase{method: "GET", endpoint: "http://localhost:8082/analyzer/ssrf?q=https://example.com/a.png", expectedResults: 1}},
|
||||
{Path: "fuzz/analyzer-open-redirect.yaml", TestCase: &analyzerKatanaTestCase{method: "GET", endpoint: "http://localhost:8082/analyzer/redirect?q=/dashboard", expectedResults: 1}},
|
||||
{Path: "fuzz/analyzer-crlf.yaml", TestCase: &analyzerKatanaTestCase{method: "GET", endpoint: "http://localhost:8082/analyzer/crlf?q=/home", expectedResults: 1}},
|
||||
{Path: "fuzz/analyzer-cors.yaml", TestCase: &analyzerKatanaTestCase{method: "GET", endpoint: "http://localhost:8082/analyzer/cors?q=x", expectedResults: 1}},
|
||||
{Path: "fuzz/analyzer-host-header.yaml", TestCase: &analyzerKatanaTestCase{method: "GET", endpoint: "http://localhost:8082/analyzer/host-header?q=x", expectedResults: 1}},
|
||||
{Path: "fuzz/analyzer-cookie-ssti.yaml", TestCase: &analyzerKatanaTestCase{method: "GET", endpoint: "http://localhost:8082/analyzer/cookie/ssti", headers: map[string]string{"Cookie": "lang=en"}, expectedResults: 1}},
|
||||
{Path: "fuzz/analyzer-body-sqli.yaml", TestCase: &analyzerKatanaTestCase{method: "POST", endpoint: "http://localhost:8082/analyzer/body/sqli", headers: map[string]string{"Content-Type": "application/json"}, body: `{"name":"en"}`, expectedResults: 1}},
|
||||
}
|
||||
|
||||
// analyzerKatanaTestCase drives an analyzer template against a katana JSONL
|
||||
// crawl entry for the matching playground endpoint, exercising the full crawl
|
||||
// -> input-format -> fuzz -> analyzer pipeline through the CLI
|
||||
// (-im katana ... -dast).
|
||||
type analyzerKatanaTestCase struct {
|
||||
method string
|
||||
endpoint string
|
||||
headers map[string]string
|
||||
body string
|
||||
expectedResults int
|
||||
}
|
||||
|
||||
func (a *analyzerKatanaTestCase) Execute(filePath string) error {
|
||||
crawlFile, cleanup, err := writeKatanaCrawlEntry(a.method, a.endpoint, a.headers, a.body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
results, err := testutils.RunNucleiWithArgsAndGetResults(debug, "-t", filePath, "-l", crawlFile, "-im", "katana", "-dast")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return expectResultsCount(results, a.expectedResults)
|
||||
}
|
||||
|
||||
// writeKatanaCrawlEntry writes a single katana JSONL crawl record to a temp file
|
||||
// and returns its path plus a cleanup func.
|
||||
func writeKatanaCrawlEntry(method, endpoint string, headers map[string]string, body string) (string, func(), error) {
|
||||
record := map[string]any{
|
||||
"request": map[string]any{
|
||||
"method": method,
|
||||
"endpoint": endpoint,
|
||||
"headers": headers,
|
||||
"body": body,
|
||||
},
|
||||
}
|
||||
line, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return "", func() {}, err
|
||||
}
|
||||
f, err := os.CreateTemp("", "analyzer-katana-*.jsonl")
|
||||
if err != nil {
|
||||
return "", func() {}, err
|
||||
}
|
||||
if _, err := f.Write(append(line, '\n')); err != nil {
|
||||
_ = f.Close()
|
||||
_ = os.Remove(f.Name())
|
||||
return "", func() {}, err
|
||||
}
|
||||
_ = f.Close()
|
||||
return f.Name(), func() { _ = os.Remove(f.Name()) }, nil
|
||||
}
|
||||
|
||||
const analyzerPositionsTargetFile = "fuzz/testData/analyzer-positions.proxify.yaml"
|
||||
|
||||
// analyzerPositionTestCase runs an analyzer-driven template that fuzzes a
|
||||
// non-query component against the captured analyzer-bench requests. Only the
|
||||
// request matching the template's fuzzed component produces a finding.
|
||||
type analyzerPositionTestCase struct {
|
||||
expectedResults int
|
||||
}
|
||||
|
||||
func (a *analyzerPositionTestCase) Execute(filePath string) error {
|
||||
results, err := testutils.RunNucleiWithArgsAndGetResults(debug, "-t", filePath, "-l", analyzerPositionsTargetFile, "-im", "yaml", "-dast")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return expectResultsCount(results, a.expectedResults)
|
||||
}
|
||||
|
||||
// analyzerFuzzTestCase runs an analyzer-driven fuzzing template against the
|
||||
// running fuzz playground (localhost:8082) and asserts the number of findings.
|
||||
type analyzerFuzzTestCase struct {
|
||||
route string
|
||||
expectedResults int
|
||||
}
|
||||
|
||||
func (a *analyzerFuzzTestCase) Execute(filePath string) error {
|
||||
target := "http://localhost:8082" + a.route
|
||||
results, err := testutils.RunNucleiTemplateAndGetResults(filePath, target, debug, "-dast")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return expectResultsCount(results, a.expectedResults)
|
||||
}
|
||||
|
||||
type genericFuzzTestCase struct {
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
|
||||
var headlessTestcases = []integrationCase{
|
||||
{Path: "protocols/headless/headless-basic.yaml", TestCase: &headlessBasic{}},
|
||||
{Path: "protocols/headless/headless-duration.yaml", TestCase: &headlessBasic{}},
|
||||
{Path: "protocols/headless/headless-waitevent.yaml", TestCase: &headlessBasic{}},
|
||||
{Path: "protocols/headless/headless-dsl.yaml", TestCase: &headlessBasic{}},
|
||||
{Path: "protocols/headless/headless-self-contained.yaml", TestCase: &headlessSelfContained{}},
|
||||
|
||||
@@ -75,6 +75,7 @@ var httpTestcases = []integrationCase{
|
||||
{Path: "protocols/http/variable-dsl-function.yaml", TestCase: &httpVariableDSLFunction{}},
|
||||
{Path: "protocols/http/get-override-sni.yaml", TestCase: &httpSniAnnotation{}},
|
||||
{Path: "protocols/http/get-sni.yaml", TestCase: &customCLISNI{}},
|
||||
{Path: "protocols/http/tls-metadata.yaml", TestCase: &httpTLSMetadata{}},
|
||||
{Path: "protocols/http/redirect-match-url.yaml", TestCase: &httpRedirectMatchURL{}},
|
||||
{Path: "protocols/http/get-sni-unsafe.yaml", TestCase: &customCLISNIUnsafe{}},
|
||||
{Path: "protocols/http/annotation-timeout.yaml", TestCase: &annotationTimeout{}},
|
||||
@@ -829,7 +830,7 @@ func (h *httpRawUnsafePath) Execute(filepath string) error {
|
||||
}
|
||||
|
||||
actual := []string{}
|
||||
for _, v := range strings.Split(results, "\n") {
|
||||
for v := range strings.SplitSeq(results, "\n") {
|
||||
if strings.Contains(v, "GET") {
|
||||
parts := strings.Fields(v)
|
||||
if len(parts) == 3 {
|
||||
@@ -877,7 +878,7 @@ func (h *httpPaths) Execute(filepath string) error {
|
||||
}
|
||||
|
||||
actual := []string{}
|
||||
for _, v := range strings.Split(results, "\n") {
|
||||
for v := range strings.SplitSeq(results, "\n") {
|
||||
if strings.Contains(v, "GET") {
|
||||
parts := strings.Fields(v)
|
||||
if len(parts) == 3 {
|
||||
@@ -1394,7 +1395,7 @@ func (h *httpVariableDSLFunction) Execute(filePath string) error {
|
||||
}
|
||||
|
||||
actual := []string{}
|
||||
for _, v := range strings.Split(results, "\n") {
|
||||
for v := range strings.SplitSeq(results, "\n") {
|
||||
if strings.Contains(v, "GET") {
|
||||
parts := strings.Fields(v)
|
||||
if len(parts) == 3 {
|
||||
@@ -1431,6 +1432,28 @@ func (h *customCLISNI) Execute(filePath string) error {
|
||||
return expectResultsCount(results, 1)
|
||||
}
|
||||
|
||||
type httpTLSMetadata struct{}
|
||||
|
||||
// Execute verifies TLS handshake metadata from the same HTTP connection is matchable.
|
||||
func (h *httpTLSMetadata) Execute(filePath string) error {
|
||||
router := httprouter.New()
|
||||
router.GET("/", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
|
||||
if r.TLS != nil && r.TLS.ServerName == "test" {
|
||||
_, _ = w.Write([]byte("test-ok"))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("test-ko"))
|
||||
})
|
||||
ts := httptest.NewTLSServer(router)
|
||||
defer ts.Close()
|
||||
|
||||
results, err := testutils.RunNucleiTemplateAndGetResults(filePath, ts.URL, debug, "-sni", "test")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return expectResultsCount(results, 1)
|
||||
}
|
||||
|
||||
type httpSniAnnotation struct{}
|
||||
|
||||
// Execute executes a test case and returns an error if occurred
|
||||
@@ -1763,7 +1786,7 @@ func (h *httpRawPathSingleSlash) Execute(filepath string) error {
|
||||
}
|
||||
|
||||
var actual string
|
||||
for _, v := range strings.Split(results, "\n") {
|
||||
for v := range strings.SplitSeq(results, "\n") {
|
||||
if strings.Contains(v, "GET") {
|
||||
parts := strings.Fields(v)
|
||||
if len(parts) == 3 {
|
||||
@@ -1788,7 +1811,7 @@ func (h *httpRawUnsafePathSingleSlash) Execute(filepath string) error {
|
||||
}
|
||||
|
||||
var actual string
|
||||
for _, v := range strings.Split(results, "\n") {
|
||||
for v := range strings.SplitSeq(results, "\n") {
|
||||
if strings.Contains(v, "GET") {
|
||||
parts := strings.Fields(v)
|
||||
if len(parts) == 3 {
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/health"
|
||||
healthpb "google.golang.org/grpc/health/grpc_health_v1"
|
||||
"google.golang.org/grpc/reflection"
|
||||
)
|
||||
|
||||
// startGRPCHealthServer starts an in-process gRPC server exposing the standard
|
||||
// health service (status SERVING) and server reflection on a random loopback
|
||||
// port. It returns the "host:port" address and a stop func.
|
||||
func startGRPCHealthServer() (string, func(), error) {
|
||||
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("grpc health listen: %w", err)
|
||||
}
|
||||
srv := grpc.NewServer()
|
||||
hs := health.NewServer()
|
||||
hs.SetServingStatus("", healthpb.HealthCheckResponse_SERVING)
|
||||
healthpb.RegisterHealthServer(srv, hs)
|
||||
reflection.Register(srv)
|
||||
go func() { _ = srv.Serve(lis) }()
|
||||
return lis.Addr().String(), srv.Stop, nil
|
||||
}
|
||||
|
||||
// javascriptGRPCHealth exercises the nuclei/grpc library end to end against a
|
||||
// real gRPC server via server reflection.
|
||||
type javascriptGRPCHealth struct{}
|
||||
|
||||
func (j *javascriptGRPCHealth) Execute(filePath string) error {
|
||||
address, stop, err := startGRPCHealthServer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stop()
|
||||
|
||||
results, err := runSignedNucleiTemplateAndGetResults(filePath, address, debug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return expectResultsCount(results, 1)
|
||||
}
|
||||
|
||||
// javascriptGRPCDenied asserts the nuclei/grpc library refuses to dial a host
|
||||
// on the exclude list (network-policy enforcement) before connecting.
|
||||
type javascriptGRPCDenied struct{}
|
||||
|
||||
func (j *javascriptGRPCDenied) Execute(filePath string) error {
|
||||
results, err := runSignedNucleiTemplateAndGetResults(filePath, "127.0.0.1", debug, "-eh", "203.0.113.10")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return expectResultsCount(results, 1)
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// startJSHTTPServer starts a local HTTP server exercising the generic paths
|
||||
// used by the nuclei/http javascript integration templates.
|
||||
func startJSHTTPServer() *httptest.Server {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("X-Test", "ok")
|
||||
_, _ = io.WriteString(w, "hello")
|
||||
})
|
||||
mux.HandleFunc("/echo-header", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, r.Header.Get("X-Token"))
|
||||
})
|
||||
mux.HandleFunc("/echo", func(w http.ResponseWriter, r *http.Request) {
|
||||
b, _ := io.ReadAll(r.Body)
|
||||
_, _ = w.Write(b)
|
||||
})
|
||||
mux.HandleFunc("/set-cookie", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{Name: "sid", Value: "abc"})
|
||||
_, _ = io.WriteString(w, "set")
|
||||
})
|
||||
mux.HandleFunc("/echo-cookie", func(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := r.Cookie("sid")
|
||||
if err != nil || c.Value != "abc" {
|
||||
_, _ = io.WriteString(w, "missing")
|
||||
return
|
||||
}
|
||||
_, _ = io.WriteString(w, "sid-ok")
|
||||
})
|
||||
mux.HandleFunc("/redir", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/land", http.StatusFound)
|
||||
})
|
||||
mux.HandleFunc("/land", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, "landed")
|
||||
})
|
||||
return httptest.NewServer(mux)
|
||||
}
|
||||
|
||||
type javascriptHTTPGet struct{}
|
||||
|
||||
func (j *javascriptHTTPGet) Execute(filePath string) error {
|
||||
ts := startJSHTTPServer()
|
||||
defer ts.Close()
|
||||
|
||||
// Target as host:port so javascript Host/Port args populate cleanly.
|
||||
hostPort := strings.TrimPrefix(ts.URL, "http://")
|
||||
results, err := runSignedNucleiTemplateAndGetResults(filePath, hostPort, debug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return expectResultsCount(results, 1)
|
||||
}
|
||||
|
||||
type javascriptHTTPClientFlow struct{}
|
||||
|
||||
func (j *javascriptHTTPClientFlow) Execute(filePath string) error {
|
||||
ts := startJSHTTPServer()
|
||||
defer ts.Close()
|
||||
|
||||
hostPort := strings.TrimPrefix(ts.URL, "http://")
|
||||
results, err := runSignedNucleiTemplateAndGetResults(filePath, hostPort, debug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return expectResultsCount(results, 1)
|
||||
}
|
||||
|
||||
type javascriptHTTPDenied struct{}
|
||||
|
||||
func (j *javascriptHTTPDenied) Execute(filePath string) error {
|
||||
results, err := runSignedNucleiTemplateAndGetResults(filePath, "127.0.0.1", debug, "-eh", "203.0.113.10")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return expectResultsCount(results, 1)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"net"
|
||||
"time"
|
||||
)
|
||||
|
||||
type javascriptMSSQLFingerprint struct{}
|
||||
|
||||
func (j *javascriptMSSQLFingerprint) Execute(filePath string) error {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = ln.Close() }()
|
||||
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go handleMSSQLPreloginConn(conn)
|
||||
}
|
||||
}()
|
||||
|
||||
results, err := runSignedNucleiTemplateAndGetResults(filePath, ln.Addr().String(), debug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return expectResultsCount(results, 1)
|
||||
}
|
||||
|
||||
func handleMSSQLPreloginConn(conn net.Conn) {
|
||||
defer func() { _ = conn.Close() }()
|
||||
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
|
||||
buf := make([]byte, 256)
|
||||
if _, err := io.ReadAtLeast(conn, buf, 8); err != nil {
|
||||
return
|
||||
}
|
||||
_, _ = conn.Write(sampleMSSQLPreloginResponse())
|
||||
}
|
||||
|
||||
func sampleMSSQLPreloginResponse() []byte {
|
||||
body := []byte{
|
||||
0x00, 0x00, 0x1f, 0x00, 0x06,
|
||||
0x01, 0x00, 0x25, 0x00, 0x01,
|
||||
0x02, 0x00, 0x26, 0x00, 0x01,
|
||||
0x03, 0x00, 0x27, 0x00, 0x00,
|
||||
0x04, 0x00, 0x27, 0x00, 0x01,
|
||||
0x05, 0x00, 0x28, 0x00, 0x00,
|
||||
0xff,
|
||||
0x0f, 0x00, 0x07, 0xd0, 0x00, 0x00,
|
||||
0x02,
|
||||
0x00,
|
||||
0x01,
|
||||
}
|
||||
header := []byte{0x04, 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00}
|
||||
binary.BigEndian.PutUint16(header[2:4], uint16(8+len(body)))
|
||||
return append(header, body...)
|
||||
}
|
||||
@@ -31,13 +31,21 @@ func javascriptGoExecSambaDisabled() bool {
|
||||
|
||||
var jsTestcases = []integrationCase{
|
||||
{Path: "protocols/javascript/redis-pass-brute.yaml", TestCase: &javascriptRedisPassBrute{}, DisableOn: javascriptDockerDisabled, Serial: true},
|
||||
{Path: "protocols/javascript/redis-lua-script.yaml", TestCase: &javascriptRedisLuaScript{}, DisableOn: javascriptDockerDisabled, Serial: true},
|
||||
{Path: "protocols/javascript/ssh-server-fingerprint.yaml", TestCase: &javascriptSSHServerFingerprint{}, DisableOn: javascriptDockerDisabled, Serial: true},
|
||||
{Path: "protocols/javascript/net-multi-step.yaml", TestCase: &networkMultiStep{}},
|
||||
{Path: "protocols/javascript/net-https.yaml", TestCase: &javascriptNetHttps{}},
|
||||
{Path: "protocols/javascript/grpc-health.yaml", TestCase: &javascriptGRPCHealth{}},
|
||||
{Path: "protocols/javascript/grpc-denied.yaml", TestCase: &javascriptGRPCDenied{}},
|
||||
{Path: "protocols/javascript/http-get.yaml", TestCase: &javascriptHTTPGet{}},
|
||||
{Path: "protocols/javascript/http-client-flow.yaml", TestCase: &javascriptHTTPClientFlow{}},
|
||||
{Path: "protocols/javascript/http-denied.yaml", TestCase: &javascriptHTTPDenied{}},
|
||||
{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/postgres-pass-brute.yaml", TestCase: &javascriptPostgresPassBrute{}, DisableOn: javascriptDockerDisabled, Serial: true},
|
||||
{Path: "protocols/javascript/mysql-connect.yaml", TestCase: &javascriptMySQLConnect{}, DisableOn: javascriptDockerDisabled, Serial: true},
|
||||
{Path: "protocols/javascript/mysql-fingerprint.yaml", TestCase: &javascriptMySQLFingerprint{}, DisableOn: javascriptDockerDisabled, Serial: true},
|
||||
{Path: "protocols/javascript/mssql-fingerprint.yaml", TestCase: &javascriptMSSQLFingerprint{}},
|
||||
{Path: "protocols/javascript/multi-ports.yaml", TestCase: &javascriptMultiPortsSSH{}},
|
||||
{Path: "protocols/javascript/no-port-args.yaml", TestCase: &javascriptNoPortArgs{}},
|
||||
{Path: "protocols/javascript/telnet-auth-test.yaml", TestCase: &javascriptTelnetAuthTest{}, DisableOn: javascriptDockerDisabled, Serial: true},
|
||||
@@ -111,6 +119,16 @@ func (j *javascriptRedisPassBrute) Execute(filePath string) error {
|
||||
}, javascriptServiceReadyTimeout, 0, nil))
|
||||
}
|
||||
|
||||
type javascriptRedisLuaScript struct{}
|
||||
|
||||
func (j *javascriptRedisLuaScript) Execute(filePath string) error {
|
||||
return runJavascriptDockerCase(filePath, newJavascriptDockerSpec("6379/tcp", &dockertest.RunOptions{
|
||||
Repository: "redis",
|
||||
Tag: "latest",
|
||||
Cmd: []string{"redis-server", "--requirepass", "iamadmin"},
|
||||
}, javascriptServiceReadyTimeout, 0, nil))
|
||||
}
|
||||
|
||||
type javascriptSSHServerFingerprint struct{}
|
||||
|
||||
func (j *javascriptSSHServerFingerprint) Execute(filePath string) error {
|
||||
@@ -163,6 +181,57 @@ func (j *javascriptMySQLConnect) Execute(filePath string) error {
|
||||
}, javascriptDatabaseReadyTimeout, 0, mysqlReadyCheck("root", "secret")))
|
||||
}
|
||||
|
||||
type javascriptMySQLFingerprint struct{}
|
||||
|
||||
// Execute fingerprints multiple MySQL/MariaDB server versions and asserts the
|
||||
// extended handshake fields (protocol, version, salt, capabilities, auth plugin).
|
||||
func (j *javascriptMySQLFingerprint) Execute(filePath string) error {
|
||||
cases := []struct {
|
||||
name string
|
||||
repository string
|
||||
tag string
|
||||
env []string
|
||||
}{
|
||||
{
|
||||
name: "mysql-5.7",
|
||||
repository: "mysql",
|
||||
tag: "5.7",
|
||||
env: []string{"MYSQL_ROOT_PASSWORD=secret"},
|
||||
},
|
||||
{
|
||||
name: "mysql-8.0",
|
||||
repository: "mysql",
|
||||
tag: "8.0",
|
||||
env: []string{"MYSQL_ROOT_PASSWORD=secret"},
|
||||
},
|
||||
{
|
||||
name: "mysql-8.4",
|
||||
repository: "mysql",
|
||||
tag: "8.4",
|
||||
env: []string{"MYSQL_ROOT_PASSWORD=secret"},
|
||||
},
|
||||
{
|
||||
name: "mariadb-11.4",
|
||||
repository: "mariadb",
|
||||
tag: "11.4",
|
||||
env: []string{"MARIADB_ROOT_PASSWORD=secret"},
|
||||
},
|
||||
}
|
||||
|
||||
var errs []error
|
||||
for _, tc := range cases {
|
||||
err := runJavascriptDockerCase(filePath, newJavascriptDockerSpec("3306/tcp", &dockertest.RunOptions{
|
||||
Repository: tc.repository,
|
||||
Tag: tc.tag,
|
||||
Env: tc.env,
|
||||
}, javascriptDatabaseReadyTimeout, 0, mysqlReadyCheck("root", "secret")))
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("%s: %w", tc.name, err))
|
||||
}
|
||||
}
|
||||
return multierr.Combine(errs...)
|
||||
}
|
||||
|
||||
type javascriptMultiPortsSSH struct{}
|
||||
|
||||
func (j *javascriptMultiPortsSSH) Execute(filePath string) error {
|
||||
|
||||
@@ -116,6 +116,46 @@ func TestNetwork(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Duration", func(t *testing.T) {
|
||||
errState := &capturedError{}
|
||||
server := testutils.NewTCPServer(nil, 0, func(conn net.Conn) {
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
data, err := reader.ConnReadNWithTimeout(conn, 5, 5*time.Second)
|
||||
if err != nil {
|
||||
errState.Set(err)
|
||||
return
|
||||
}
|
||||
if string(data) == "FIRST" {
|
||||
time.Sleep(integrationDurationObservationDelay)
|
||||
_, _ = conn.Write([]byte("PING"))
|
||||
}
|
||||
|
||||
data, err = reader.ConnReadNWithTimeout(conn, 6, 5*time.Second)
|
||||
if err != nil {
|
||||
errState.Set(err)
|
||||
return
|
||||
}
|
||||
if string(data) == "SECOND" {
|
||||
time.Sleep(integrationDurationObservationDelay)
|
||||
_, _ = conn.Write([]byte("PONG"))
|
||||
}
|
||||
_, _ = conn.Write([]byte("NUCLEI"))
|
||||
})
|
||||
defer server.Close()
|
||||
|
||||
results, err := testutils.RunNucleiTemplateAndGetResults("protocols/network/duration.yaml", server.URL, suite.debug)
|
||||
if err != nil {
|
||||
t.Fatalf("duration network request failed: %v", err)
|
||||
}
|
||||
if err := errState.Err(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := expectResultsCount(results, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("SelfContained", func(t *testing.T) {
|
||||
server := testutils.NewTCPServer(nil, 0, func(conn net.Conn) {
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
@@ -23,6 +23,17 @@ func TestSSL(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Duration", func(t *testing.T) {
|
||||
server := newTLSServer(t, &tls.Config{})
|
||||
results, err := testutils.RunNucleiTemplateAndGetResults("protocols/ssl/duration.yaml", server.URL, suite.debug)
|
||||
if err != nil {
|
||||
t.Fatalf("duration ssl request failed: %v", err)
|
||||
}
|
||||
if err := expectResultsCount(results, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("BasicZTLS", func(t *testing.T) {
|
||||
server := newTLSServer(t, &tls.Config{})
|
||||
results, err := testutils.RunNucleiTemplateAndGetResults("protocols/ssl/basic-ztls.yaml", server.URL, suite.debug, "-ztls")
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
id: analyzer-body-sqli
|
||||
|
||||
info:
|
||||
name: Body-position error-based SQLi via analyzer
|
||||
author: pdteam
|
||||
severity: high
|
||||
description: |
|
||||
Drives the sqli_error analyzer while fuzzing the JSON body "name" field,
|
||||
proving the analyzer works on the body component through the real pipeline.
|
||||
|
||||
http:
|
||||
- pre-condition:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- 'method == "POST"'
|
||||
condition: and
|
||||
|
||||
payloads:
|
||||
seed:
|
||||
- "en"
|
||||
|
||||
fuzzing:
|
||||
- part: body
|
||||
type: replace
|
||||
mode: single
|
||||
keys:
|
||||
- "name"
|
||||
fuzz:
|
||||
- "{{seed}}"
|
||||
analyzer:
|
||||
name: sqli_error
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- "analyzer == true"
|
||||
@@ -1,29 +0,0 @@
|
||||
id: analyzer-cmdi
|
||||
|
||||
info:
|
||||
name: OS Command Injection via analyzer
|
||||
author: pdteam
|
||||
severity: critical
|
||||
description: |
|
||||
Drives the built-in cmdi analyzer through the real fuzzing pipeline. The
|
||||
analyzer injects shell separators followed by `id` and confirms when the
|
||||
command output (uid=...gid=...) appears in the response.
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}"
|
||||
fuzzing:
|
||||
- part: query
|
||||
type: replace
|
||||
mode: single
|
||||
keys:
|
||||
- "q"
|
||||
fuzz:
|
||||
- "127.0.0.1"
|
||||
analyzer:
|
||||
name: cmdi
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- "analyzer == true"
|
||||
@@ -1,37 +0,0 @@
|
||||
id: analyzer-cookie-ssti
|
||||
|
||||
info:
|
||||
name: Cookie-position SSTI via analyzer
|
||||
author: pdteam
|
||||
severity: critical
|
||||
description: |
|
||||
Drives the ssti analyzer while fuzzing the "lang" cookie value, proving the
|
||||
analyzer works on the cookie component through the real pipeline. SSTI is
|
||||
used (not SQLi) because cookie value sanitization strips SQLi quote payloads.
|
||||
|
||||
http:
|
||||
- pre-condition:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- 'method == "GET"'
|
||||
- 'len(cookie) > 0'
|
||||
condition: and
|
||||
|
||||
payloads:
|
||||
seed:
|
||||
- "en"
|
||||
|
||||
fuzzing:
|
||||
- part: cookie
|
||||
type: replace
|
||||
mode: single
|
||||
keys:
|
||||
- "lang"
|
||||
fuzz:
|
||||
- "{{seed}}"
|
||||
analyzer:
|
||||
name: ssti
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- "analyzer == true"
|
||||
@@ -1,29 +0,0 @@
|
||||
id: analyzer-cors
|
||||
|
||||
info:
|
||||
name: CORS Misconfiguration via analyzer
|
||||
author: pdteam
|
||||
severity: medium
|
||||
description: |
|
||||
Drives the built-in cors analyzer through the real fuzzing pipeline. The
|
||||
analyzer sends an attacker-controlled Origin and confirms when it is
|
||||
reflected in Access-Control-Allow-Origin together with credentials.
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}"
|
||||
fuzzing:
|
||||
- part: query
|
||||
type: replace
|
||||
mode: single
|
||||
keys:
|
||||
- "q"
|
||||
fuzz:
|
||||
- "x"
|
||||
analyzer:
|
||||
name: cors
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- "analyzer == true"
|
||||
@@ -1,29 +0,0 @@
|
||||
id: analyzer-crlf
|
||||
|
||||
info:
|
||||
name: CRLF Injection via analyzer
|
||||
author: pdteam
|
||||
severity: medium
|
||||
description: |
|
||||
Drives the built-in crlf analyzer through the real fuzzing pipeline. The
|
||||
analyzer injects raw CRLF sequences with canary headers/cookies and confirms
|
||||
when the response is split to include them.
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}"
|
||||
fuzzing:
|
||||
- part: query
|
||||
type: replace
|
||||
mode: single
|
||||
keys:
|
||||
- "q"
|
||||
fuzz:
|
||||
- "/home"
|
||||
analyzer:
|
||||
name: crlf
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- "analyzer == true"
|
||||
@@ -1,35 +0,0 @@
|
||||
id: analyzer-header-sqli
|
||||
|
||||
info:
|
||||
name: Header-position error-based SQLi via analyzer
|
||||
author: pdteam
|
||||
severity: high
|
||||
description: |
|
||||
Drives the sqli_error analyzer while fuzzing the X-Search request header,
|
||||
proving the analyzer works on the header component through the real pipeline.
|
||||
|
||||
http:
|
||||
- pre-condition:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- 'method == "GET"'
|
||||
condition: and
|
||||
|
||||
payloads:
|
||||
seed:
|
||||
- "en"
|
||||
|
||||
fuzzing:
|
||||
- part: header
|
||||
type: replace
|
||||
mode: single
|
||||
keys:
|
||||
- "X-Search"
|
||||
fuzz:
|
||||
- "{{seed}}"
|
||||
analyzer:
|
||||
name: sqli_error
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- "analyzer == true"
|
||||
@@ -1,29 +0,0 @@
|
||||
id: analyzer-host-header
|
||||
|
||||
info:
|
||||
name: Host Header Injection via analyzer
|
||||
author: pdteam
|
||||
severity: medium
|
||||
description: |
|
||||
Drives the built-in host_header_injection analyzer through the real fuzzing
|
||||
pipeline. The analyzer overrides Host / X-Forwarded-Host with a canary and
|
||||
confirms when it is reflected back in an absolute URL.
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}"
|
||||
fuzzing:
|
||||
- part: query
|
||||
type: replace
|
||||
mode: single
|
||||
keys:
|
||||
- "q"
|
||||
fuzz:
|
||||
- "x"
|
||||
analyzer:
|
||||
name: host_header_injection
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- "analyzer == true"
|
||||
@@ -1,29 +0,0 @@
|
||||
id: analyzer-lfi
|
||||
|
||||
info:
|
||||
name: Local File Inclusion via analyzer
|
||||
author: pdteam
|
||||
severity: high
|
||||
description: |
|
||||
Drives the built-in lfi analyzer through the real fuzzing pipeline. The
|
||||
analyzer injects path-traversal payloads and confirms a finding when known
|
||||
file-content signatures (e.g. /etc/passwd, win.ini) appear.
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}"
|
||||
fuzzing:
|
||||
- part: query
|
||||
type: replace
|
||||
mode: single
|
||||
keys:
|
||||
- "q"
|
||||
fuzz:
|
||||
- "home.txt"
|
||||
analyzer:
|
||||
name: lfi
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- "analyzer == true"
|
||||
@@ -1,29 +0,0 @@
|
||||
id: analyzer-open-redirect
|
||||
|
||||
info:
|
||||
name: Open Redirect via analyzer
|
||||
author: pdteam
|
||||
severity: medium
|
||||
description: |
|
||||
Drives the built-in open_redirect analyzer through the real fuzzing
|
||||
pipeline. The analyzer injects an unresolvable canary host and confirms when
|
||||
the application redirects to it via the Location header or final URL.
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}"
|
||||
fuzzing:
|
||||
- part: query
|
||||
type: replace
|
||||
mode: single
|
||||
keys:
|
||||
- "q"
|
||||
fuzz:
|
||||
- "/dashboard"
|
||||
analyzer:
|
||||
name: open_redirect
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- "analyzer == true"
|
||||
@@ -1,33 +0,0 @@
|
||||
id: analyzer-path-sqli
|
||||
|
||||
info:
|
||||
name: Path-position error-based SQLi via analyzer
|
||||
author: pdteam
|
||||
severity: high
|
||||
description: |
|
||||
Drives the sqli_error analyzer while fuzzing a numeric PATH segment, proving
|
||||
the analyzer works on the path component through the real pipeline.
|
||||
|
||||
http:
|
||||
- pre-condition:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- 'method == "GET"'
|
||||
condition: and
|
||||
|
||||
payloads:
|
||||
seed:
|
||||
- "1"
|
||||
|
||||
fuzzing:
|
||||
- part: path
|
||||
type: replace
|
||||
mode: single
|
||||
fuzz:
|
||||
- "{{seed}}"
|
||||
analyzer:
|
||||
name: sqli_error
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- "analyzer == true"
|
||||
@@ -1,29 +0,0 @@
|
||||
id: analyzer-sqli
|
||||
|
||||
info:
|
||||
name: Error-based SQLi via analyzer
|
||||
author: pdteam
|
||||
severity: high
|
||||
description: |
|
||||
Drives the built-in sqli_error analyzer through the real fuzzing pipeline.
|
||||
The analyzer injects syntax-breaking tokens and fingerprints DBMS errors; a
|
||||
finding is raised only when the analyzer confirms the injection.
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}"
|
||||
fuzzing:
|
||||
- part: query
|
||||
type: replace
|
||||
mode: single
|
||||
keys:
|
||||
- "q"
|
||||
fuzz:
|
||||
- "1337"
|
||||
analyzer:
|
||||
name: sqli_error
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- "analyzer == true"
|
||||
@@ -1,29 +0,0 @@
|
||||
id: analyzer-ssrf
|
||||
|
||||
info:
|
||||
name: Server-Side Request Forgery via analyzer
|
||||
author: pdteam
|
||||
severity: high
|
||||
description: |
|
||||
Drives the built-in ssrf analyzer through the real fuzzing pipeline. The
|
||||
analyzer points the parameter at cloud metadata endpoints and confirms when
|
||||
metadata signatures are reflected in-band.
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}"
|
||||
fuzzing:
|
||||
- part: query
|
||||
type: replace
|
||||
mode: single
|
||||
keys:
|
||||
- "q"
|
||||
fuzz:
|
||||
- "https://example.com/a.png"
|
||||
analyzer:
|
||||
name: ssrf
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- "analyzer == true"
|
||||
@@ -1,29 +0,0 @@
|
||||
id: analyzer-ssti
|
||||
|
||||
info:
|
||||
name: Server-Side Template Injection via analyzer
|
||||
author: pdteam
|
||||
severity: critical
|
||||
description: |
|
||||
Drives the built-in ssti analyzer through the real fuzzing pipeline. The
|
||||
analyzer injects an arithmetic oracle in multiple template syntaxes and only
|
||||
reports when the engine evaluates the expression.
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}"
|
||||
fuzzing:
|
||||
- part: query
|
||||
type: replace
|
||||
mode: single
|
||||
keys:
|
||||
- "q"
|
||||
fuzz:
|
||||
- "1337"
|
||||
analyzer:
|
||||
name: ssti
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- "analyzer == true"
|
||||
-72
@@ -1,72 +0,0 @@
|
||||
timestamp: 2024-02-20T19:24:13+05:30
|
||||
url: http://127.0.0.1:8082/analyzer/path/sqli/75
|
||||
request:
|
||||
raw: |+
|
||||
GET /analyzer/path/sqli/75 HTTP/1.1
|
||||
Host: 127.0.0.1:8082
|
||||
Accept-Encoding: gzip
|
||||
Connection: close
|
||||
User-Agent: nuclei-fuzz
|
||||
|
||||
response:
|
||||
raw: |+
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
Connection: close
|
||||
|
||||
---
|
||||
timestamp: 2024-02-20T19:24:13+05:30
|
||||
url: http://127.0.0.1:8082/analyzer/header/sqli
|
||||
request:
|
||||
raw: |+
|
||||
GET /analyzer/header/sqli HTTP/1.1
|
||||
Host: 127.0.0.1:8082
|
||||
Accept-Encoding: gzip
|
||||
Connection: close
|
||||
X-Search: en
|
||||
User-Agent: nuclei-fuzz
|
||||
|
||||
response:
|
||||
raw: |+
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
Connection: close
|
||||
|
||||
---
|
||||
timestamp: 2024-02-20T19:24:13+05:30
|
||||
url: http://127.0.0.1:8082/analyzer/body/sqli
|
||||
request:
|
||||
raw: |+
|
||||
POST /analyzer/body/sqli HTTP/1.1
|
||||
Host: 127.0.0.1:8082
|
||||
Accept-Encoding: gzip
|
||||
Connection: close
|
||||
Content-Type: application/json
|
||||
Content-Length: 13
|
||||
User-Agent: nuclei-fuzz
|
||||
|
||||
{"name":"en"}
|
||||
|
||||
response:
|
||||
raw: |+
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: application/json
|
||||
Connection: close
|
||||
|
||||
---
|
||||
timestamp: 2024-02-20T19:24:13+05:30
|
||||
url: http://127.0.0.1:8082/analyzer/cookie/ssti
|
||||
request:
|
||||
raw: |+
|
||||
GET /analyzer/cookie/ssti HTTP/1.1
|
||||
Host: 127.0.0.1:8082
|
||||
Accept-Encoding: gzip
|
||||
Connection: close
|
||||
Cookie: lang=en
|
||||
User-Agent: nuclei-fuzz
|
||||
|
||||
response:
|
||||
raw: |+
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: text/html
|
||||
Connection: close
|
||||
@@ -0,0 +1,23 @@
|
||||
id: dns-duration
|
||||
|
||||
info:
|
||||
name: DNS Duration
|
||||
author: pdteam
|
||||
severity: info
|
||||
|
||||
flow: dns("duration_dns")
|
||||
|
||||
dns:
|
||||
- id: duration_dns
|
||||
name: "{{FQDN}}"
|
||||
type: A
|
||||
class: inet
|
||||
recursion: true
|
||||
retries: 3
|
||||
matchers:
|
||||
- type: dsl
|
||||
condition: and
|
||||
dsl:
|
||||
- "duration > 0 && duration < 60"
|
||||
- "duration_dns_duration > 0 && duration_dns_duration < 60"
|
||||
- "duration == duration_dns_duration"
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
id: headless-duration
|
||||
|
||||
info:
|
||||
name: Headless Duration
|
||||
author: pdteam
|
||||
severity: info
|
||||
tags: headless
|
||||
|
||||
flow: headless("duration_headless")
|
||||
|
||||
headless:
|
||||
- id: duration_headless
|
||||
steps:
|
||||
- action: navigate
|
||||
args:
|
||||
url: "{{BaseURL}}/"
|
||||
- action: waitload
|
||||
matchers:
|
||||
- type: dsl
|
||||
condition: and
|
||||
dsl:
|
||||
- "duration > 0 && duration < 60"
|
||||
- "duration_1 > 0 && duration_1 < 60"
|
||||
- "duration_2 > 0 && duration_2 < 60"
|
||||
- "duration == duration_2"
|
||||
- "duration_headless_duration > 0 && duration_headless_duration < 60"
|
||||
- "duration_headless_duration_1 > 0 && duration_headless_duration_1 < 60"
|
||||
- "duration_headless_duration_2 > 0 && duration_headless_duration_2 < 60"
|
||||
- "duration_headless_duration == duration"
|
||||
- "duration_headless_duration_1 == duration_1"
|
||||
- "duration_headless_duration_2 == duration_2"
|
||||
- type: word
|
||||
words:
|
||||
- "<html>"
|
||||
matchers-condition: and
|
||||
@@ -0,0 +1,22 @@
|
||||
id: http-tls-metadata
|
||||
|
||||
info:
|
||||
name: HTTP TLS metadata from same connection
|
||||
author: pdteam
|
||||
severity: info
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}"
|
||||
matchers-condition: and
|
||||
matchers:
|
||||
- type: word
|
||||
words:
|
||||
- "test-ok"
|
||||
- type: dsl
|
||||
dsl:
|
||||
- sni == "test"
|
||||
- tls_version != ""
|
||||
- cipher != ""
|
||||
- len(to_string(domains)) > 0 || subject_cn != "" || len(to_string(subject_an)) > 0
|
||||
@@ -0,0 +1,37 @@
|
||||
id: grpc-network-policy-denied
|
||||
|
||||
info:
|
||||
name: gRPC Network Policy Denial
|
||||
author: pdteam
|
||||
severity: info
|
||||
description: |
|
||||
Hardening check for the nuclei/grpc javascript library: dialing a host on
|
||||
the exclude list (RFC 5737 TEST-NET-3) must be rejected by the network
|
||||
policy before any connection is made. The error is captured and asserted.
|
||||
|
||||
javascript:
|
||||
- code: |
|
||||
const grpc = require('nuclei/grpc');
|
||||
let out = "";
|
||||
try {
|
||||
const opts = new grpc.Options();
|
||||
opts.Plaintext = true;
|
||||
const client = new grpc.Client("203.0.113.10:50051", opts);
|
||||
client.Invoke('grpc.health.v1.Health/Check', '{}');
|
||||
} catch (e) {
|
||||
out = "" + e;
|
||||
}
|
||||
out;
|
||||
args:
|
||||
Host: "{{Host}}"
|
||||
matchers-condition: and
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- "success == true"
|
||||
- type: word
|
||||
part: response
|
||||
words:
|
||||
- "network policy"
|
||||
- "203.0.113.10"
|
||||
condition: and
|
||||
@@ -0,0 +1,31 @@
|
||||
id: grpc-health-check
|
||||
|
||||
info:
|
||||
name: gRPC Health Check
|
||||
author: pdteam
|
||||
severity: info
|
||||
description: |
|
||||
Exercises the nuclei/grpc javascript library end to end: dial through the
|
||||
network policy, resolve the schema via server reflection and invoke the
|
||||
standard health check, expecting a SERVING response.
|
||||
|
||||
javascript:
|
||||
- code: |
|
||||
const grpc = require('nuclei/grpc');
|
||||
const opts = new grpc.Options();
|
||||
opts.Plaintext = true;
|
||||
const client = new grpc.Client(address, opts);
|
||||
const resp = client.Invoke('grpc.health.v1.Health/Check', '{"service":""}');
|
||||
client.Close();
|
||||
resp;
|
||||
args:
|
||||
address: "{{Host}}:{{Port}}"
|
||||
matchers-condition: and
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- "success == true"
|
||||
- type: word
|
||||
part: response
|
||||
words:
|
||||
- "SERVING"
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
id: js-http-client-flow
|
||||
|
||||
info:
|
||||
name: nuclei/http client flow
|
||||
author: pdteam
|
||||
severity: info
|
||||
description: |
|
||||
Exercises nuclei/http Client for Post, cookie jar reuse, and redirect
|
||||
following against a local HTTP server.
|
||||
|
||||
javascript:
|
||||
- code: |
|
||||
const http = require('nuclei/http');
|
||||
const client = new http.Client();
|
||||
const post = client.Post(BaseURL + '/echo', 'payload');
|
||||
client.Get(BaseURL + '/set-cookie');
|
||||
const cookie = client.Get(BaseURL + '/echo-cookie');
|
||||
const redir = client.Get(BaseURL + '/redir');
|
||||
post.Body + '|' + cookie.Body + '|' + redir.StatusCode + '|' + redir.Body;
|
||||
args:
|
||||
BaseURL: "http://{{Host}}:{{Port}}"
|
||||
matchers-condition: and
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- "success == true"
|
||||
- type: word
|
||||
part: response
|
||||
words:
|
||||
- "payload|sid-ok|200|landed"
|
||||
@@ -0,0 +1,33 @@
|
||||
id: js-http-network-policy-denied
|
||||
|
||||
info:
|
||||
name: nuclei/http network policy denial
|
||||
author: pdteam
|
||||
severity: info
|
||||
description: |
|
||||
Hardening check for nuclei/http: requesting a host on the exclude list
|
||||
(RFC 5737 TEST-NET-3) must be rejected by network policy before dialing.
|
||||
|
||||
javascript:
|
||||
- code: |
|
||||
const http = require('nuclei/http');
|
||||
let out = "";
|
||||
try {
|
||||
http.Get('http://203.0.113.10/');
|
||||
} catch (e) {
|
||||
out = "" + e;
|
||||
}
|
||||
out;
|
||||
args:
|
||||
Host: "{{Host}}"
|
||||
matchers-condition: and
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- "success == true"
|
||||
- type: word
|
||||
part: response
|
||||
words:
|
||||
- "network policy"
|
||||
- "203.0.113.10"
|
||||
condition: and
|
||||
@@ -0,0 +1,29 @@
|
||||
id: js-http-get
|
||||
|
||||
info:
|
||||
name: nuclei/http GET
|
||||
author: pdteam
|
||||
severity: info
|
||||
description: |
|
||||
Exercises nuclei/http package Get and Client.Get/SetHeader against a local
|
||||
HTTP server (status, body, response headers, default request header).
|
||||
|
||||
javascript:
|
||||
- code: |
|
||||
const http = require('nuclei/http');
|
||||
const a = http.Get(BaseURL + '/hello');
|
||||
const client = new http.Client();
|
||||
client.SetHeader('X-Token', 'nuclei');
|
||||
const b = client.Get(BaseURL + '/echo-header');
|
||||
a.StatusCode + '|' + a.Body + '|' + a.GetHeader('X-Test') + '|' + b.Body;
|
||||
args:
|
||||
BaseURL: "http://{{Host}}:{{Port}}"
|
||||
matchers-condition: and
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- "success == true"
|
||||
- type: word
|
||||
part: response
|
||||
words:
|
||||
- "200|hello|ok|nuclei"
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
id: mssql-fingerprint
|
||||
|
||||
info:
|
||||
name: MSSQL Fingerprint Test
|
||||
author: pdteam
|
||||
severity: info
|
||||
|
||||
javascript:
|
||||
- pre-condition: |
|
||||
isPortOpen(Host, Port)
|
||||
code: |
|
||||
const mssql = require('nuclei/mssql');
|
||||
const client = new mssql.MSSQLClient();
|
||||
const isMssql = client.IsMssql(Host, Port);
|
||||
const info = client.FingerprintMssql(Host, Port);
|
||||
const ok = isMssql && info && info.Version && info.Version.length > 0 && info.MajorVersion > 0 && info.EncryptionMode && info.EncryptionMode.length > 0 && info.Protocol === "mssql";
|
||||
Export({
|
||||
ok: ok,
|
||||
isMssql: isMssql,
|
||||
version: info.Version,
|
||||
majorVersion: info.MajorVersion,
|
||||
minorVersion: info.MinorVersion,
|
||||
buildNumber: info.BuildNumber,
|
||||
encryptionMode: info.EncryptionMode,
|
||||
protocol: info.Protocol,
|
||||
transport: info.Transport,
|
||||
});
|
||||
args:
|
||||
Host: "{{Host}}"
|
||||
Port: "1433"
|
||||
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- "success == true"
|
||||
- "ok == true"
|
||||
- "isMssql == true"
|
||||
- "len(version) > 0"
|
||||
- "majorVersion > 0"
|
||||
- 'protocol == "mssql"'
|
||||
- 'transport == "tcp"'
|
||||
- "len(encryptionMode) > 0"
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
id: mysql-fingerprint
|
||||
|
||||
info:
|
||||
name: MySQL Fingerprint Test
|
||||
author: pdteam
|
||||
severity: info
|
||||
|
||||
javascript:
|
||||
- pre-condition: |
|
||||
isPortOpen(Host, Port)
|
||||
code: |
|
||||
const mysql = require('nuclei/mysql');
|
||||
const client = new mysql.MySQLClient();
|
||||
const isMySQL = client.IsMySQL(Host, Port);
|
||||
const info = client.FingerprintMySQL(Host, Port);
|
||||
const ok = isMySQL && info && info.ProtocolVersion === 10 && info.Version && info.Version.length > 0 && info.Salt && info.Salt.length > 0 && info.CapabilityFlags > 0 && info.Debug && info.Debug.PacketType === 'handshake';
|
||||
Export({
|
||||
ok: ok,
|
||||
isMySQL: isMySQL,
|
||||
packetType: info.Debug.PacketType,
|
||||
protocolVersion: info.ProtocolVersion,
|
||||
version: info.Version,
|
||||
threadId: info.ThreadID,
|
||||
authPluginName: info.AuthPluginName,
|
||||
capabilityFlags: info.CapabilityFlags,
|
||||
capabilities: info.Capabilities,
|
||||
status: info.Status,
|
||||
salt: info.Salt,
|
||||
});
|
||||
args:
|
||||
Host: "{{Host}}"
|
||||
Port: "3306"
|
||||
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- "success == true"
|
||||
- "ok == true"
|
||||
- "isMySQL == true"
|
||||
- 'packetType == "handshake"'
|
||||
- "protocolVersion == 10"
|
||||
- "len(version) > 0"
|
||||
- "capabilityFlags > 0"
|
||||
- "len(salt) > 0"
|
||||
- "threadId > 0"
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
id: redis-lua-script
|
||||
|
||||
info:
|
||||
name: Redis RunLuaScript - Detect
|
||||
author: DhiyaneshDK
|
||||
severity: info
|
||||
|
||||
javascript:
|
||||
- code: |
|
||||
const redis = require('nuclei/redis');
|
||||
// Set a key via KEYS with a string and a numeric arg via ARGV: validates
|
||||
// that keys are passed through and non-string args are not dropped
|
||||
redis.RunLuaScript(Host, Port, Password, 'return redis.call("set", KEYS[1], ARGV[1] .. ":" .. ARGV[2])', ['testkey'], ['testvalue', 123]);
|
||||
|
||||
// Read the value back via KEYS and return it as the response so the
|
||||
// matcher can assert the combined string was stored correctly
|
||||
redis.RunLuaScript(Host, Port, Password, 'return redis.call("get", KEYS[1])', ['testkey'], []);
|
||||
|
||||
args:
|
||||
Host: "{{Host}}"
|
||||
Port: "6379"
|
||||
# must match the --requirepass value of the dockerized redis instance
|
||||
# started by internal/tests/integration/javascript_test.go
|
||||
Password: "iamadmin"
|
||||
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- 'success == true && contains(response, "testvalue:123")'
|
||||
@@ -0,0 +1,48 @@
|
||||
id: network-duration
|
||||
|
||||
info:
|
||||
name: Network Duration
|
||||
author: pdteam
|
||||
severity: info
|
||||
|
||||
flow: tcp("duration_network")
|
||||
|
||||
network:
|
||||
- id: duration_network
|
||||
inputs:
|
||||
- data: "FIRST"
|
||||
read: 4
|
||||
name: first
|
||||
- data: "SECOND"
|
||||
read: 4
|
||||
name: second
|
||||
host:
|
||||
- "{{Hostname}}"
|
||||
read-size: 6
|
||||
matchers:
|
||||
- type: dsl
|
||||
condition: and
|
||||
dsl:
|
||||
- "duration > 0 && duration < 60"
|
||||
- "duration_1 > 0 && duration_1 < 60"
|
||||
- "duration_2 > 0 && duration_2 < 60"
|
||||
- "duration == duration_2"
|
||||
- "duration_network_duration > 0 && duration_network_duration < 60"
|
||||
- "duration_network_duration_1 > 0 && duration_network_duration_1 < 60"
|
||||
- "duration_network_duration_2 > 0 && duration_network_duration_2 < 60"
|
||||
- "duration_network_duration == duration"
|
||||
- "duration_network_duration_1 == duration_1"
|
||||
- "duration_network_duration_2 == duration_2"
|
||||
- type: word
|
||||
part: first
|
||||
words:
|
||||
- "PING"
|
||||
- type: word
|
||||
part: second
|
||||
words:
|
||||
- "PONG"
|
||||
- type: word
|
||||
part: data
|
||||
words:
|
||||
- "NUCLEI"
|
||||
matchers-condition: and
|
||||
@@ -0,0 +1,20 @@
|
||||
id: ssl-duration
|
||||
|
||||
info:
|
||||
name: SSL Duration
|
||||
author: pdteam
|
||||
severity: info
|
||||
|
||||
flow: ssl("duration_ssl")
|
||||
|
||||
ssl:
|
||||
- id: duration_ssl
|
||||
address: "{{Host}}:{{Port}}"
|
||||
matchers:
|
||||
- type: dsl
|
||||
condition: and
|
||||
dsl:
|
||||
- "probe_status == true"
|
||||
- "duration > 0 && duration < 60"
|
||||
- "duration_ssl_duration > 0 && duration_ssl_duration < 60"
|
||||
- "duration == duration_ssl_duration"
|
||||
@@ -0,0 +1,36 @@
|
||||
id: websocket-duration
|
||||
|
||||
info:
|
||||
name: WebSocket Duration
|
||||
author: pdteam
|
||||
severity: info
|
||||
|
||||
flow: websocket("duration_ws")
|
||||
|
||||
websocket:
|
||||
- id: duration_ws
|
||||
address: "{{Scheme}}://{{Hostname}}"
|
||||
inputs:
|
||||
- data: hello
|
||||
- data: status
|
||||
matchers:
|
||||
- type: dsl
|
||||
condition: and
|
||||
dsl:
|
||||
- "duration > 0 && duration < 60"
|
||||
- "duration_1 > 0 && duration_1 < 60"
|
||||
- "duration_2 > 0 && duration_2 < 60"
|
||||
- "duration == duration_2"
|
||||
- "duration_ws_duration > 0 && duration_ws_duration < 60"
|
||||
- "duration_ws_duration_1 > 0 && duration_ws_duration_1 < 60"
|
||||
- "duration_ws_duration_2 > 0 && duration_ws_duration_2 < 60"
|
||||
- "duration_ws_duration == duration"
|
||||
- "duration_ws_duration_1 == duration_1"
|
||||
- "duration_ws_duration_2 == duration_2"
|
||||
- type: word
|
||||
part: response
|
||||
words:
|
||||
- world
|
||||
- ready
|
||||
condition: and
|
||||
matchers-condition: and
|
||||
@@ -0,0 +1,19 @@
|
||||
id: whois-duration
|
||||
|
||||
info:
|
||||
name: WHOIS Duration
|
||||
author: pdteam
|
||||
severity: info
|
||||
|
||||
flow: whois("duration_whois")
|
||||
|
||||
whois:
|
||||
- id: duration_whois
|
||||
query: "{{Host}}"
|
||||
matchers:
|
||||
- type: dsl
|
||||
condition: and
|
||||
dsl:
|
||||
- "duration > 0 && duration < 60"
|
||||
- "duration_whois_duration > 0 && duration_whois_duration < 60"
|
||||
- "duration == duration_whois_duration"
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gobwas/ws/wsutil"
|
||||
"github.com/projectdiscovery/nuclei/v3/internal/tests/testutils"
|
||||
@@ -38,6 +39,37 @@ func TestWebSocket(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Duration", func(t *testing.T) {
|
||||
connHandler := func(conn net.Conn) {
|
||||
for {
|
||||
msg, op, err := wsutil.ReadClientData(conn)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
switch string(msg) {
|
||||
case "hello":
|
||||
time.Sleep(integrationDurationObservationDelay)
|
||||
_ = wsutil.WriteServerMessage(conn, op, []byte("world"))
|
||||
case "status":
|
||||
time.Sleep(integrationDurationObservationDelay)
|
||||
_ = wsutil.WriteServerMessage(conn, op, []byte("ready"))
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
server := testutils.NewWebsocketServer("", connHandler, func(origin string) bool { return true })
|
||||
defer server.Close()
|
||||
|
||||
results, err := testutils.RunNucleiTemplateAndGetResults("protocols/websocket/duration.yaml", strings.ReplaceAll(server.URL, "http", "ws"), suite.debug)
|
||||
if err != nil {
|
||||
t.Fatalf("duration websocket request failed: %v", err)
|
||||
}
|
||||
if err := expectResultsCount(results, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CSWSH", func(t *testing.T) {
|
||||
server := testutils.NewWebsocketServer("", func(conn net.Conn) {}, func(origin string) bool { return true })
|
||||
defer server.Close()
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
var whoisTestCases = []integrationCase{
|
||||
{Path: "protocols/whois/basic.yaml", TestCase: &whoisBasic{}},
|
||||
{Path: "protocols/whois/duration.yaml", TestCase: &whoisBasic{}},
|
||||
}
|
||||
|
||||
type whoisBasic struct{}
|
||||
|
||||
@@ -339,6 +339,21 @@ func WithScanStrategy(strategy string) NucleiSDKOptions {
|
||||
// OutputWriter
|
||||
type OutputWriter output.Writer
|
||||
|
||||
// WithResultCallback sets a result callback invoked for each finding.
|
||||
//
|
||||
// When used with ThreadSafeNucleiEngine.ExecuteNucleiWithOpts / ExecuteNucleiWithOptsCtx,
|
||||
// the callback is scoped to that execution only (combined with any GlobalResultCallback).
|
||||
// When used at engine construction time, it behaves like the default result callback list.
|
||||
func WithResultCallback(callback func(event *output.ResultEvent)) NucleiSDKOptions {
|
||||
return func(e *NucleiEngine) error {
|
||||
if callback == nil {
|
||||
return nil
|
||||
}
|
||||
e.resultCallbacks = append(e.resultCallbacks, callback)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// UseOutputWriter allows setting custom output writer
|
||||
// by default a mock writer is used with user defined callback
|
||||
// if outputWriter is used callback will be ignored
|
||||
|
||||
+46
-12
@@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/logrusorgru/aurora/v4"
|
||||
"github.com/projectdiscovery/nuclei/v3/internal/tests/testutils"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/loader"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/core"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/input/provider"
|
||||
@@ -22,14 +23,17 @@ import (
|
||||
// in ThreadSafeNucleiEngine
|
||||
type unsafeOptions struct {
|
||||
executerOpts *protocols.ExecutorOptions
|
||||
engine *core.Engine
|
||||
}
|
||||
|
||||
// createEphemeralObjects creates ephemeral nuclei objects/instances/types
|
||||
func createEphemeralObjects(ctx context.Context, base *NucleiEngine, opts *types.Options) (*unsafeOptions, error) {
|
||||
// createEphemeralObjects creates ephemeral nuclei objects/instances/types.
|
||||
// outputWriter overrides the base engine writer when non-nil (used for per-call callbacks).
|
||||
func createEphemeralObjects(ctx context.Context, base *NucleiEngine, opts *types.Options, outputWriter output.Writer) (*unsafeOptions, error) {
|
||||
if outputWriter == nil {
|
||||
outputWriter = base.customWriter
|
||||
}
|
||||
u := &unsafeOptions{}
|
||||
u.executerOpts = &protocols.ExecutorOptions{
|
||||
Output: base.customWriter,
|
||||
Output: outputWriter,
|
||||
Options: opts,
|
||||
Progress: base.customProgress,
|
||||
Catalog: base.catalog,
|
||||
@@ -40,6 +44,11 @@ func createEphemeralObjects(ctx context.Context, base *NucleiEngine, opts *types
|
||||
ResumeCfg: types.NewResumeCfg(),
|
||||
Parser: base.parser,
|
||||
Browser: base.browserInstance,
|
||||
// Thread-safe executes can run concurrently with different Output writers.
|
||||
// The compiled-template cache shallow-copies requests and mutates shared
|
||||
// ExecutorOptions.Output via UpdateOptions/ApplyNewEngineOptions, which
|
||||
// would otherwise route all findings to whichever call last won the race.
|
||||
DoNotCache: true,
|
||||
}
|
||||
if opts.ShouldUseHostError() && base.hostErrCache != nil {
|
||||
u.executerOpts.HostErrorsCache = base.hostErrCache
|
||||
@@ -52,11 +61,33 @@ func createEphemeralObjects(ctx context.Context, base *NucleiEngine, opts *types
|
||||
opts.RateLimitDuration = time.Second
|
||||
}
|
||||
u.executerOpts.RateLimiter = utils.GetRateLimiter(ctx, opts.RateLimit, opts.RateLimitDuration)
|
||||
u.engine = core.New(opts)
|
||||
u.engine.SetExecuterOptions(u.executerOpts)
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// resolveEphemeralOutput combines the base/global writer with any per-call result callbacks.
|
||||
// Global callbacks keep working; per-call callbacks are isolated to this execution.
|
||||
func resolveEphemeralOutput(base, call *NucleiEngine) output.Writer {
|
||||
out := base.customWriter
|
||||
if call == nil || len(call.resultCallbacks) == 0 {
|
||||
return out
|
||||
}
|
||||
|
||||
callbacks := append([]func(event *output.ResultEvent){}, call.resultCallbacks...)
|
||||
callWriter := testutils.NewMockOutputWriter(call.opts.OmitTemplate)
|
||||
callWriter.WriteCallback = func(event *output.ResultEvent) {
|
||||
for _, cb := range callbacks {
|
||||
if cb != nil {
|
||||
cb(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if out == nil {
|
||||
return callWriter
|
||||
}
|
||||
return output.NewMultiWriter(out, callWriter)
|
||||
}
|
||||
|
||||
// closeEphemeralObjects closes all resources used by ephemeral nuclei objects/instances/types
|
||||
func closeEphemeralObjects(u *unsafeOptions) {
|
||||
if u.executerOpts.RateLimiter != nil {
|
||||
@@ -113,14 +144,15 @@ func (e *ThreadSafeNucleiEngine) GlobalLoadAllTemplates() error {
|
||||
}
|
||||
|
||||
// GlobalResultCallback sets a callback function which will be called for each result
|
||||
// across all ExecuteNucleiWithOpts invocations. For a callback scoped to a single
|
||||
// execution, pass WithResultCallback in that call's opts instead.
|
||||
func (e *ThreadSafeNucleiEngine) GlobalResultCallback(callback func(event *output.ResultEvent)) {
|
||||
e.eng.resultCallbacks = []func(*output.ResultEvent){callback}
|
||||
}
|
||||
|
||||
// ExecuteNucleiWithOptsCtx executes templates on targets and calls callback on each result(only if results are found)
|
||||
// This method can be called concurrently and it will use some global resources but can be run parallelly
|
||||
// by invoking this method with different options and targets
|
||||
// Note: Not all options are thread-safe. this method will throw error if you try to use non-thread-safe options
|
||||
// ExecuteNucleiWithOptsCtx executes templates on targets and can be called concurrently.
|
||||
// Pass WithResultCallback in opts for a per-execution result callback (combined with any
|
||||
// GlobalResultCallback). Not all options are thread-safe.
|
||||
func (e *ThreadSafeNucleiEngine) ExecuteNucleiWithOptsCtx(ctx context.Context, targets []string, opts ...NucleiSDKOptions) error {
|
||||
baseOpts := e.eng.opts.Copy()
|
||||
tmpEngine := &NucleiEngine{opts: baseOpts, mode: threadSafe}
|
||||
@@ -131,7 +163,7 @@ func (e *ThreadSafeNucleiEngine) ExecuteNucleiWithOptsCtx(ctx context.Context, t
|
||||
}
|
||||
|
||||
// create ephemeral nuclei objects/instances/types using base nuclei engine
|
||||
unsafeOpts, err := createEphemeralObjects(ctx, e.eng, tmpEngine.opts)
|
||||
unsafeOpts, err := createEphemeralObjects(ctx, e.eng, tmpEngine.opts, resolveEphemeralOutput(e.eng, tmpEngine))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -145,7 +177,9 @@ func (e *ThreadSafeNucleiEngine) ExecuteNucleiWithOptsCtx(ctx context.Context, t
|
||||
}
|
||||
unsafeOpts.executerOpts.WorkflowLoader = workflowLoader
|
||||
|
||||
store, err := loader.New(loader.NewConfig(tmpEngine.opts, e.eng.catalog, unsafeOpts.executerOpts))
|
||||
loaderConfig := loader.NewConfig(tmpEngine.opts, e.eng.catalog, unsafeOpts.executerOpts)
|
||||
loaderConfig.MetadataIndex = e.eng.getMetadataIndex()
|
||||
store, err := loader.New(loaderConfig)
|
||||
if err != nil {
|
||||
return errkit.Wrapf(err, "Could not create loader client: %s", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package nuclei
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/index"
|
||||
)
|
||||
|
||||
func BenchmarkExecuteNucleiWithOptsCtx(b *testing.B) {
|
||||
templatePath := filepath.Join(b.TempDir(), "benchmark.yaml")
|
||||
err := os.WriteFile(templatePath, []byte(`id: benchmark-execute-nuclei-with-opts-ctx
|
||||
|
||||
info:
|
||||
name: Benchmark ExecuteNucleiWithOptsCtx
|
||||
author: pdteam
|
||||
severity: info
|
||||
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}"
|
||||
matchers:
|
||||
- type: status
|
||||
status:
|
||||
- 418
|
||||
`), 0o600)
|
||||
if err != nil {
|
||||
b.Fatalf("could not write benchmark template: %s", err)
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
b.Cleanup(server.Close)
|
||||
|
||||
metadataIndex, err := index.NewIndex(b.TempDir())
|
||||
if err != nil {
|
||||
b.Fatalf("could not create benchmark metadata index: %s", err)
|
||||
}
|
||||
|
||||
engine, err := NewThreadSafeNucleiEngineCtx(
|
||||
b.Context(),
|
||||
DisableUpdateCheck(),
|
||||
WithTemplatesOrWorkflows(TemplateSources{Templates: []string{templatePath}}),
|
||||
)
|
||||
if err != nil {
|
||||
b.Fatalf("could not create thread-safe nuclei engine: %s", err)
|
||||
}
|
||||
b.Cleanup(engine.Close)
|
||||
|
||||
// Keep benchmark metadata isolated from the user's persistent cache.
|
||||
engine.eng.metadataIndexOnce.Do(func() {
|
||||
engine.eng.metadataIndex = metadataIndex
|
||||
})
|
||||
|
||||
targets := []string{server.URL}
|
||||
if err := engine.ExecuteNucleiWithOptsCtx(b.Context(), targets); err != nil {
|
||||
b.Fatalf("could not warm up nuclei execution: %s", err)
|
||||
}
|
||||
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
|
||||
for b.Loop() {
|
||||
if err := engine.ExecuteNucleiWithOptsCtx(b.Context(), targets); err != nil {
|
||||
b.Fatalf("could not execute nuclei: %s", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package nuclei_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
nuclei "github.com/projectdiscovery/nuclei/v3/lib"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/output"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestThreadSafeWithResultCallbackIsolation(t *testing.T) {
|
||||
templatePath := writeSDKMatchTemplate(t)
|
||||
|
||||
srvA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("token-A-unique"))
|
||||
}))
|
||||
t.Cleanup(srvA.Close)
|
||||
|
||||
srvB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("token-B-unique"))
|
||||
}))
|
||||
t.Cleanup(srvB.Close)
|
||||
|
||||
ne, err := nuclei.NewThreadSafeNucleiEngineCtx(context.Background())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(ne.Close)
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
callbackA []string
|
||||
callbackB []string
|
||||
globalURLs []string
|
||||
globalCount int
|
||||
errA, errB error
|
||||
)
|
||||
|
||||
ne.GlobalResultCallback(func(event *output.ResultEvent) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
globalCount++
|
||||
globalURLs = append(globalURLs, eventMatchedURL(event))
|
||||
})
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(2)
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
errA = ne.ExecuteNucleiWithOptsCtx(context.Background(), []string{srvA.URL},
|
||||
nuclei.WithTemplatesOrWorkflows(nuclei.TemplateSources{Templates: []string{templatePath}}),
|
||||
nuclei.WithTemplateFilters(nuclei.TemplateFilters{IDs: []string{"sdk-callback-match"}}),
|
||||
nuclei.WithResultCallback(func(event *output.ResultEvent) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
callbackA = append(callbackA, eventMatchedURL(event))
|
||||
}),
|
||||
)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
errB = ne.ExecuteNucleiWithOptsCtx(context.Background(), []string{srvB.URL},
|
||||
nuclei.WithTemplatesOrWorkflows(nuclei.TemplateSources{Templates: []string{templatePath}}),
|
||||
nuclei.WithTemplateFilters(nuclei.TemplateFilters{IDs: []string{"sdk-callback-match"}}),
|
||||
nuclei.WithResultCallback(func(event *output.ResultEvent) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
callbackB = append(callbackB, eventMatchedURL(event))
|
||||
}),
|
||||
)
|
||||
}()
|
||||
|
||||
wg.Wait()
|
||||
|
||||
require.NoError(t, errA)
|
||||
require.NoError(t, errB)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
require.NotEmpty(t, callbackA, "per-call callback A should receive results")
|
||||
require.NotEmpty(t, callbackB, "per-call callback B should receive results")
|
||||
for _, matched := range callbackA {
|
||||
require.Contains(t, matched, srvA.URL, "callback A must only see server A")
|
||||
require.NotContains(t, matched, srvB.URL, "callback A must not see server B")
|
||||
}
|
||||
for _, matched := range callbackB {
|
||||
require.Contains(t, matched, srvB.URL, "callback B must only see server B")
|
||||
require.NotContains(t, matched, srvA.URL, "callback B must not see server A")
|
||||
}
|
||||
|
||||
// Global callback still sees both executions (no regression).
|
||||
require.GreaterOrEqual(t, globalCount, 2)
|
||||
require.NotEmpty(t, globalURLs)
|
||||
}
|
||||
|
||||
func TestThreadSafeGlobalCallbackWithoutPerCallStillWorks(t *testing.T) {
|
||||
templatePath := writeSDKMatchTemplate(t)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("token-A-unique"))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
ne, err := nuclei.NewThreadSafeNucleiEngineCtx(context.Background())
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(ne.Close)
|
||||
|
||||
var got int
|
||||
ne.GlobalResultCallback(func(event *output.ResultEvent) {
|
||||
got++
|
||||
})
|
||||
|
||||
err = ne.ExecuteNucleiWithOptsCtx(context.Background(), []string{srv.URL},
|
||||
nuclei.WithTemplatesOrWorkflows(nuclei.TemplateSources{Templates: []string{templatePath}}),
|
||||
nuclei.WithTemplateFilters(nuclei.TemplateFilters{IDs: []string{"sdk-callback-match"}}),
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.GreaterOrEqual(t, got, 1, "global callback should still receive results without WithResultCallback")
|
||||
}
|
||||
|
||||
func TestWithResultCallbackNilIsNoop(t *testing.T) {
|
||||
ne, err := nuclei.NewThreadSafeNucleiEngineCtx(context.Background(), nuclei.WithResultCallback(nil))
|
||||
require.NoError(t, err)
|
||||
ne.Close()
|
||||
}
|
||||
|
||||
func writeSDKMatchTemplate(t *testing.T) string {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "sdk-callback-match.yaml")
|
||||
content := `id: sdk-callback-match
|
||||
info:
|
||||
name: SDK callback match
|
||||
author: nuclei-sdk-test
|
||||
severity: info
|
||||
http:
|
||||
- method: GET
|
||||
path:
|
||||
- "{{BaseURL}}/"
|
||||
matchers:
|
||||
- type: word
|
||||
words:
|
||||
- "token-"
|
||||
`
|
||||
require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
|
||||
return path
|
||||
}
|
||||
|
||||
// eventMatchedURL prefers Matched/URL over Host (Host omits the port).
|
||||
func eventMatchedURL(event *output.ResultEvent) string {
|
||||
if event == nil {
|
||||
return ""
|
||||
}
|
||||
if event.Matched != "" {
|
||||
return event.Matched
|
||||
}
|
||||
if event.URL != "" {
|
||||
return event.URL
|
||||
}
|
||||
return event.Host
|
||||
}
|
||||
+32
-1
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/projectdiscovery/gologger"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/authprovider"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/catalog"
|
||||
metadataindex "github.com/projectdiscovery/nuclei/v3/pkg/catalog/index"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/loader"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/core"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/input/provider"
|
||||
@@ -83,6 +84,9 @@ type NucleiEngine struct {
|
||||
ownsParser bool // true when the engine created the parser and may purge it on Close
|
||||
authprovider authprovider.AuthProvider
|
||||
|
||||
metadataIndex *metadataindex.Index
|
||||
metadataIndexOnce sync.Once
|
||||
|
||||
// unexported meta options
|
||||
opts *types.Options
|
||||
interactshOpts *interactsh.Options
|
||||
@@ -100,6 +104,24 @@ type NucleiEngine struct {
|
||||
tmpDir string
|
||||
}
|
||||
|
||||
func (e *NucleiEngine) getMetadataIndex() *metadataindex.Index {
|
||||
e.metadataIndexOnce.Do(func() {
|
||||
metadataIndex, err := metadataindex.NewDefaultIndex()
|
||||
if err != nil {
|
||||
e.Logger.Warning().Msgf("Could not create metadata cache: %v", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
e.metadataIndex = metadataIndex
|
||||
if err := e.metadataIndex.Load(); err != nil {
|
||||
e.Logger.Warning().Msgf("Could not load metadata cache: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
return e.metadataIndex
|
||||
}
|
||||
|
||||
// LoadAllTemplates loads all nuclei template based on given options
|
||||
func (e *NucleiEngine) LoadAllTemplates() error {
|
||||
workflowLoader, err := workflow.NewLoader(e.executerOpts)
|
||||
@@ -108,7 +130,11 @@ func (e *NucleiEngine) LoadAllTemplates() error {
|
||||
}
|
||||
e.executerOpts.WorkflowLoader = workflowLoader
|
||||
|
||||
e.store, err = loader.New(loader.NewConfig(e.opts, e.catalog, e.executerOpts))
|
||||
loaderConfig := loader.NewConfig(e.opts, e.catalog, e.executerOpts)
|
||||
if e.mode == threadSafe {
|
||||
loaderConfig.MetadataIndex = e.getMetadataIndex()
|
||||
}
|
||||
e.store, err = loader.New(loaderConfig)
|
||||
if err != nil {
|
||||
return errkit.Wrapf(err, "Could not create loader client: %s", err)
|
||||
}
|
||||
@@ -239,6 +265,11 @@ func (e *NucleiEngine) closeInternal() {
|
||||
if e.httpxClient != nil {
|
||||
_ = e.httpxClient.Close()
|
||||
}
|
||||
if e.metadataIndex != nil {
|
||||
if err := e.metadataIndex.Save(); err != nil {
|
||||
e.Logger.Warning().Msgf("Could not save metadata cache: %v", err)
|
||||
}
|
||||
}
|
||||
if e.tmpDir != "" {
|
||||
_ = os.RemoveAll(e.tmpDir)
|
||||
}
|
||||
|
||||
+21
-18
@@ -18,11 +18,16 @@ var knownLeaks = []goleak.Option{
|
||||
// net/http transport maintains idle keep-alive connections whose goroutines
|
||||
// exit on idle timeout or explicit close - not real leaks.
|
||||
goleak.IgnoreAnyFunction("net/http.(*http2ClientConn).readLoop"),
|
||||
goleak.IgnoreAnyFunction("net/http.(*persistConn).readLoop"),
|
||||
goleak.IgnoreAnyFunction("net/http.(*persistConn).writeLoop"),
|
||||
// expirable LRU cache creates a background goroutine for TTL expiration that persists
|
||||
// see: https://github.com/hashicorp/golang-lru/blob/770151e9c8cdfae1797826b7b74c33d6f103fbd8/expirable/expirable_lru.go#L79
|
||||
goleak.IgnoreAnyContainingPkg("github.com/hashicorp/golang-lru/v2/expirable"),
|
||||
goleak.IgnoreAnyFunction("net/http.(*persistConn).readLoop"),
|
||||
goleak.IgnoreAnyFunction("net/http.(*persistConn).writeLoop"),
|
||||
// httpcache leveldb + shared rate limiter + memguardian outlive a single SDK engine.
|
||||
// Sleep alone is not reliable under -race or when NewNucleiEngineCtx fails before Close.
|
||||
goleak.IgnoreAnyContainingPkg("github.com/syndtr/goleveldb"),
|
||||
goleak.IgnoreAnyContainingPkg("github.com/projectdiscovery/ratelimit"),
|
||||
goleak.IgnoreAnyFunction("github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate.StartActiveMemGuardian.func1"),
|
||||
}
|
||||
|
||||
func TestSimpleNuclei(t *testing.T) {
|
||||
@@ -38,12 +43,12 @@ func TestSimpleNuclei(t *testing.T) {
|
||||
nuclei.WithTemplateFilters(nuclei.TemplateFilters{ProtocolTypes: "dns"}), // filter dns templates
|
||||
nuclei.EnableStatsWithOpts(nuclei.StatsOptions{JSON: true}),
|
||||
)
|
||||
require.Nil(t, err)
|
||||
require.NoError(t, err)
|
||||
defer ne.Close()
|
||||
ne.LoadTargets([]string{"scanme.sh"}, false) // probe non http/https target is set to false here
|
||||
// when callback is nil it nuclei will print JSON output to stdout
|
||||
err = ne.ExecuteWithCallback(nil)
|
||||
require.Nil(t, err)
|
||||
defer ne.Close()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// this is shared test so needs to be run as separate process
|
||||
@@ -76,14 +81,14 @@ func TestSimpleNucleiRemote(t *testing.T) {
|
||||
},
|
||||
),
|
||||
)
|
||||
require.Nil(t, err)
|
||||
require.NoError(t, err)
|
||||
defer ne.Close()
|
||||
ne.LoadTargets([]string{"scanme.sh"}, false) // probe non http/https target is set to false here
|
||||
err = ne.LoadAllTemplates()
|
||||
require.Nil(t, err, "could not load templates")
|
||||
require.NoError(t, err, "could not load templates")
|
||||
// when callback is nil it nuclei will print JSON output to stdout
|
||||
err = ne.ExecuteWithCallback(nil)
|
||||
require.Nil(t, err)
|
||||
defer ne.Close()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
// this is shared test so needs to be run as separate process
|
||||
if env.GetEnvOrDefault("TestSimpleNucleiRemote", false) {
|
||||
@@ -108,22 +113,20 @@ func TestThreadSafeNuclei(t *testing.T) {
|
||||
}()
|
||||
// create nuclei engine with options
|
||||
ne, err := nuclei.NewThreadSafeNucleiEngineCtx(context.TODO())
|
||||
require.Nil(t, err)
|
||||
require.NoError(t, err)
|
||||
defer ne.Close()
|
||||
|
||||
// scan 1 = run dns templates on scanme.sh
|
||||
t.Run("scanme.sh", func(t *testing.T) {
|
||||
err = ne.ExecuteNucleiWithOpts([]string{"scanme.sh"}, nuclei.WithTemplateFilters(nuclei.TemplateFilters{ProtocolTypes: "dns"}))
|
||||
require.Nil(t, err)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
// scan 2 = run dns templates on honey.scanme.sh
|
||||
t.Run("honey.scanme.sh", func(t *testing.T) {
|
||||
err = ne.ExecuteNucleiWithOpts([]string{"honey.scanme.sh"}, nuclei.WithTemplateFilters(nuclei.TemplateFilters{ProtocolTypes: "dns"}))
|
||||
require.Nil(t, err)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
// wait for all scans to finish
|
||||
defer ne.Close()
|
||||
}
|
||||
|
||||
if env.GetEnvOrDefault("TestThreadSafeNuclei", false) {
|
||||
@@ -153,11 +156,11 @@ func TestWithVarsNuclei(t *testing.T) {
|
||||
nuclei.WithVars([]string{"token=foobar"}),
|
||||
nuclei.WithVerbosity(nuclei.VerbosityOptions{Debug: true}),
|
||||
)
|
||||
require.Nil(t, err)
|
||||
require.NoError(t, err)
|
||||
defer ne.Close()
|
||||
ne.LoadTargets([]string{"scanme.sh"}, true) // probe http/https target is set to true here
|
||||
err = ne.ExecuteWithCallback(nil)
|
||||
require.Nil(t, err)
|
||||
defer ne.Close()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
// this is shared test so needs to be run as separate process
|
||||
if env.GetEnvOrDefault("TestWithVarsNuclei", false) {
|
||||
|
||||
+31
-1
@@ -122,7 +122,12 @@
|
||||
"CAA",
|
||||
"TLSA",
|
||||
"ANY",
|
||||
"SRV"
|
||||
"SRV",
|
||||
"RRSIG",
|
||||
"NSEC",
|
||||
"DNSKEY",
|
||||
"NSEC3",
|
||||
"NSEC3PARAM"
|
||||
],
|
||||
"title": "type of DNS request to make",
|
||||
"description": "Type is the type of DNS request to make"
|
||||
@@ -468,6 +473,31 @@
|
||||
"type": "boolean",
|
||||
"title": "do not perform recursion",
|
||||
"description": "Specifies whether to not do recursive checks if folders are provided"
|
||||
},
|
||||
"smb-user": {
|
||||
"type": "string",
|
||||
"title": "SMB username",
|
||||
"description": "Username for SMB file targets"
|
||||
},
|
||||
"smb-password": {
|
||||
"type": "string",
|
||||
"title": "SMB password",
|
||||
"description": "Password for SMB file targets"
|
||||
},
|
||||
"smb-domain": {
|
||||
"type": "string",
|
||||
"title": "SMB domain",
|
||||
"description": "Domain for SMB authentication"
|
||||
},
|
||||
"smb-hash": {
|
||||
"type": "string",
|
||||
"title": "SMB NT hash",
|
||||
"description": "NT hash for SMB pass-the-hash"
|
||||
},
|
||||
"smb-port": {
|
||||
"type": "integer",
|
||||
"title": "SMB port",
|
||||
"description": "TCP port for SMB (default 445)"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
package authx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/projectdiscovery/gologger"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/authprovider/autologin"
|
||||
"github.com/projectdiscovery/utils/errkit"
|
||||
)
|
||||
|
||||
// AutoLoginConfig declares a turnkey, template-free authenticated-scanning
|
||||
// login. Instead of pointing at a hand-written auth template (Dynamic.TemplatePath),
|
||||
// the user supplies only a login URL and credentials; nuclei fetches the login
|
||||
// page, auto-detects the form (username/password fields plus hidden CSRF tokens)
|
||||
// and submits it, capturing the resulting session. The captured session is fed
|
||||
// into the same Dynamic state machine, so refresh-interval and
|
||||
// reauth-status-codes re-run the login automatically.
|
||||
type AutoLoginConfig struct {
|
||||
// LoginURL is the page serving the login form (required).
|
||||
LoginURL string `json:"login-url" yaml:"login-url"`
|
||||
// Username / Password are the credentials to submit. Username may be empty
|
||||
// for password-only forms.
|
||||
Username string `json:"username" yaml:"username"`
|
||||
Password string `json:"password" yaml:"password"`
|
||||
// UsernameField / PasswordField force the form field names when
|
||||
// auto-detection would guess wrong (optional).
|
||||
UsernameField string `json:"username-field" yaml:"username-field"`
|
||||
PasswordField string `json:"password-field" yaml:"password-field"`
|
||||
// ExtraFields are additional form values submitted verbatim (e.g. a tenant
|
||||
// id), overriding detected fields of the same name.
|
||||
ExtraFields []KV `json:"extra-fields" yaml:"extra-fields"`
|
||||
// TokenRegex, when set, extracts a bearer token (first capture group) from
|
||||
// the final response body for token-based logins that don't set a cookie.
|
||||
TokenRegex string `json:"token-regex" yaml:"token-regex"`
|
||||
|
||||
// Headless drives a real browser for the login instead of the HTTP engine,
|
||||
// enabling JS-rendered / SPA / multi-step / SSO login pages.
|
||||
Headless bool `json:"headless" yaml:"headless"`
|
||||
// ShowBrowser runs the headless browser headful (debugging only).
|
||||
ShowBrowser bool `json:"show-browser" yaml:"show-browser"`
|
||||
// UseInstalledChrome forces a system-installed Chrome for the headless login.
|
||||
UseInstalledChrome bool `json:"use-installed-chrome" yaml:"use-installed-chrome"`
|
||||
// Proxy routes the headless login's browser traffic through this proxy URL.
|
||||
Proxy string `json:"proxy" yaml:"proxy"`
|
||||
// Steps, when set, drives an explicit multi-step headless login flow
|
||||
// (username-first / SSO / consent screens) instead of single-shot detection.
|
||||
Steps []autologin.LoginStep `json:"steps" yaml:"steps"`
|
||||
// Recording is a path to a Chrome DevTools Recorder / @puppeteer/replay JSON
|
||||
// export of a real login. It is compiled into Steps at validation time, so a
|
||||
// complex login can be authored by recording in a browser instead of writing
|
||||
// steps by hand. Implies a headless login. Recorded credential literals are
|
||||
// replaced with {{username}}/{{password}} placeholders.
|
||||
Recording string `json:"recording" yaml:"recording"`
|
||||
}
|
||||
|
||||
// Validate validates the auto-login configuration.
|
||||
func (a *AutoLoginConfig) Validate() error {
|
||||
// A recording compiles into Steps and drives a headless login. Load it first
|
||||
// so a missing/invalid recording fails fast and so it can supply the login
|
||||
// URL when one isn't given explicitly.
|
||||
if a.Recording != "" {
|
||||
steps, err := autologin.StepsFromRecordingFile(a.Recording, a.Username, a.Password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
a.Steps = steps
|
||||
a.Headless = true
|
||||
if a.LoginURL == "" {
|
||||
a.LoginURL = autologin.FirstNavigateURL(steps)
|
||||
}
|
||||
}
|
||||
if a.LoginURL == "" {
|
||||
return errkit.New("login-url is required for auto-login dynamic secret")
|
||||
}
|
||||
if a.Password == "" {
|
||||
return errkit.New("password is required for auto-login dynamic secret")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AutoLoginRuntimeOptions carries scan-level browser configuration (from
|
||||
// types.Options) into the auto-login engine so a headless login uses the same
|
||||
// identity (user-agent, headers), network path (proxy, CDP endpoint) and Chrome
|
||||
// settings as the scan. Per-secret YAML fields take precedence over these.
|
||||
type AutoLoginRuntimeOptions struct {
|
||||
HTTPClient *http.Client
|
||||
UserAgent string
|
||||
CustomHeaders map[string]string
|
||||
Proxy string
|
||||
CDPEndpoint string
|
||||
UseInstalledChrome bool
|
||||
ShowBrowser bool
|
||||
}
|
||||
|
||||
// SetAutoLoginCallback installs the fetch callback that performs the form-based
|
||||
// auto-login and renders the captured session into the secret. Unlike
|
||||
// SetLazyFetchCallback, it does not run the template-substitution wrapper: the
|
||||
// applied secret is built directly from the captured cookies/token, so it is
|
||||
// simply overwritten on each (re-)authentication.
|
||||
//
|
||||
// rt carries optional scan-level browser/runtime options (user-agent, headers,
|
||||
// proxy, CDP endpoint, HTTP client); pass nil for defaults.
|
||||
func (d *Dynamic) SetAutoLoginCallback(rt *AutoLoginRuntimeOptions) {
|
||||
if rt == nil {
|
||||
rt = &AutoLoginRuntimeOptions{}
|
||||
}
|
||||
d.autoLoginClient = rt.HTTPClient
|
||||
d.fetchCallback = func(d *Dynamic) error {
|
||||
if d.AutoLogin == nil {
|
||||
return errkit.New("auto-login callback invoked without auto-login config")
|
||||
}
|
||||
cfg := autologin.Config{
|
||||
LoginURL: d.AutoLogin.LoginURL,
|
||||
Username: d.AutoLogin.Username,
|
||||
Password: d.AutoLogin.Password,
|
||||
UsernameField: d.AutoLogin.UsernameField,
|
||||
PasswordField: d.AutoLogin.PasswordField,
|
||||
TokenRegex: d.AutoLogin.TokenRegex,
|
||||
ExtraFields: kvSliceToMap(d.AutoLogin.ExtraFields),
|
||||
Headless: d.AutoLogin.Headless,
|
||||
ShowBrowser: d.AutoLogin.ShowBrowser || rt.ShowBrowser,
|
||||
UseInstalledChrome: d.AutoLogin.UseInstalledChrome || rt.UseInstalledChrome,
|
||||
Proxy: firstNonEmpty(d.AutoLogin.Proxy, rt.Proxy),
|
||||
CDPEndpoint: rt.CDPEndpoint,
|
||||
UserAgent: rt.UserAgent,
|
||||
CustomHeaders: rt.CustomHeaders,
|
||||
Steps: d.AutoLogin.Steps,
|
||||
}
|
||||
var (
|
||||
session *autologin.Session
|
||||
err error
|
||||
)
|
||||
engine := autoLoginEngineName(d.AutoLogin.Headless)
|
||||
if d.AutoLogin.Headless {
|
||||
// The browser engine runs JS, so it handles SPA / multi-step / SSO
|
||||
// login pages the HTTP form submitter cannot.
|
||||
session, err = autologin.LoginHeadless(context.Background(), cfg)
|
||||
} else {
|
||||
session, err = autologin.Login(context.Background(), d.autoLoginClient, cfg)
|
||||
}
|
||||
if err != nil {
|
||||
// Surface a clear, actionable failure at scan start (prefetch) and on
|
||||
// every lazy re-authentication.
|
||||
gologger.Warning().Msgf("auto-login (%s) failed for %s: %s", engine, cfg.LoginURL, err)
|
||||
return errkit.Wrapf(err, "auto-login (%s) failed for %s", engine, cfg.LoginURL)
|
||||
}
|
||||
|
||||
// Expose extracted values for observability / downstream templating.
|
||||
d.Extracted = map[string]interface{}{}
|
||||
if session.CookieHeader != "" {
|
||||
d.Extracted["cookie"] = session.CookieHeader
|
||||
}
|
||||
for _, c := range session.Cookies {
|
||||
d.Extracted[c.Name] = c.Value
|
||||
}
|
||||
if session.Token != "" {
|
||||
d.Extracted["token"] = session.Token
|
||||
}
|
||||
|
||||
if applyErr := d.applyAutoLoginSession(session); applyErr != nil {
|
||||
gologger.Warning().Msgf("auto-login (%s) for %s: %s", engine, cfg.LoginURL, applyErr)
|
||||
return errkit.Wrapf(applyErr, "auto-login (%s) for %s", engine, cfg.LoginURL)
|
||||
}
|
||||
gologger.Info().Msgf("auto-login (%s) succeeded for %s: %s", engine, cfg.LoginURL, summarizeSession(session))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// autoLoginEngineName returns a human-readable engine label for log messages.
|
||||
func autoLoginEngineName(headless bool) string {
|
||||
if headless {
|
||||
return "headless"
|
||||
}
|
||||
return "http"
|
||||
}
|
||||
|
||||
// summarizeSession produces a concise human-readable summary of what an
|
||||
// auto-login captured, for the success log line.
|
||||
func summarizeSession(session *autologin.Session) string {
|
||||
if session == nil {
|
||||
return "no session"
|
||||
}
|
||||
var parts []string
|
||||
if n := len(session.Cookies); n > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d cookie(s)", n))
|
||||
}
|
||||
if session.Token != "" {
|
||||
parts = append(parts, "bearer token")
|
||||
}
|
||||
if n := len(session.LocalStorage); n > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d localStorage item(s)", n))
|
||||
}
|
||||
if n := len(session.SessionStorage); n > 0 {
|
||||
parts = append(parts, fmt.Sprintf("%d sessionStorage item(s)", n))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "no usable session material"
|
||||
}
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
// applyAutoLoginSession builds the concrete applied secret(s) from a captured
|
||||
// session. Cookies are applied as a cookie secret; an extracted token is applied
|
||||
// as a bearer header. When both are present (common for SPAs that set a session
|
||||
// cookie *and* a localStorage JWT) both are applied. The applied fields are
|
||||
// reset first so a re-authentication fully replaces the previous session.
|
||||
func (d *Dynamic) applyAutoLoginSession(session *autologin.Session) error {
|
||||
if d.Secret == nil {
|
||||
d.Secret = &Secret{}
|
||||
}
|
||||
// Reset previously applied auth so re-auth replaces (not appends to) it.
|
||||
d.Headers = nil
|
||||
d.Cookies = nil
|
||||
d.Token = ""
|
||||
d.Secrets = nil
|
||||
|
||||
// Stash captured web storage (headless logins only) and any extra derived
|
||||
// secrets on the shared fetchState so every domain-scoped value-copy and a
|
||||
// re-authentication observe the same, freshly-captured session. Reset both
|
||||
// each re-auth.
|
||||
if d.fetchState != nil {
|
||||
d.fetchState.webStorageLocal = session.LocalStorage
|
||||
d.fetchState.webStorageSession = session.SessionStorage
|
||||
d.fetchState.autoLoginSecrets = nil
|
||||
}
|
||||
|
||||
hasCookies := len(session.Cookies) > 0
|
||||
hasToken := session.Token != ""
|
||||
hasStorage := len(session.LocalStorage) > 0 || len(session.SessionStorage) > 0
|
||||
if !hasCookies && !hasToken && !hasStorage {
|
||||
return errkit.New("auto-login produced no applicable session (no cookies, token or web storage)")
|
||||
}
|
||||
if !hasCookies && !hasToken {
|
||||
// Storage-only session (e.g. a pure localStorage-JWT SPA): there is no
|
||||
// HTTP-applicable secret, but the headless engine will replay the storage.
|
||||
// Leave the secret type empty so it yields no HTTP strategy.
|
||||
d.Type = ""
|
||||
return nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case hasCookies:
|
||||
d.Type = string(CookiesAuth)
|
||||
for _, c := range session.Cookies {
|
||||
d.Cookies = append(d.Cookies, Cookie{Key: c.Name, Value: c.Value})
|
||||
}
|
||||
if hasToken && d.fetchState != nil {
|
||||
// Apply the token as an additional bearer header secret on the shared
|
||||
// fetchState so it reaches every domain the secret is scoped to.
|
||||
d.fetchState.autoLoginSecrets = append(d.fetchState.autoLoginSecrets, &Secret{
|
||||
Type: string(BearerTokenAuth),
|
||||
Domains: d.Domains,
|
||||
Token: session.Token,
|
||||
})
|
||||
}
|
||||
default: // token only
|
||||
d.Type = string(BearerTokenAuth)
|
||||
d.Token = session.Token
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// firstNonEmpty returns the first non-empty string argument.
|
||||
func firstNonEmpty(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// kvSliceToMap converts a slice of KV pairs to a map for the autologin engine.
|
||||
func kvSliceToMap(kvs []KV) map[string]string {
|
||||
if len(kvs) == 0 {
|
||||
return nil
|
||||
}
|
||||
m := make(map[string]string, len(kvs))
|
||||
for _, kv := range kvs {
|
||||
m[kv.Key] = kv.Value
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
package authx
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/authprovider/autologin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAutoLoginConfig_Validate_Recording(t *testing.T) {
|
||||
recording := `{"steps": [
|
||||
{"type": "navigate", "url": "https://app.example.com/login"},
|
||||
{"type": "change", "value": "dave", "selectors": [["#user"]]},
|
||||
{"type": "change", "value": "secret", "selectors": [["#pass"]]},
|
||||
{"type": "click", "selectors": [["#submit"]]}
|
||||
]}`
|
||||
path := filepath.Join(t.TempDir(), "login.flow.json")
|
||||
require.NoError(t, os.WriteFile(path, []byte(recording), 0o600))
|
||||
|
||||
// LoginURL intentionally omitted: it must be derived from the recording.
|
||||
cfg := &AutoLoginConfig{Recording: path, Username: "dave", Password: "secret"}
|
||||
require.NoError(t, cfg.Validate())
|
||||
|
||||
require.True(t, cfg.Headless, "a recording must force a headless login")
|
||||
require.Equal(t, "https://app.example.com/login", cfg.LoginURL, "login URL derived from first navigate step")
|
||||
require.Equal(t, []autologin.LoginStep{
|
||||
{Action: "navigate", Value: "https://app.example.com/login"},
|
||||
{Action: "fill", Selector: "#user", Value: "{{username}}"},
|
||||
{Action: "fill", Selector: "#pass", Value: "{{password}}"},
|
||||
{Action: "click", Selector: "#submit"},
|
||||
}, cfg.Steps, "recording must compile to placeholder-parameterized steps")
|
||||
}
|
||||
|
||||
func TestAutoLoginConfig_Validate_RecordingMissingFile(t *testing.T) {
|
||||
cfg := &AutoLoginConfig{Recording: filepath.Join(t.TempDir(), "nope.json"), Password: "x"}
|
||||
require.Error(t, cfg.Validate(), "a missing recording file must fail validation")
|
||||
}
|
||||
|
||||
func newAutoLoginDynamic() *Dynamic {
|
||||
return &Dynamic{
|
||||
Secret: &Secret{Domains: []string{"app.example.com"}},
|
||||
AutoLogin: &AutoLoginConfig{LoginURL: "https://app.example.com/login", Password: "x"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAutoLoginSession_CookiesOnly(t *testing.T) {
|
||||
d := newAutoLoginDynamic()
|
||||
err := d.applyAutoLoginSession(&autologin.Session{
|
||||
Cookies: []*http.Cookie{{Name: "session", Value: "abc"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, string(CookiesAuth), d.Type)
|
||||
require.Len(t, d.Cookies, 1)
|
||||
require.Equal(t, "session", d.Cookies[0].Key)
|
||||
require.Empty(t, d.Secrets, "no extra bearer secret when there is no token")
|
||||
}
|
||||
|
||||
func TestApplyAutoLoginSession_TokenOnly(t *testing.T) {
|
||||
d := newAutoLoginDynamic()
|
||||
err := d.applyAutoLoginSession(&autologin.Session{Token: "jwt-123"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, string(BearerTokenAuth), d.Type)
|
||||
require.Equal(t, "jwt-123", d.Token)
|
||||
require.Empty(t, d.Secrets)
|
||||
}
|
||||
|
||||
func TestApplyAutoLoginSession_CookiesAndToken(t *testing.T) {
|
||||
d := newAutoLoginDynamic()
|
||||
require.NoError(t, d.Validate())
|
||||
err := d.applyAutoLoginSession(&autologin.Session{
|
||||
Cookies: []*http.Cookie{{Name: "session", Value: "abc"}},
|
||||
Token: "jwt-123",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, string(CookiesAuth), d.Type)
|
||||
// The bearer lives on the shared fetchState (so it reaches every scoped
|
||||
// domain), not on the per-copy Secrets slice.
|
||||
require.Empty(t, d.Secrets, "bearer must not live on the per-copy Secrets slice")
|
||||
require.Len(t, d.fetchState.autoLoginSecrets, 1, "token should be applied as an additional bearer secret")
|
||||
require.Equal(t, string(BearerTokenAuth), d.fetchState.autoLoginSecrets[0].Type)
|
||||
require.Equal(t, "jwt-123", d.fetchState.autoLoginSecrets[0].Token)
|
||||
require.Equal(t, []string{"app.example.com"}, d.fetchState.autoLoginSecrets[0].Domains)
|
||||
}
|
||||
|
||||
func TestApplyAutoLoginSession_Empty(t *testing.T) {
|
||||
d := newAutoLoginDynamic()
|
||||
err := d.applyAutoLoginSession(&autologin.Session{})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestSummarizeSession(t *testing.T) {
|
||||
require.Equal(t, "no session", summarizeSession(nil))
|
||||
require.Equal(t, "no usable session material", summarizeSession(&autologin.Session{}))
|
||||
require.Equal(t, "2 cookie(s)", summarizeSession(&autologin.Session{
|
||||
Cookies: []*http.Cookie{{Name: "a"}, {Name: "b"}},
|
||||
}))
|
||||
require.Equal(t, "1 cookie(s), bearer token, 2 localStorage item(s)", summarizeSession(&autologin.Session{
|
||||
Cookies: []*http.Cookie{{Name: "a"}},
|
||||
Token: "jwt",
|
||||
LocalStorage: map[string]string{"x": "1", "y": "2"},
|
||||
}))
|
||||
}
|
||||
|
||||
func TestAutoLoginEngineName(t *testing.T) {
|
||||
require.Equal(t, "headless", autoLoginEngineName(true))
|
||||
require.Equal(t, "http", autoLoginEngineName(false))
|
||||
}
|
||||
|
||||
// TestDynamic_WebStorage_ConcurrentReauth exercises the locking contract that
|
||||
// storage replay relies on: readers (the headless engine) call WebStorage while
|
||||
// re-authentication concurrently rewrites the captured storage on the shared
|
||||
// fetchState. Must be run under -race.
|
||||
func TestDynamic_WebStorage_ConcurrentReauth(t *testing.T) {
|
||||
d := newAutoLoginDynamic()
|
||||
d.ReauthStatusCodes = []int{401}
|
||||
require.NoError(t, d.Validate())
|
||||
|
||||
var gen atomic.Int64
|
||||
// Each (re)auth captures a fresh storage map, simulating a new session.
|
||||
d.fetchCallback = func(dyn *Dynamic) error {
|
||||
n := gen.Add(1)
|
||||
// Cookie + token + storage so every shared-state write path (Secret,
|
||||
// fetchState.autoLoginSecrets, webStorage) is exercised under -race.
|
||||
return dyn.applyAutoLoginSession(&autologin.Session{
|
||||
Cookies: []*http.Cookie{{Name: "session", Value: fmt.Sprintf("v%d", n)}},
|
||||
Token: fmt.Sprintf("token-%d", n),
|
||||
LocalStorage: map[string]string{"jwt": fmt.Sprintf("token-%d", n)},
|
||||
})
|
||||
}
|
||||
|
||||
// Share the session across value-copies, as the real provider does.
|
||||
strategy := &DynamicAuthStrategy{Dynamic: *d}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
const workers = 8
|
||||
const iterations = 200
|
||||
for w := 0; w < workers; w++ {
|
||||
wg.Add(1)
|
||||
go func(id int) {
|
||||
defer wg.Done()
|
||||
for i := 0; i < iterations; i++ {
|
||||
switch id % 4 {
|
||||
case 0:
|
||||
// Force a re-auth (rewrites the storage map under the write lock).
|
||||
strategy.Dynamic.MarkStale()
|
||||
_ = strategy.Dynamic.Fetch(false)
|
||||
case 1:
|
||||
// Read + iterate storage the way resolveBrowserStorage does.
|
||||
local, session := strategy.WebStorage()
|
||||
for k, v := range local {
|
||||
_ = k + v
|
||||
}
|
||||
for k, v := range session {
|
||||
_ = k + v
|
||||
}
|
||||
// Also materialize strategies (reads shared autoLoginSecrets).
|
||||
_ = strategy.Dynamic.GetStrategies()
|
||||
case 2:
|
||||
strategy.Dynamic.ApplyStrategies(func(AuthStrategy) {})
|
||||
default:
|
||||
_ = strategy.Dynamic.NotifyResponse(401)
|
||||
}
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
// After the storm, a fresh fetch must still yield a consistent session.
|
||||
local, _ := strategy.WebStorage()
|
||||
require.NotEmpty(t, local["jwt"], "storage must remain populated after concurrent re-auth")
|
||||
}
|
||||
|
||||
// TestDynamicClose_WipesCapturedSession verifies that Close drops the in-memory
|
||||
// session captured by an auto-login (cookies, bearer token, web storage and
|
||||
// extracted values) without contacting the target.
|
||||
func TestDynamicClose_WipesCapturedSession(t *testing.T) {
|
||||
d := newAutoLoginDynamic()
|
||||
require.NoError(t, d.Validate())
|
||||
d.fetchCallback = func(dyn *Dynamic) error {
|
||||
return dyn.applyAutoLoginSession(&autologin.Session{
|
||||
Cookies: []*http.Cookie{{Name: "session", Value: "abc"}},
|
||||
Token: "jwt-123",
|
||||
LocalStorage: map[string]string{"jwt": "jwt-123"},
|
||||
})
|
||||
}
|
||||
require.NoError(t, d.Fetch(false))
|
||||
d.Extracted = map[string]interface{}{"token": "jwt-123"}
|
||||
|
||||
// Sanity: the session is populated before Close.
|
||||
require.NotEmpty(t, d.Cookies)
|
||||
local, _ := d.WebStorage()
|
||||
require.NotEmpty(t, local["jwt"])
|
||||
|
||||
d.Close()
|
||||
|
||||
require.Empty(t, d.Cookies, "cookies must be wiped")
|
||||
require.Empty(t, d.Token, "token must be wiped")
|
||||
require.Empty(t, d.Headers, "headers must be wiped")
|
||||
require.Nil(t, d.Extracted, "extracted values must be cleared")
|
||||
require.False(t, d.IsExpired(), "a closed session reports not-expired (unfetched)")
|
||||
require.Equal(t, []string{"app.example.com"}, d.Domains, "scoping domains must be preserved")
|
||||
|
||||
localAfter, sessionAfter := d.fetchState.webStorageLocal, d.fetchState.webStorageSession
|
||||
require.Nil(t, localAfter, "captured local storage must be cleared")
|
||||
require.Nil(t, sessionAfter, "captured session storage must be cleared")
|
||||
}
|
||||
|
||||
// TestAutoLogin_MultiDomain_BearerSharedAcrossCopies reproduces a sharing bug:
|
||||
// the auto-login session (cookie + bearer token) must be applied on *every*
|
||||
// domain the secret is scoped to, not just the one whose DynamicAuthStrategy
|
||||
// copy happened to trigger the login. init() creates one value-copy per domain,
|
||||
// all sharing *Secret and *fetchState; the extra bearer secret must be visible
|
||||
// to sibling copies too.
|
||||
func TestAutoLogin_MultiDomain_BearerSharedAcrossCopies(t *testing.T) {
|
||||
d := &Dynamic{
|
||||
Secret: &Secret{Domains: []string{"app.example.com", "api.example.com"}},
|
||||
AutoLogin: &AutoLoginConfig{LoginURL: "https://app.example.com/login", Password: "x"},
|
||||
}
|
||||
require.NoError(t, d.Validate())
|
||||
d.fetchCallback = func(dyn *Dynamic) error {
|
||||
return dyn.applyAutoLoginSession(&autologin.Session{
|
||||
Cookies: []*http.Cookie{{Name: "session", Value: "abc"}},
|
||||
Token: "jwt-123",
|
||||
})
|
||||
}
|
||||
|
||||
// Mirror init(): one copy per domain, sharing the pointers.
|
||||
copyApp := &DynamicAuthStrategy{Dynamic: *d}
|
||||
copyAPI := &DynamicAuthStrategy{Dynamic: *d}
|
||||
|
||||
// The app-domain copy triggers the login.
|
||||
appStrategies := copyApp.Dynamic.GetStrategies()
|
||||
require.Len(t, appStrategies, 2, "app domain must get cookie + bearer")
|
||||
|
||||
// The api-domain copy (login already done, shared fetchState) must ALSO see
|
||||
// both the cookie and the bearer token.
|
||||
apiStrategies := copyAPI.Dynamic.GetStrategies()
|
||||
require.Len(t, apiStrategies, 2, "api domain must also get cookie + bearer (shared session)")
|
||||
|
||||
// Concretely, the bearer must be applied to a request on the api domain.
|
||||
req, _ := http.NewRequest(http.MethodGet, "https://api.example.com/data", nil)
|
||||
copyAPI.Apply(req)
|
||||
require.Equal(t, "Bearer jwt-123", req.Header.Get("Authorization"), "bearer token must reach the api domain")
|
||||
require.NotEmpty(t, req.Header.Get("Cookie"), "session cookie must reach the api domain")
|
||||
}
|
||||
|
||||
func TestApplyAutoLoginSession_WebStorageCaptured(t *testing.T) {
|
||||
d := newAutoLoginDynamic()
|
||||
require.NoError(t, d.Validate())
|
||||
d.SetAutoLoginCallback(nil)
|
||||
// Stub the fetch to bypass the network and simulate a headless capture.
|
||||
d.fetchCallback = func(dyn *Dynamic) error {
|
||||
return dyn.applyAutoLoginSession(&autologin.Session{
|
||||
Cookies: []*http.Cookie{{Name: "session", Value: "abc"}},
|
||||
LocalStorage: map[string]string{"jwt": "eyJ..."},
|
||||
SessionStorage: map[string]string{"csrf": "tok"},
|
||||
})
|
||||
}
|
||||
|
||||
local, session := d.WebStorage()
|
||||
require.Equal(t, map[string]string{"jwt": "eyJ..."}, local)
|
||||
require.Equal(t, map[string]string{"csrf": "tok"}, session)
|
||||
|
||||
// And the DynamicAuthStrategy must surface the same storage to the engine.
|
||||
strategy := &DynamicAuthStrategy{Dynamic: *d}
|
||||
l2, s2 := strategy.WebStorage()
|
||||
require.Equal(t, "eyJ...", l2["jwt"])
|
||||
require.Equal(t, "tok", s2["csrf"])
|
||||
}
|
||||
|
||||
func TestApplyAutoLoginSession_StorageOnly(t *testing.T) {
|
||||
d := newAutoLoginDynamic()
|
||||
require.NoError(t, d.Validate())
|
||||
// Storage-only session (pure localStorage-JWT SPA): no HTTP secret, but the
|
||||
// session is still valid because storage will be replayed by the engine.
|
||||
err := d.applyAutoLoginSession(&autologin.Session{
|
||||
LocalStorage: map[string]string{"jwt": "x"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, d.Type, "storage-only session yields no HTTP strategy")
|
||||
require.Equal(t, map[string]string{"jwt": "x"}, d.fetchState.webStorageLocal)
|
||||
}
|
||||
|
||||
func TestApplyAutoLoginSession_ReauthReplaces(t *testing.T) {
|
||||
d := newAutoLoginDynamic()
|
||||
require.NoError(t, d.Validate())
|
||||
// First auth: cookies + token (creates an extra bearer secret).
|
||||
require.NoError(t, d.applyAutoLoginSession(&autologin.Session{
|
||||
Cookies: []*http.Cookie{{Name: "session", Value: "v1"}},
|
||||
Token: "jwt-1",
|
||||
}))
|
||||
require.Len(t, d.fetchState.autoLoginSecrets, 1)
|
||||
|
||||
// Re-auth: cookies only -> the previous bearer secret and cookies must be
|
||||
// fully replaced, not appended.
|
||||
require.NoError(t, d.applyAutoLoginSession(&autologin.Session{
|
||||
Cookies: []*http.Cookie{{Name: "session", Value: "v2"}},
|
||||
}))
|
||||
require.Empty(t, d.fetchState.autoLoginSecrets, "stale bearer secret must be cleared on re-auth")
|
||||
require.Len(t, d.Cookies, 1)
|
||||
require.Equal(t, "v2", d.Cookies[0].Value, "cookie value must be refreshed")
|
||||
}
|
||||
@@ -2,10 +2,8 @@ package authx
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/projectdiscovery/gologger"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/replacer"
|
||||
@@ -16,35 +14,11 @@ import (
|
||||
|
||||
type LazyFetchSecret func(d *Dynamic) error
|
||||
|
||||
// fetchState holds the session state for a dynamic secret and makes it safe
|
||||
// for concurrent use. It is stored as a pointer in Dynamic so that value copies
|
||||
// (e.g. the one held inside DynamicAuthStrategy) share the same session.
|
||||
//
|
||||
// Unlike a plain sync.Once, this state machine supports re-authentication: the
|
||||
// login flow can be re-run when the session goes stale (explicitly marked,
|
||||
// detected from a response status, or after a configured refresh interval).
|
||||
//
|
||||
// Locking contract:
|
||||
// - Fetch/Refresh take the write lock while (re)running the login callback.
|
||||
// - The dynamic apply path (ApplyStrategies) holds the read lock while reading
|
||||
// the rendered secret, so request-time reads never race a concurrent re-auth.
|
||||
// fetchState holds the sync.Once and error for thread-safe fetching.
|
||||
// This is stored as a pointer in Dynamic so that value copies share the same state.
|
||||
type fetchState struct {
|
||||
mu sync.RWMutex
|
||||
fetched bool // whether the login callback has completed at least once
|
||||
stale bool // whether the session must be re-authenticated on next fetch
|
||||
fetchedAt time.Time // time of the last successful/attempted fetch
|
||||
err error // error from the most recent fetch attempt
|
||||
// webStorageLocal/webStorageSession hold browser web storage captured by a
|
||||
// headless auto-login, to be replayed into headless scan pages. Stored here
|
||||
// (on the shared fetchState pointer) so all Dynamic value-copies and a
|
||||
// re-authentication observe the same, freshly-captured storage.
|
||||
webStorageLocal map[string]string
|
||||
webStorageSession map[string]string
|
||||
// autoLoginSecrets holds extra applied secrets derived from an auto-login
|
||||
// session (e.g. a bearer token captured alongside a session cookie). They
|
||||
// live on the shared fetchState rather than the per-copy Dynamic.Secrets
|
||||
// slice so every domain-scoped value-copy applies the same captured session.
|
||||
autoLoginSecrets []*Secret
|
||||
once sync.Once
|
||||
err error
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -56,44 +30,16 @@ var (
|
||||
// ex: username and password are dynamic secrets, the actual secret is the token obtained
|
||||
// after authenticating with the username and password
|
||||
type Dynamic struct {
|
||||
*Secret `yaml:",inline"` // this is a static secret that will be generated after the dynamic secret is resolved
|
||||
Secrets []*Secret `yaml:"secrets"`
|
||||
TemplatePath string `json:"template" yaml:"template"`
|
||||
Variables []KV `json:"variables" yaml:"variables"`
|
||||
Input string `json:"input" yaml:"input"` // (optional) target for the dynamic secret
|
||||
Extracted map[string]interface{} `json:"-" yaml:"-"` // extracted values from the dynamic secret
|
||||
|
||||
// AutoLogin, when set, replaces the template-based login (TemplatePath) with
|
||||
// a turnkey form-based auto-login: nuclei fetches the login page, detects the
|
||||
// form and submits the supplied credentials, capturing the session
|
||||
// automatically. It is mutually exclusive with TemplatePath.
|
||||
AutoLogin *AutoLoginConfig `json:"auto-login" yaml:"auto-login"`
|
||||
|
||||
// RefreshInterval, when set (e.g. "15m"), causes the session to be
|
||||
// re-authenticated automatically once the rendered secret is older than the
|
||||
// interval. When empty/zero the session is fetched once and never expires by time.
|
||||
RefreshInterval string `json:"refresh-interval" yaml:"refresh-interval"`
|
||||
// ReauthStatusCodes is the set of HTTP response status codes that indicate
|
||||
// the session has expired (e.g. [401, 403]). When a matching response is
|
||||
// observed, the session is marked stale and re-authenticated before the next
|
||||
// request. When empty, response-triggered re-authentication is disabled.
|
||||
ReauthStatusCodes []int `json:"reauth-status-codes" yaml:"reauth-status-codes"`
|
||||
|
||||
fetchCallback LazyFetchSecret `json:"-" yaml:"-"`
|
||||
*Secret `yaml:",inline"` // this is a static secret that will be generated after the dynamic secret is resolved
|
||||
Secrets []*Secret `yaml:"secrets"`
|
||||
TemplatePath string `json:"template" yaml:"template"`
|
||||
Variables []KV `json:"variables" yaml:"variables"`
|
||||
Input string `json:"input" yaml:"input"` // (optional) target for the dynamic secret
|
||||
Extracted map[string]interface{} `json:"-" yaml:"-"` // extracted values from the dynamic secret
|
||||
fetchCallback LazyFetchSecret `json:"-" yaml:"-"`
|
||||
// fetchState is shared across value-copies of Dynamic (e.g., inside DynamicAuthStrategy).
|
||||
// It must be initialized via Validate() before calling Fetch().
|
||||
fetchState *fetchState `json:"-" yaml:"-"`
|
||||
// refreshInterval is the parsed form of RefreshInterval.
|
||||
refreshInterval time.Duration `json:"-" yaml:"-"`
|
||||
// autoLoginClient is an optional template HTTP client (proxy/TLS) reused for
|
||||
// auto-login requests. nil means use the autologin engine's defaults.
|
||||
autoLoginClient *http.Client `json:"-" yaml:"-"`
|
||||
// origSecret/origSecrets hold pristine copies of the secret templates (with
|
||||
// their {{var}} placeholders intact) captured before the first fetch. They are
|
||||
// used to re-render the secret on each re-authentication, since substitution
|
||||
// is destructive (placeholders are replaced with concrete values in place).
|
||||
origSecret *Secret `json:"-" yaml:"-"`
|
||||
origSecrets []*Secret `json:"-" yaml:"-"`
|
||||
}
|
||||
|
||||
func (d *Dynamic) GetDomainAndDomainRegex() ([]string, []string) {
|
||||
@@ -134,56 +80,12 @@ func (d *Dynamic) Validate() error {
|
||||
// NOTE: Validate() must not be called concurrently with Fetch()/GetStrategies().
|
||||
// Re-validating resets fetch state and allows re-fetching.
|
||||
d.fetchState = &fetchState{}
|
||||
|
||||
// Auto-login is a template-free alternative to TemplatePath. When set, it is
|
||||
// validated on its own and the template/variables requirements do not apply;
|
||||
// domains (used for scoping the captured session) are still required.
|
||||
if d.AutoLogin != nil {
|
||||
if d.TemplatePath != "" {
|
||||
return errkit.New("auto-login and template are mutually exclusive for dynamic secret")
|
||||
}
|
||||
if err := d.AutoLogin.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
domains, domainsRegex := d.GetDomainAndDomainRegex()
|
||||
if len(domains) == 0 && len(domainsRegex) == 0 {
|
||||
return errkit.New("domains or domains-regex are required for auto-login dynamic secret")
|
||||
}
|
||||
if d.RefreshInterval != "" {
|
||||
dur, err := time.ParseDuration(d.RefreshInterval)
|
||||
if err != nil {
|
||||
return errkit.New("invalid refresh-interval %q for dynamic secret: %s", d.RefreshInterval, err)
|
||||
}
|
||||
if dur < 0 {
|
||||
return errkit.New("refresh-interval cannot be negative for dynamic secret")
|
||||
}
|
||||
d.refreshInterval = dur
|
||||
}
|
||||
// The applied secret is built from the captured session at fetch time, so
|
||||
// skip the (pre-fetch) Secret.Validate which would reject the still-empty
|
||||
// cookies/token. We only need its domains, captured above.
|
||||
if d.Secret != nil {
|
||||
d.skipCookieParse = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if d.TemplatePath == "" {
|
||||
return errkit.New(" template-path is required for dynamic secret")
|
||||
}
|
||||
if len(d.Variables) == 0 {
|
||||
return errkit.New("variables are required for dynamic secret")
|
||||
}
|
||||
if d.RefreshInterval != "" {
|
||||
dur, err := time.ParseDuration(d.RefreshInterval)
|
||||
if err != nil {
|
||||
return errkit.New("invalid refresh-interval %q for dynamic secret: %s", d.RefreshInterval, err)
|
||||
}
|
||||
if dur < 0 {
|
||||
return errkit.New("refresh-interval cannot be negative for dynamic secret")
|
||||
}
|
||||
d.refreshInterval = dur
|
||||
}
|
||||
|
||||
if d.Secret != nil {
|
||||
d.skipCookieParse = true // skip cookie parsing in dynamic secrets during validation
|
||||
@@ -200,15 +102,8 @@ func (d *Dynamic) Validate() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetLazyFetchCallback sets the lazy fetch callback for the dynamic secret.
|
||||
//
|
||||
// The provided callback is responsible for running the login flow and populating
|
||||
// d.Extracted. The wrapper here renders the secret templates with the freshly
|
||||
// extracted values. It snapshots the pristine secret templates on first use so
|
||||
// that re-authentication can re-render them with new values (template
|
||||
// substitution is destructive and would otherwise consume the placeholders).
|
||||
// SetLazyFetchCallback sets the lazy fetch callback for the dynamic secret
|
||||
func (d *Dynamic) SetLazyFetchCallback(callback LazyFetchSecret) {
|
||||
d.snapshotTemplates()
|
||||
d.fetchCallback = func(d *Dynamic) error {
|
||||
err := callback(d)
|
||||
if err != nil {
|
||||
@@ -219,16 +114,12 @@ func (d *Dynamic) SetLazyFetchCallback(callback LazyFetchSecret) {
|
||||
}
|
||||
|
||||
if d.Secret != nil {
|
||||
restoreSecretTemplate(d.Secret, d.origSecret)
|
||||
if err := d.applyValuesToSecret(d.Secret); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for i, secret := range d.Secrets {
|
||||
if i < len(d.origSecrets) {
|
||||
restoreSecretTemplate(secret, d.origSecrets[i])
|
||||
}
|
||||
for _, secret := range d.Secrets {
|
||||
if err := d.applyValuesToSecret(secret); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -237,47 +128,6 @@ func (d *Dynamic) SetLazyFetchCallback(callback LazyFetchSecret) {
|
||||
}
|
||||
}
|
||||
|
||||
// snapshotTemplates captures pristine copies of the secret templates (with their
|
||||
// {{var}} placeholders intact) so they can be re-rendered on re-authentication.
|
||||
func (d *Dynamic) snapshotTemplates() {
|
||||
if d.Secret != nil {
|
||||
d.origSecret = cloneSecretTemplate(d.Secret)
|
||||
}
|
||||
if len(d.Secrets) > 0 {
|
||||
d.origSecrets = make([]*Secret, len(d.Secrets))
|
||||
for i, secret := range d.Secrets {
|
||||
d.origSecrets[i] = cloneSecretTemplate(secret)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cloneSecretTemplate deep-copies the templated fields of a secret so the
|
||||
// original placeholders survive destructive in-place substitution.
|
||||
func cloneSecretTemplate(s *Secret) *Secret {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
clone := *s
|
||||
clone.Headers = append([]KV(nil), s.Headers...)
|
||||
clone.Cookies = append([]Cookie(nil), s.Cookies...)
|
||||
clone.Params = append([]KV(nil), s.Params...)
|
||||
return &clone
|
||||
}
|
||||
|
||||
// restoreSecretTemplate resets the templated fields of live back to the pristine
|
||||
// template captured in snap, so the next substitution starts from placeholders.
|
||||
func restoreSecretTemplate(live, snap *Secret) {
|
||||
if live == nil || snap == nil {
|
||||
return
|
||||
}
|
||||
live.Headers = append([]KV(nil), snap.Headers...)
|
||||
live.Cookies = append([]Cookie(nil), snap.Cookies...)
|
||||
live.Params = append([]KV(nil), snap.Params...)
|
||||
live.Username = snap.Username
|
||||
live.Password = snap.Password
|
||||
live.Token = snap.Token
|
||||
}
|
||||
|
||||
func (d *Dynamic) applyValuesToSecret(secret *Secret) error {
|
||||
// evaluate headers
|
||||
for i, header := range secret.Headers {
|
||||
@@ -346,68 +196,20 @@ func (d *Dynamic) GetStrategies() []AuthStrategy {
|
||||
// does not terminate the entire scan process.
|
||||
_ = d.Fetch(false)
|
||||
|
||||
if d.fetchState == nil {
|
||||
return nil
|
||||
}
|
||||
d.fetchState.mu.RLock()
|
||||
defer d.fetchState.mu.RUnlock()
|
||||
if d.fetchState.err != nil {
|
||||
if d.fetchState != nil && d.fetchState.err != nil {
|
||||
return nil
|
||||
}
|
||||
var strategies []AuthStrategy
|
||||
if d.Secret != nil {
|
||||
if s := d.GetStrategy(); s != nil {
|
||||
strategies = append(strategies, s)
|
||||
}
|
||||
strategies = append(strategies, d.GetStrategy())
|
||||
}
|
||||
for _, secret := range d.Secrets {
|
||||
if s := secret.GetStrategy(); s != nil {
|
||||
strategies = append(strategies, s)
|
||||
}
|
||||
}
|
||||
for _, secret := range d.fetchState.autoLoginSecrets {
|
||||
if s := secret.GetStrategy(); s != nil {
|
||||
strategies = append(strategies, s)
|
||||
}
|
||||
strategies = append(strategies, secret.GetStrategy())
|
||||
}
|
||||
return strategies
|
||||
}
|
||||
|
||||
// ApplyStrategies fetches (or re-authenticates) the session if needed and then
|
||||
// applies each resolved auth strategy via the provided apply func while holding
|
||||
// the read lock. This guarantees the rendered secret is never read at request
|
||||
// time while a concurrent re-authentication is rewriting it.
|
||||
func (d *Dynamic) ApplyStrategies(apply func(AuthStrategy)) {
|
||||
// Fetch (re)authenticates under the write lock if the session is missing or stale.
|
||||
_ = d.Fetch(false)
|
||||
if d.fetchState == nil {
|
||||
return
|
||||
}
|
||||
d.fetchState.mu.RLock()
|
||||
defer d.fetchState.mu.RUnlock()
|
||||
if d.fetchState.err != nil {
|
||||
return
|
||||
}
|
||||
if d.Secret != nil {
|
||||
if s := d.GetStrategy(); s != nil {
|
||||
apply(s)
|
||||
}
|
||||
}
|
||||
for _, secret := range d.Secrets {
|
||||
if s := secret.GetStrategy(); s != nil {
|
||||
apply(s)
|
||||
}
|
||||
}
|
||||
for _, secret := range d.fetchState.autoLoginSecrets {
|
||||
if s := secret.GetStrategy(); s != nil {
|
||||
apply(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch fetches the dynamic secret, (re)running the login flow when the session
|
||||
// has not been fetched yet or has gone stale (explicitly marked, expired by
|
||||
// refresh-interval, or invalidated by an observed response status code).
|
||||
// Fetch fetches the dynamic secret
|
||||
// if isFatal is true, it will stop the execution if the secret could not be fetched
|
||||
func (d *Dynamic) Fetch(isFatal bool) error {
|
||||
if d.fetchState == nil {
|
||||
@@ -417,135 +219,18 @@ func (d *Dynamic) Fetch(isFatal bool) error {
|
||||
return errkit.New("dynamic secret not validated: call Validate() before Fetch()")
|
||||
}
|
||||
|
||||
d.fetchState.mu.Lock()
|
||||
if !d.fetchState.fetched || d.isExpiredLocked() {
|
||||
d.runFetchLocked()
|
||||
}
|
||||
err := d.fetchState.err
|
||||
d.fetchState.mu.Unlock()
|
||||
|
||||
if err != nil && isFatal {
|
||||
gologger.Fatal().Msgf("Could not fetch dynamic secret: %s\n", err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Refresh forces an immediate re-authentication, re-running the login flow
|
||||
// regardless of the current session state.
|
||||
func (d *Dynamic) Refresh(isFatal bool) error {
|
||||
if d.fetchState == nil {
|
||||
if isFatal {
|
||||
gologger.Fatal().Msgf("Could not refresh dynamic secret: Validate() must be called before Refresh()")
|
||||
d.fetchState.once.Do(func() {
|
||||
if d.fetchCallback == nil {
|
||||
d.fetchState.err = errkit.New("dynamic secret fetch callback not set: call SetLazyFetchCallback() before Fetch()")
|
||||
return
|
||||
}
|
||||
return errkit.New("dynamic secret not validated: call Validate() before Refresh()")
|
||||
}
|
||||
d.fetchState.err = d.fetchCallback(d)
|
||||
})
|
||||
|
||||
d.fetchState.mu.Lock()
|
||||
d.fetchState.fetched = false
|
||||
d.fetchState.stale = false
|
||||
d.runFetchLocked()
|
||||
err := d.fetchState.err
|
||||
d.fetchState.mu.Unlock()
|
||||
|
||||
if err != nil && isFatal {
|
||||
gologger.Fatal().Msgf("Could not refresh dynamic secret: %s\n", err)
|
||||
if d.fetchState.err != nil && isFatal {
|
||||
gologger.Fatal().Msgf("Could not fetch dynamic secret: %s\n", d.fetchState.err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// MarkStale marks the session for re-authentication on the next fetch. It is
|
||||
// cheap and does not perform any network I/O; the actual re-auth happens on the
|
||||
// next Fetch/ApplyStrategies call so it never runs in a response-handling path.
|
||||
func (d *Dynamic) MarkStale() {
|
||||
if d.fetchState == nil {
|
||||
return
|
||||
}
|
||||
d.fetchState.mu.Lock()
|
||||
d.fetchState.stale = true
|
||||
d.fetchState.mu.Unlock()
|
||||
}
|
||||
|
||||
// NotifyResponse inspects a response and, if its status code is configured as a
|
||||
// session-expiry signal, marks the session stale so it is re-authenticated
|
||||
// before the next request. It returns true if re-authentication was triggered.
|
||||
func (d *Dynamic) NotifyResponse(statusCode int) bool {
|
||||
if d.fetchState == nil || !d.shouldReauthOnStatus(statusCode) {
|
||||
return false
|
||||
}
|
||||
// Only mark stale if we actually have an established session to refresh.
|
||||
d.fetchState.mu.RLock()
|
||||
fetched := d.fetchState.fetched
|
||||
d.fetchState.mu.RUnlock()
|
||||
if !fetched {
|
||||
return false
|
||||
}
|
||||
d.MarkStale()
|
||||
return true
|
||||
}
|
||||
|
||||
// WebStorage returns the browser web storage (localStorage/sessionStorage)
|
||||
// captured by a headless auto-login, fetching the session first if needed. It
|
||||
// returns nil maps when no storage was captured (e.g. HTTP auto-login or a
|
||||
// fetch error).
|
||||
func (d *Dynamic) WebStorage() (map[string]string, map[string]string) {
|
||||
_ = d.Fetch(false)
|
||||
if d.fetchState == nil {
|
||||
return nil, nil
|
||||
}
|
||||
d.fetchState.mu.RLock()
|
||||
defer d.fetchState.mu.RUnlock()
|
||||
if d.fetchState.err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
return d.fetchState.webStorageLocal, d.fetchState.webStorageSession
|
||||
}
|
||||
|
||||
// IsExpired reports whether the session is currently considered expired/stale.
|
||||
func (d *Dynamic) IsExpired() bool {
|
||||
if d.fetchState == nil {
|
||||
return false
|
||||
}
|
||||
d.fetchState.mu.RLock()
|
||||
defer d.fetchState.mu.RUnlock()
|
||||
return d.isExpiredLocked()
|
||||
}
|
||||
|
||||
// runFetchLocked runs the login callback. The caller must hold the write lock.
|
||||
func (d *Dynamic) runFetchLocked() {
|
||||
if d.fetchCallback == nil {
|
||||
d.fetchState.err = errkit.New("dynamic secret fetch callback not set: call SetLazyFetchCallback() before Fetch()")
|
||||
return
|
||||
}
|
||||
d.fetchState.err = d.fetchCallback(d)
|
||||
d.fetchState.fetched = true
|
||||
d.fetchState.stale = false
|
||||
d.fetchState.fetchedAt = time.Now()
|
||||
}
|
||||
|
||||
// isExpiredLocked reports whether the session needs re-authentication. The
|
||||
// caller must hold at least the read lock.
|
||||
func (d *Dynamic) isExpiredLocked() bool {
|
||||
if !d.fetchState.fetched {
|
||||
return false
|
||||
}
|
||||
if d.fetchState.stale {
|
||||
return true
|
||||
}
|
||||
if d.refreshInterval > 0 && time.Since(d.fetchState.fetchedAt) > d.refreshInterval {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// shouldReauthOnStatus reports whether the given status code is configured as a
|
||||
// session-expiry signal.
|
||||
func (d *Dynamic) shouldReauthOnStatus(statusCode int) bool {
|
||||
for _, code := range d.ReauthStatusCodes {
|
||||
if code == statusCode {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
return d.fetchState.err
|
||||
}
|
||||
|
||||
// Error returns the error if any
|
||||
@@ -553,50 +238,5 @@ func (d *Dynamic) Error() error {
|
||||
if d.fetchState == nil {
|
||||
return nil
|
||||
}
|
||||
d.fetchState.mu.RLock()
|
||||
defer d.fetchState.mu.RUnlock()
|
||||
return d.fetchState.err
|
||||
}
|
||||
|
||||
// Close drops the in-memory session captured for this dynamic secret: the
|
||||
// applied cookies/token/headers, any captured web storage and derived secrets,
|
||||
// and the extracted values. It does not contact the server (sessions are left
|
||||
// to expire on their own); it simply ensures nuclei does not retain live
|
||||
// session material after the scan, which matters when nuclei is embedded as a
|
||||
// library and the process outlives the scan. The session is also marked
|
||||
// unfetched so a later reuse re-authenticates from scratch.
|
||||
func (d *Dynamic) Close() {
|
||||
if d.fetchState != nil {
|
||||
d.fetchState.mu.Lock()
|
||||
d.fetchState.webStorageLocal = nil
|
||||
d.fetchState.webStorageSession = nil
|
||||
for _, s := range d.fetchState.autoLoginSecrets {
|
||||
wipeSecretSession(s)
|
||||
}
|
||||
d.fetchState.autoLoginSecrets = nil
|
||||
d.fetchState.fetched = false
|
||||
d.fetchState.stale = false
|
||||
d.fetchState.err = nil
|
||||
d.fetchState.mu.Unlock()
|
||||
}
|
||||
wipeSecretSession(d.Secret)
|
||||
for _, s := range d.Secrets {
|
||||
wipeSecretSession(s)
|
||||
}
|
||||
d.Extracted = nil
|
||||
}
|
||||
|
||||
// wipeSecretSession zeroes the session-bearing fields of a secret (cookies,
|
||||
// token, headers, query params and web storage), leaving non-sensitive scoping
|
||||
// fields (type/domains) intact. It is a no-op on nil.
|
||||
func wipeSecretSession(s *Secret) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.Headers = nil
|
||||
s.Cookies = nil
|
||||
s.Params = nil
|
||||
s.Token = ""
|
||||
s.LocalStorage = nil
|
||||
s.SessionStorage = nil
|
||||
}
|
||||
@@ -98,8 +98,8 @@ func TestDynamicUnmarshalJSON(t *testing.T) {
|
||||
require.Equal(t, "HeadersAuth", d.Type)
|
||||
require.Equal(t, []string{"api.test.com"}, d.Domains)
|
||||
require.Len(t, d.Headers, 1)
|
||||
require.Equal(t, "X-API-Key", d.Headers[0].Key)
|
||||
require.Equal(t, "secret-key", d.Headers[0].Value)
|
||||
require.Equal(t, "X-API-Key", d.Secret.Headers[0].Key)
|
||||
require.Equal(t, "secret-key", d.Secret.Headers[0].Value)
|
||||
|
||||
// Dynamic fields
|
||||
require.Equal(t, "test-template.yaml", d.TemplatePath)
|
||||
@@ -529,7 +529,7 @@ func TestSetLazyFetchCallbackAppliesValues(t *testing.T) {
|
||||
|
||||
err := d.Fetch(false)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Bearer jwt-secret-123", d.Headers[0].Value)
|
||||
require.Equal(t, "Bearer jwt-secret-123", d.Secret.Headers[0].Value)
|
||||
})
|
||||
|
||||
t.Run("applies to secrets array", func(t *testing.T) {
|
||||
|
||||
@@ -22,11 +22,6 @@ const (
|
||||
HeadersAuth AuthType = "Header"
|
||||
CookiesAuth AuthType = "Cookie"
|
||||
QueryAuth AuthType = "Query"
|
||||
// WebStorageAuth carries browser localStorage/sessionStorage to be seeded
|
||||
// into headless scan pages. It has no HTTP representation (its Apply is a
|
||||
// no-op) and is used to replay token-in-web-storage sessions (e.g. captured
|
||||
// via auth-capture) for SPA scans.
|
||||
WebStorageAuth AuthType = "WebStorage"
|
||||
)
|
||||
|
||||
// SupportedAuthTypes returns the supported auth types
|
||||
@@ -37,7 +32,6 @@ func SupportedAuthTypes() []string {
|
||||
string(HeadersAuth),
|
||||
string(CookiesAuth),
|
||||
string(QueryAuth),
|
||||
string(WebStorageAuth),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,20 +52,16 @@ type AuthFileInfo struct {
|
||||
|
||||
// Secret is a struct for secret or credential
|
||||
type Secret struct {
|
||||
Type string `json:"type" yaml:"type"`
|
||||
Domains []string `json:"domains" yaml:"domains"`
|
||||
DomainsRegex []string `json:"domains-regex" yaml:"domains-regex"`
|
||||
Headers []KV `json:"headers" yaml:"headers"` // Headers preserve exact casing (useful for case-sensitive APIs)
|
||||
Cookies []Cookie `json:"cookies" yaml:"cookies"`
|
||||
Params []KV `json:"params" yaml:"params"`
|
||||
Username string `json:"username" yaml:"username"` // can be either email or username
|
||||
Password string `json:"password" yaml:"password"`
|
||||
Token string `json:"token" yaml:"token"` // Bearer Auth token
|
||||
// LocalStorage / SessionStorage carry browser web storage for WebStorageAuth
|
||||
// secrets, seeded into headless scan pages before page scripts run.
|
||||
LocalStorage map[string]string `json:"local-storage" yaml:"local-storage"`
|
||||
SessionStorage map[string]string `json:"session-storage" yaml:"session-storage"`
|
||||
skipCookieParse bool `json:"-" yaml:"-"` // temporary flag to skip cookie parsing (used in dynamic secrets)
|
||||
Type string `json:"type" yaml:"type"`
|
||||
Domains []string `json:"domains" yaml:"domains"`
|
||||
DomainsRegex []string `json:"domains-regex" yaml:"domains-regex"`
|
||||
Headers []KV `json:"headers" yaml:"headers"` // Headers preserve exact casing (useful for case-sensitive APIs)
|
||||
Cookies []Cookie `json:"cookies" yaml:"cookies"`
|
||||
Params []KV `json:"params" yaml:"params"`
|
||||
Username string `json:"username" yaml:"username"` // can be either email or username
|
||||
Password string `json:"password" yaml:"password"`
|
||||
Token string `json:"token" yaml:"token"` // Bearer Auth token
|
||||
skipCookieParse bool `json:"-" yaml:"-"` // temporary flag to skip cookie parsing (used in dynamic secrets)
|
||||
}
|
||||
|
||||
// GetStrategy returns the auth strategy for the secret
|
||||
@@ -87,8 +77,6 @@ func (s *Secret) GetStrategy() AuthStrategy {
|
||||
return NewCookiesAuthStrategy(s)
|
||||
case strings.EqualFold(s.Type, string(QueryAuth)):
|
||||
return NewQueryAuthStrategy(s)
|
||||
case strings.EqualFold(s.Type, string(WebStorageAuth)):
|
||||
return NewWebStorageAuthStrategy(s.LocalStorage, s.SessionStorage)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -144,10 +132,6 @@ func (s *Secret) Validate() error {
|
||||
return fmt.Errorf("invalid cookie in cookiesAuth: %s", err)
|
||||
}
|
||||
}
|
||||
case strings.EqualFold(s.Type, string(WebStorageAuth)):
|
||||
if len(s.LocalStorage) == 0 && len(s.SessionStorage) == 0 {
|
||||
return fmt.Errorf("local-storage or session-storage cannot be empty in web storage auth")
|
||||
}
|
||||
case strings.EqualFold(s.Type, string(QueryAuth)):
|
||||
if len(s.Params) == 0 {
|
||||
return fmt.Errorf("query cannot be empty in query auth")
|
||||
@@ -243,11 +227,6 @@ func GetTemplatePathsFromSecretFile(file string) ([]string, error) {
|
||||
}
|
||||
var paths []string
|
||||
for _, dynamic := range auth.Dynamic {
|
||||
// Auto-login dynamics have no template path; skip them so we don't try to
|
||||
// load an empty template.
|
||||
if dynamic.TemplatePath == "" {
|
||||
continue
|
||||
}
|
||||
paths = append(paths, dynamic.TemplatePath)
|
||||
}
|
||||
return paths, nil
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
package authx
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// fakeAuthServer simulates a login + protected-resource backend with
|
||||
// server-side session expiry, to exercise the dynamic session lifecycle end to
|
||||
// end over real HTTP:
|
||||
// - GET /login issues a fresh incrementing bearer token and marks it as the
|
||||
// only currently-valid session.
|
||||
// - GET /api returns 200 when the presented bearer matches the valid session,
|
||||
// and 401 once the session has been expired server-side.
|
||||
type fakeAuthServer struct {
|
||||
mu sync.Mutex
|
||||
counter int
|
||||
valid string // currently valid token; empty means "no valid session"
|
||||
logins atomic.Int32
|
||||
}
|
||||
|
||||
func (s *fakeAuthServer) handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.Lock()
|
||||
s.counter++
|
||||
s.valid = fmt.Sprintf("sess-%d", s.counter)
|
||||
tok := s.valid
|
||||
s.mu.Unlock()
|
||||
s.logins.Add(1)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"token": tok})
|
||||
})
|
||||
mux.HandleFunc("/api", func(w http.ResponseWriter, r *http.Request) {
|
||||
s.mu.Lock()
|
||||
valid := s.valid
|
||||
s.mu.Unlock()
|
||||
if valid != "" && r.Header.Get("Authorization") == "Bearer "+valid {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.WriteString(w, "secret-data")
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = io.WriteString(w, "session expired")
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
// expire invalidates the current session server-side without issuing a new one,
|
||||
// so the next /api call with the old token returns 401.
|
||||
func (s *fakeAuthServer) expire() {
|
||||
s.mu.Lock()
|
||||
s.valid = ""
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// TestSessionLifecycle_E2E drives the whole dynamic-session state machine against
|
||||
// a real HTTP server: it performs a real login to mint a token, uses it on a
|
||||
// protected endpoint, lets the server expire the session, and verifies that a
|
||||
// 401 (surfaced through NotifyResponse) triggers a real re-authentication whose
|
||||
// fresh token restores access — all through the production Dynamic/strategy code.
|
||||
func TestSessionLifecycle_E2E(t *testing.T) {
|
||||
backend := &fakeAuthServer{}
|
||||
srv := httptest.NewServer(backend.handler())
|
||||
defer srv.Close()
|
||||
|
||||
httpClient := srv.Client()
|
||||
|
||||
d := &Dynamic{
|
||||
Secret: &Secret{
|
||||
Type: "Header",
|
||||
Domains: []string{"127.0.0.1", "localhost"},
|
||||
Headers: []KV{{Key: "Authorization", Value: "Bearer {{token}}"}},
|
||||
},
|
||||
TemplatePath: "auth.yaml",
|
||||
Variables: []KV{{Key: "user", Value: "admin"}},
|
||||
ReauthStatusCodes: []int{401},
|
||||
}
|
||||
require.NoError(t, d.Validate())
|
||||
|
||||
// The login callback performs a REAL HTTP login and extracts the token, the
|
||||
// same shape the template-driven fetch produces.
|
||||
d.SetLazyFetchCallback(func(dyn *Dynamic) error {
|
||||
resp, err := httpClient.Get(srv.URL + "/login")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
var payload struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return err
|
||||
}
|
||||
dyn.Extracted = map[string]interface{}{"token": payload.Token}
|
||||
return nil
|
||||
})
|
||||
|
||||
strategy := &DynamicAuthStrategy{Dynamic: *d}
|
||||
inspector, ok := AuthStrategy(strategy).(ResponseInspector)
|
||||
require.True(t, ok, "DynamicAuthStrategy must implement ResponseInspector")
|
||||
|
||||
// doAPI applies the (possibly re-authenticated) session and calls /api.
|
||||
doAPI := func() int {
|
||||
req, err := http.NewRequest(http.MethodGet, srv.URL+"/api", nil)
|
||||
require.NoError(t, err)
|
||||
strategy.Apply(req)
|
||||
resp, err := httpClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
_ = resp.Body.Close()
|
||||
// surface the response to the auth strategy, like the http protocol does
|
||||
inspector.OnResponse(resp.StatusCode)
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
// 1) First call logs in and succeeds.
|
||||
require.Equal(t, http.StatusOK, doAPI(), "first authenticated request should succeed")
|
||||
require.Equal(t, int32(1), backend.logins.Load(), "exactly one login so far")
|
||||
require.False(t, d.IsExpired())
|
||||
|
||||
// 2) Server expires the session out from under us → next call sees 401 and,
|
||||
// via NotifyResponse, marks the session stale.
|
||||
backend.expire()
|
||||
require.Equal(t, http.StatusUnauthorized, doAPI(), "request with the now-expired token should 401")
|
||||
require.True(t, d.IsExpired(), "401 must mark the session stale for re-authentication")
|
||||
|
||||
// 3) Next call re-authenticates (a second real login) and succeeds again.
|
||||
require.Equal(t, http.StatusOK, doAPI(), "request after re-auth should succeed with the fresh token")
|
||||
require.Equal(t, int32(2), backend.logins.Load(), "a second login should have occurred")
|
||||
require.False(t, d.IsExpired(), "session should be fresh again after re-auth")
|
||||
}
|
||||
@@ -1,250 +0,0 @@
|
||||
package authx
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// newDynamicWithToken builds a validated dynamic secret whose login callback
|
||||
// returns an incrementing token each time it runs, so re-authentication is observable.
|
||||
func newDynamicWithToken(t *testing.T, calls *atomic.Int32) *Dynamic {
|
||||
t.Helper()
|
||||
d := &Dynamic{
|
||||
Secret: &Secret{
|
||||
Type: "Header",
|
||||
Domains: []string{"example.com"},
|
||||
Headers: []KV{{Key: "Authorization", Value: "Bearer {{token}}"}},
|
||||
},
|
||||
TemplatePath: "auth.yaml",
|
||||
Variables: []KV{{Key: "user", Value: "admin"}},
|
||||
}
|
||||
require.NoError(t, d.Validate())
|
||||
d.SetLazyFetchCallback(func(dyn *Dynamic) error {
|
||||
n := calls.Add(1)
|
||||
dyn.Extracted = map[string]interface{}{"token": tokenForCall(n)}
|
||||
return nil
|
||||
})
|
||||
return d
|
||||
}
|
||||
|
||||
func tokenForCall(n int32) string {
|
||||
switch n {
|
||||
case 1:
|
||||
return "token-1"
|
||||
case 2:
|
||||
return "token-2"
|
||||
case 3:
|
||||
return "token-3"
|
||||
default:
|
||||
return "token-n"
|
||||
}
|
||||
}
|
||||
|
||||
func applyHeader(t *testing.T, d *Dynamic) string {
|
||||
t.Helper()
|
||||
strategy := &DynamicAuthStrategy{Dynamic: *d}
|
||||
req, _ := http.NewRequest("GET", "https://example.com", nil)
|
||||
strategy.Apply(req)
|
||||
return req.Header.Get("Authorization")
|
||||
}
|
||||
|
||||
func TestSessionRefreshReRendersTemplate(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
d := newDynamicWithToken(t, &calls)
|
||||
|
||||
require.Equal(t, "Bearer token-1", applyHeader(t, d), "first fetch should render initial token")
|
||||
require.Equal(t, "Bearer token-1", applyHeader(t, d), "second apply should reuse cached session")
|
||||
require.Equal(t, int32(1), calls.Load(), "login should run once before refresh")
|
||||
|
||||
require.NoError(t, d.Refresh(false))
|
||||
require.Equal(t, int32(2), calls.Load(), "refresh should re-run the login flow")
|
||||
require.Equal(t, "Bearer token-2", applyHeader(t, d), "refresh must re-render template with the new token")
|
||||
}
|
||||
|
||||
func TestSessionMarkStaleTriggersReauth(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
d := newDynamicWithToken(t, &calls)
|
||||
|
||||
require.Equal(t, "Bearer token-1", applyHeader(t, d))
|
||||
require.Equal(t, int32(1), calls.Load())
|
||||
|
||||
d.MarkStale()
|
||||
require.True(t, d.IsExpired(), "session should report expired after MarkStale")
|
||||
|
||||
require.Equal(t, "Bearer token-2", applyHeader(t, d), "stale session should re-authenticate on next apply")
|
||||
require.Equal(t, int32(2), calls.Load())
|
||||
require.False(t, d.IsExpired(), "session should be fresh again after re-auth")
|
||||
}
|
||||
|
||||
func TestSessionRefreshIntervalExpiry(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
d := &Dynamic{
|
||||
Secret: &Secret{
|
||||
Type: "Header",
|
||||
Domains: []string{"example.com"},
|
||||
Headers: []KV{{Key: "Authorization", Value: "Bearer {{token}}"}},
|
||||
},
|
||||
TemplatePath: "auth.yaml",
|
||||
Variables: []KV{{Key: "user", Value: "admin"}},
|
||||
RefreshInterval: "40ms",
|
||||
}
|
||||
require.NoError(t, d.Validate())
|
||||
d.SetLazyFetchCallback(func(dyn *Dynamic) error {
|
||||
n := calls.Add(1)
|
||||
dyn.Extracted = map[string]interface{}{"token": tokenForCall(n)}
|
||||
return nil
|
||||
})
|
||||
|
||||
require.Equal(t, "Bearer token-1", applyHeader(t, d))
|
||||
require.Equal(t, "Bearer token-1", applyHeader(t, d), "within interval the session is reused")
|
||||
require.Equal(t, int32(1), calls.Load())
|
||||
|
||||
time.Sleep(60 * time.Millisecond)
|
||||
require.True(t, d.IsExpired(), "session should expire after the refresh interval")
|
||||
require.Equal(t, "Bearer token-2", applyHeader(t, d), "expired session should re-authenticate")
|
||||
require.Equal(t, int32(2), calls.Load())
|
||||
}
|
||||
|
||||
func TestSessionNotifyResponseReauth(t *testing.T) {
|
||||
t.Run("configured status marks stale", func(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
d := newDynamicWithToken(t, &calls)
|
||||
d.ReauthStatusCodes = []int{401, 403}
|
||||
|
||||
require.Equal(t, "Bearer token-1", applyHeader(t, d))
|
||||
|
||||
require.False(t, d.NotifyResponse(200), "2xx must not trigger re-auth")
|
||||
require.False(t, d.IsExpired())
|
||||
|
||||
require.True(t, d.NotifyResponse(401), "401 must trigger re-auth for established session")
|
||||
require.True(t, d.IsExpired())
|
||||
|
||||
require.Equal(t, "Bearer token-2", applyHeader(t, d))
|
||||
require.Equal(t, int32(2), calls.Load())
|
||||
})
|
||||
|
||||
t.Run("no reauth before first fetch", func(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
d := newDynamicWithToken(t, &calls)
|
||||
d.ReauthStatusCodes = []int{401}
|
||||
|
||||
require.False(t, d.NotifyResponse(401), "should not mark stale before a session exists")
|
||||
require.Equal(t, int32(0), calls.Load())
|
||||
})
|
||||
|
||||
t.Run("unconfigured status code is ignored", func(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
d := newDynamicWithToken(t, &calls)
|
||||
// ReauthStatusCodes left empty => response-triggered reauth disabled
|
||||
require.Equal(t, "Bearer token-1", applyHeader(t, d))
|
||||
require.False(t, d.NotifyResponse(401))
|
||||
require.False(t, d.IsExpired())
|
||||
})
|
||||
}
|
||||
|
||||
func TestSessionResponseInspectorInterface(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
d := newDynamicWithToken(t, &calls)
|
||||
d.ReauthStatusCodes = []int{401}
|
||||
var strategy AuthStrategy = &DynamicAuthStrategy{Dynamic: *d}
|
||||
|
||||
inspector, ok := strategy.(ResponseInspector)
|
||||
require.True(t, ok, "DynamicAuthStrategy must implement ResponseInspector")
|
||||
|
||||
// establish session via the same shared state
|
||||
req, _ := http.NewRequest("GET", "https://example.com", nil)
|
||||
strategy.Apply(req)
|
||||
require.Equal(t, "Bearer token-1", req.Header.Get("Authorization"))
|
||||
|
||||
require.True(t, inspector.OnResponse(401))
|
||||
req2, _ := http.NewRequest("GET", "https://example.com", nil)
|
||||
strategy.Apply(req2)
|
||||
require.Equal(t, "Bearer token-2", req2.Header.Get("Authorization"))
|
||||
}
|
||||
|
||||
func TestSessionValidateRejectsBadRefreshInterval(t *testing.T) {
|
||||
d := &Dynamic{
|
||||
TemplatePath: "auth.yaml",
|
||||
Variables: []KV{{Key: "user", Value: "admin"}},
|
||||
RefreshInterval: "not-a-duration",
|
||||
}
|
||||
err := d.Validate()
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "invalid refresh-interval")
|
||||
}
|
||||
|
||||
// TestSessionConcurrentApplyDuringReauth exercises the locking contract: request
|
||||
// threads applying the strategy must never race a concurrent re-authentication.
|
||||
// Intended to be run with -race.
|
||||
func TestSessionConcurrentApplyDuringReauth(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
d := &Dynamic{
|
||||
Secret: &Secret{
|
||||
Type: "Header",
|
||||
Domains: []string{"example.com"},
|
||||
Headers: []KV{{Key: "Authorization", Value: "Bearer {{token}}"}},
|
||||
},
|
||||
TemplatePath: "auth.yaml",
|
||||
Variables: []KV{{Key: "user", Value: "admin"}},
|
||||
}
|
||||
require.NoError(t, d.Validate())
|
||||
d.SetLazyFetchCallback(func(dyn *Dynamic) error {
|
||||
calls.Add(1)
|
||||
// simulate some work during login so writers and readers overlap
|
||||
time.Sleep(time.Millisecond)
|
||||
dyn.Extracted = map[string]interface{}{"token": "tok"}
|
||||
return nil
|
||||
})
|
||||
|
||||
strategy := &DynamicAuthStrategy{Dynamic: *d}
|
||||
|
||||
const workers = 16
|
||||
var wg sync.WaitGroup
|
||||
stop := make(chan struct{})
|
||||
|
||||
// readers continuously apply the strategy
|
||||
for i := 0; i < workers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
req, _ := http.NewRequest("GET", "https://example.com", nil)
|
||||
strategy.Apply(req)
|
||||
require.Equal(t, "Bearer tok", req.Header.Get("Authorization"))
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// writers continuously invalidate the session, forcing re-auth
|
||||
for i := 0; i < 4; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
default:
|
||||
d.MarkStale()
|
||||
time.Sleep(time.Millisecond)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
close(stop)
|
||||
wg.Wait()
|
||||
|
||||
require.Greater(t, calls.Load(), int32(1), "re-authentication should have happened multiple times")
|
||||
}
|
||||
@@ -15,22 +15,6 @@ type AuthStrategy interface {
|
||||
ApplyOnRR(*retryablehttp.Request)
|
||||
}
|
||||
|
||||
// ResponseInspector is an optional interface that an AuthStrategy may implement
|
||||
// to observe responses to authenticated requests. It is used to detect session
|
||||
// expiry (e.g. a 401 after a previously valid session) and trigger
|
||||
// re-authentication for subsequent requests.
|
||||
type ResponseInspector interface {
|
||||
// OnResponse is called with the status code of a response to a request the
|
||||
// strategy authenticated. It returns true if re-authentication was triggered.
|
||||
OnResponse(statusCode int) bool
|
||||
}
|
||||
|
||||
var (
|
||||
_ AuthStrategy = &DynamicAuthStrategy{}
|
||||
_ ResponseInspector = &DynamicAuthStrategy{}
|
||||
_ BrowserStorageProvider = &DynamicAuthStrategy{}
|
||||
)
|
||||
|
||||
// DynamicAuthStrategy is an auth strategy for dynamic secrets
|
||||
// it implements the AuthStrategy interface
|
||||
type DynamicAuthStrategy struct {
|
||||
@@ -40,26 +24,22 @@ type DynamicAuthStrategy struct {
|
||||
|
||||
// Apply applies the strategy to the request
|
||||
func (d *DynamicAuthStrategy) Apply(req *http.Request) {
|
||||
d.Dynamic.ApplyStrategies(func(s AuthStrategy) {
|
||||
strategies := d.Dynamic.GetStrategies()
|
||||
if strategies == nil {
|
||||
return
|
||||
}
|
||||
for _, s := range strategies {
|
||||
if s == nil {
|
||||
continue
|
||||
}
|
||||
s.Apply(req)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyOnRR applies the strategy to the retryable request
|
||||
func (d *DynamicAuthStrategy) ApplyOnRR(req *retryablehttp.Request) {
|
||||
d.Dynamic.ApplyStrategies(func(s AuthStrategy) {
|
||||
strategy := d.Dynamic.GetStrategies()
|
||||
for _, s := range strategy {
|
||||
s.ApplyOnRR(req)
|
||||
})
|
||||
}
|
||||
|
||||
// OnResponse inspects a response status code and marks the dynamic session for
|
||||
// re-authentication when the code signals an expired session.
|
||||
func (d *DynamicAuthStrategy) OnResponse(statusCode int) bool {
|
||||
return d.Dynamic.NotifyResponse(statusCode)
|
||||
}
|
||||
|
||||
// WebStorage exposes the browser web storage captured by a headless auto-login
|
||||
// so the headless engine can replay it into scan pages.
|
||||
func (d *DynamicAuthStrategy) WebStorage() (map[string]string, map[string]string) {
|
||||
return d.Dynamic.WebStorage()
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package authx
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/projectdiscovery/retryablehttp-go"
|
||||
)
|
||||
|
||||
var (
|
||||
_ AuthStrategy = &WebStorageAuthStrategy{}
|
||||
_ BrowserStorageProvider = &WebStorageAuthStrategy{}
|
||||
)
|
||||
|
||||
// BrowserStorageProvider is an optional interface implemented by auth strategies
|
||||
// that carry browser web storage (localStorage/sessionStorage) to be replayed
|
||||
// into headless scan pages. HTTP-only strategies do not implement it, so the
|
||||
// headless engine type-asserts for it before seeding storage.
|
||||
type BrowserStorageProvider interface {
|
||||
// WebStorage returns the localStorage and sessionStorage items to seed.
|
||||
WebStorage() (local map[string]string, session map[string]string)
|
||||
}
|
||||
|
||||
// WebStorageAuthStrategy carries captured web storage. Its Apply methods are
|
||||
// intentional no-ops: web storage cannot be expressed on an HTTP request, so it
|
||||
// only takes effect in the headless engine, which reads it via the
|
||||
// BrowserStorageProvider interface and seeds it into the page before page
|
||||
// scripts run.
|
||||
type WebStorageAuthStrategy struct {
|
||||
Local map[string]string
|
||||
Session map[string]string
|
||||
}
|
||||
|
||||
// NewWebStorageAuthStrategy creates a new web storage auth strategy.
|
||||
func NewWebStorageAuthStrategy(local, session map[string]string) *WebStorageAuthStrategy {
|
||||
return &WebStorageAuthStrategy{Local: local, Session: session}
|
||||
}
|
||||
|
||||
// Apply is a no-op (web storage is not an HTTP request artifact).
|
||||
func (s *WebStorageAuthStrategy) Apply(_ *http.Request) {}
|
||||
|
||||
// ApplyOnRR is a no-op (web storage is not an HTTP request artifact).
|
||||
func (s *WebStorageAuthStrategy) ApplyOnRR(_ *retryablehttp.Request) {}
|
||||
|
||||
// WebStorage returns the carried storage items.
|
||||
func (s *WebStorageAuthStrategy) WebStorage() (map[string]string, map[string]string) {
|
||||
return s.Local, s.Session
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
package authx
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestWebStorageSecret_GetStrategy(t *testing.T) {
|
||||
s := &Secret{
|
||||
Type: string(WebStorageAuth),
|
||||
Domains: []string{"app.example.com"},
|
||||
LocalStorage: map[string]string{"jwt": "eyJ.payload.sig"},
|
||||
SessionStorage: map[string]string{"csrf": "tok"},
|
||||
}
|
||||
require.NoError(t, s.Validate())
|
||||
|
||||
strategy := s.GetStrategy()
|
||||
require.NotNil(t, strategy)
|
||||
|
||||
bsp, ok := strategy.(BrowserStorageProvider)
|
||||
require.True(t, ok, "web storage secret must yield a BrowserStorageProvider")
|
||||
local, session := bsp.WebStorage()
|
||||
require.Equal(t, "eyJ.payload.sig", local["jwt"])
|
||||
require.Equal(t, "tok", session["csrf"])
|
||||
}
|
||||
|
||||
func TestWebStorageSecret_ValidateEmpty(t *testing.T) {
|
||||
s := &Secret{Type: string(WebStorageAuth), Domains: []string{"app.example.com"}}
|
||||
require.Error(t, s.Validate(), "web storage secret with no storage must be invalid")
|
||||
}
|
||||
|
||||
func TestWebStorageSecret_SessionOnlyValid(t *testing.T) {
|
||||
s := &Secret{
|
||||
Type: string(WebStorageAuth),
|
||||
Domains: []string{"app.example.com"},
|
||||
SessionStorage: map[string]string{"csrf": "tok"},
|
||||
}
|
||||
require.NoError(t, s.Validate(), "session-storage alone should satisfy validation")
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
package autologin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/go-rod/rod/lib/proto"
|
||||
"github.com/projectdiscovery/utils/errkit"
|
||||
)
|
||||
|
||||
// CaptureOnce launches a visible browser at cfg.LoginURL and lets the user log
|
||||
// in manually, then — once ready() returns — captures the resulting session
|
||||
// (cookies, web storage and an optional token). It performs no automated form
|
||||
// filling.
|
||||
//
|
||||
// This is the "log in yourself, I'll grab the session" mode for one-off
|
||||
// authenticated scans of flows that are impractical to record/replay
|
||||
// (hardware-key MFA, CAPTCHA, interactive SSO). Because the captured session is
|
||||
// a point-in-time snapshot there is no automated re-authentication; ready
|
||||
// blocks until the caller signals login completion (e.g. the user pressing
|
||||
// Enter in the terminal).
|
||||
func CaptureOnce(ctx context.Context, cfg Config, ready func() error) (*Session, error) {
|
||||
if ready == nil {
|
||||
return nil, errkit.New("auto-login(capture): a ready signal is required")
|
||||
}
|
||||
if strings.TrimSpace(cfg.LoginURL) == "" {
|
||||
return nil, errkit.New("auto-login(capture): login-url is required")
|
||||
}
|
||||
if _, err := url.Parse(cfg.LoginURL); err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login(capture): invalid login-url")
|
||||
}
|
||||
|
||||
var tokenRe *regexp.Regexp
|
||||
if cfg.TokenRegex != "" {
|
||||
var err error
|
||||
if tokenRe, err = regexp.Compile(cfg.TokenRegex); err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login(capture): invalid token-regex")
|
||||
}
|
||||
}
|
||||
|
||||
// Force a visible browser so the user can interact with the login page.
|
||||
cfg.ShowBrowser = true
|
||||
|
||||
browser, cleanup, err := launchBrowser(cfg)
|
||||
if err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login(capture): failed to launch browser")
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
page, err := browser.Page(proto.TargetCreateTarget{})
|
||||
if err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login(capture): failed to open page")
|
||||
}
|
||||
defer func() { _ = page.Close() }()
|
||||
page = page.Context(ctx)
|
||||
|
||||
if cfg.UserAgent != "" {
|
||||
if uaErr := page.SetUserAgent(&proto.NetworkSetUserAgentOverride{UserAgent: cfg.UserAgent}); uaErr != nil {
|
||||
return nil, errkit.Wrap(uaErr, "auto-login(capture): failed to set user-agent")
|
||||
}
|
||||
}
|
||||
if len(cfg.CustomHeaders) > 0 {
|
||||
pairs := make([]string, 0, len(cfg.CustomHeaders)*2)
|
||||
for k, v := range cfg.CustomHeaders {
|
||||
pairs = append(pairs, k, v)
|
||||
}
|
||||
if _, hErr := page.SetExtraHeaders(pairs); hErr != nil {
|
||||
return nil, errkit.Wrap(hErr, "auto-login(capture): failed to set custom headers")
|
||||
}
|
||||
}
|
||||
|
||||
if err := page.Navigate(cfg.LoginURL); err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login(capture): failed to navigate to login page")
|
||||
}
|
||||
_ = page.WaitLoad()
|
||||
|
||||
// Block until the user signals that the manual login is complete.
|
||||
if err := ready(); err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login(capture): aborted before capture")
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
session := &Session{}
|
||||
if info, ierr := page.Info(); ierr == nil {
|
||||
session.FinalURL = info.URL
|
||||
}
|
||||
session.Cookies = capturePageCookies(page, cfg.LoginURL, session.FinalURL)
|
||||
session.CookieHeader = renderCookieHeader(session.Cookies)
|
||||
session.LocalStorage = readStorage(page, "localStorage")
|
||||
session.SessionStorage = readStorage(page, "sessionStorage")
|
||||
|
||||
if tokenRe != nil {
|
||||
var haystack strings.Builder
|
||||
if html, herr := page.HTML(); herr == nil {
|
||||
haystack.WriteString(html)
|
||||
}
|
||||
for _, store := range []map[string]string{session.LocalStorage, session.SessionStorage} {
|
||||
for k, v := range store {
|
||||
haystack.WriteString("\n")
|
||||
haystack.WriteString(k)
|
||||
haystack.WriteString("=")
|
||||
haystack.WriteString(v)
|
||||
}
|
||||
}
|
||||
if m := tokenRe.FindStringSubmatch(haystack.String()); len(m) > 1 {
|
||||
session.Token = m[1]
|
||||
}
|
||||
}
|
||||
|
||||
// When no explicit token-regex matched, surface a JWT-shaped value from web
|
||||
// storage so a captured SPA session also authenticates HTTP scan requests.
|
||||
if session.Token == "" {
|
||||
session.Token = detectStorageJWT(session.LocalStorage, session.SessionStorage)
|
||||
}
|
||||
|
||||
if len(session.Cookies) == 0 && session.Token == "" && len(session.LocalStorage) == 0 && len(session.SessionStorage) == 0 {
|
||||
return nil, errkit.New("auto-login(capture): no session captured (no cookies, token or web storage found)")
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
package autologin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// requireDisplay skips headful (visible-browser) tests when no display server is
|
||||
// available, e.g. headless Linux CI. macOS and Windows always provide one, but
|
||||
// capture-once forces a visible browser which cannot start without an X/Wayland
|
||||
// display.
|
||||
func requireDisplay(t *testing.T) {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "linux" && os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" {
|
||||
t.Skip("no display server ($DISPLAY/$WAYLAND_DISPLAY) available; skipping headful capture-once test")
|
||||
}
|
||||
}
|
||||
|
||||
// capturePage already "logs the user in" on GET (sets a session cookie and seeds
|
||||
// web storage via JS), so a test can drive CaptureOnce without simulating manual
|
||||
// interaction: the ready() callback simply returns once the page has loaded.
|
||||
const capturePage = `<html><head><title>App</title></head><body>
|
||||
<h1>welcome dave</h1>
|
||||
<script>
|
||||
window.localStorage.setItem('auth_token', 'eyJhbGciOiJI.payload.sig');
|
||||
window.sessionStorage.setItem('csrf', 'abc123');
|
||||
</script></body></html>`
|
||||
|
||||
func TestCaptureOnce_E2E(t *testing.T) {
|
||||
requireChrome(t)
|
||||
requireDisplay(t)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.SetCookie(w, &http.Cookie{Name: "session", Value: "sess-dave", Path: "/"})
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprint(w, capturePage)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var readyCalled bool
|
||||
session, err := CaptureOnce(ctx, Config{
|
||||
LoginURL: srv.URL + "/login",
|
||||
TokenRegex: `(eyJ[A-Za-z0-9._-]+)`,
|
||||
}, func() error {
|
||||
readyCalled = true // stands in for the user pressing Enter after logging in
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.True(t, readyCalled, "ready callback must be invoked before capture")
|
||||
|
||||
names := map[string]string{}
|
||||
for _, c := range session.Cookies {
|
||||
names[c.Name] = c.Value
|
||||
}
|
||||
require.Equal(t, "sess-dave", names["session"], "session cookie must be captured")
|
||||
require.Equal(t, "eyJhbGciOiJI.payload.sig", session.LocalStorage["auth_token"], "localStorage must be captured")
|
||||
require.Equal(t, "abc123", session.SessionStorage["csrf"], "sessionStorage must be captured")
|
||||
require.Equal(t, "eyJhbGciOiJI.payload.sig", session.Token, "token must be extracted")
|
||||
}
|
||||
|
||||
func TestCaptureOnce_ReadyErrorAborts(t *testing.T) {
|
||||
requireChrome(t)
|
||||
requireDisplay(t)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprint(w, "<html><body>login</body></html>")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
_, err := CaptureOnce(ctx, Config{LoginURL: srv.URL + "/login"}, func() error {
|
||||
return errors.New("user cancelled")
|
||||
})
|
||||
require.Error(t, err, "a ready() error must abort the capture")
|
||||
}
|
||||
|
||||
func TestCaptureOnce_Validation(t *testing.T) {
|
||||
_, err := CaptureOnce(context.Background(), Config{LoginURL: "https://x"}, nil)
|
||||
require.Error(t, err, "nil ready must error")
|
||||
|
||||
_, err = CaptureOnce(context.Background(), Config{}, func() error { return nil })
|
||||
require.Error(t, err, "missing login-url must error")
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
// Package autologin implements turnkey form-based authentication for nuclei's
|
||||
// authenticated scanning. Instead of requiring a hand-written auth template that
|
||||
// performs the login and extracts a token, the caller supplies only a login URL
|
||||
// and credentials; this package fetches the login page, auto-detects the login
|
||||
// form (username/password fields plus any hidden CSRF tokens), submits the
|
||||
// credentials and captures the resulting session.
|
||||
//
|
||||
// detect.go contains the pure, network-free form-detection logic so it can be
|
||||
// unit-tested in isolation from the HTTP engine in login.go.
|
||||
package autologin
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/projectdiscovery/utils/errkit"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNoLoginForm is returned when the page contains no form with a password
|
||||
// field (the minimal signal that a form is a credential-login form).
|
||||
ErrNoLoginForm = errkit.New("no login form (password field) found on page")
|
||||
)
|
||||
|
||||
// LoginForm describes a detected HTML login form and everything needed to
|
||||
// submit it.
|
||||
type LoginForm struct {
|
||||
// Action is the absolute URL the form submits to, resolved against the page URL.
|
||||
Action string
|
||||
// Method is the upper-cased HTTP method ("POST" or "GET").
|
||||
Method string
|
||||
// EncType is the form encoding ("application/x-www-form-urlencoded" or
|
||||
// "multipart/form-data"); empty means the urlencoded default.
|
||||
EncType string
|
||||
// UsernameField is the name of the input that should receive the username.
|
||||
// It may be empty when no plausible username field is present (e.g. a
|
||||
// password-only or token form), in which case the caller may still submit.
|
||||
UsernameField string
|
||||
// PasswordField is the name of the password input. Always non-empty on a
|
||||
// successfully detected form.
|
||||
PasswordField string
|
||||
// Fields holds extra form values that must be submitted verbatim — most
|
||||
// importantly hidden inputs carrying CSRF tokens, plus any pre-filled
|
||||
// defaults and a named submit button. Username/password are NOT included
|
||||
// here; the engine fills those into UsernameField/PasswordField.
|
||||
Fields map[string]string
|
||||
}
|
||||
|
||||
// usernameHintTokens are substrings (matched case-insensitively against an
|
||||
// input's name/id/autocomplete) that strongly suggest a username/identifier
|
||||
// field. Ordered roughly by specificity.
|
||||
var usernameHintTokens = []string{"username", "user", "email", "e-mail", "login", "account", "userid", "uid", "identifier", "phone"}
|
||||
|
||||
// DetectLoginForm parses the given HTML page body and returns the best
|
||||
// credential-login form found on it. pageURL is the absolute URL the body was
|
||||
// fetched from and is used to resolve relative form actions. It returns
|
||||
// ErrNoLoginForm if no form containing a password field exists.
|
||||
func DetectLoginForm(body string, pageURL *url.URL) (*LoginForm, error) {
|
||||
doc, err := html.Parse(strings.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, errkit.Wrap(err, "failed to parse html")
|
||||
}
|
||||
|
||||
var forms []*html.Node
|
||||
collectForms(doc, &forms)
|
||||
|
||||
var best *LoginForm
|
||||
bestScore := -1
|
||||
for _, formNode := range forms {
|
||||
candidate, score := buildLoginForm(formNode, pageURL)
|
||||
if candidate == nil {
|
||||
continue
|
||||
}
|
||||
// Prefer the highest-scoring form, keeping the first on ties so document
|
||||
// order acts as the tie-breaker.
|
||||
if score > bestScore {
|
||||
best = candidate
|
||||
bestScore = score
|
||||
}
|
||||
}
|
||||
if best == nil {
|
||||
return nil, ErrNoLoginForm
|
||||
}
|
||||
return best, nil
|
||||
}
|
||||
|
||||
// collectForms walks the parse tree depth-first and appends every <form> node.
|
||||
func collectForms(n *html.Node, out *[]*html.Node) {
|
||||
if n.Type == html.ElementNode && n.Data == "form" {
|
||||
*out = append(*out, n)
|
||||
}
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
collectForms(c, out)
|
||||
}
|
||||
}
|
||||
|
||||
// fieldNode is a flattened view of a form control relevant to login detection.
|
||||
type fieldNode struct {
|
||||
tag string // input, select, textarea, button
|
||||
typ string // lower-cased type attribute (inputs)
|
||||
name string
|
||||
id string
|
||||
value string
|
||||
autoComp string // autocomplete attribute
|
||||
}
|
||||
|
||||
// buildLoginForm inspects a single <form> node and, if it looks like a
|
||||
// credential-login form (contains exactly one usable password field), returns a
|
||||
// populated LoginForm together with a heuristic score used to pick the best
|
||||
// form on a page. A nil form means "not a login form".
|
||||
func buildLoginForm(formNode *html.Node, pageURL *url.URL) (*LoginForm, int) {
|
||||
var fields []fieldNode
|
||||
collectFields(formNode, &fields)
|
||||
|
||||
// Locate the password field. A form with no password field is not a
|
||||
// credential-login form; a form with several password fields is almost
|
||||
// always a registration / change-password form, so we skip it.
|
||||
passwordIdx := -1
|
||||
passwordCount := 0
|
||||
for i, f := range fields {
|
||||
if f.tag == "input" && f.typ == "password" {
|
||||
passwordCount++
|
||||
if passwordIdx == -1 {
|
||||
passwordIdx = i
|
||||
}
|
||||
}
|
||||
}
|
||||
if passwordIdx == -1 || passwordCount > 1 {
|
||||
return nil, 0
|
||||
}
|
||||
password := fields[passwordIdx]
|
||||
if password.name == "" {
|
||||
// A password field with no name cannot be submitted.
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
form := &LoginForm{
|
||||
Method: resolveMethod(formNode),
|
||||
EncType: strings.ToLower(strings.TrimSpace(getAttr(formNode, "enctype"))),
|
||||
PasswordField: password.name,
|
||||
Fields: map[string]string{},
|
||||
}
|
||||
form.Action = resolveAction(formNode, pageURL)
|
||||
|
||||
usernameIdx := pickUsernameField(fields, passwordIdx)
|
||||
if usernameIdx >= 0 {
|
||||
form.UsernameField = fields[usernameIdx].name
|
||||
}
|
||||
|
||||
// Carry along hidden inputs (CSRF tokens etc.), pre-filled defaults and a
|
||||
// single named submit button, but never the username/password controls.
|
||||
submitAdded := false
|
||||
for i, f := range fields {
|
||||
if i == passwordIdx || i == usernameIdx || f.name == "" {
|
||||
continue
|
||||
}
|
||||
switch {
|
||||
case f.tag == "input" && f.typ == "hidden":
|
||||
form.Fields[f.name] = f.value
|
||||
case f.tag == "input" && (f.typ == "submit" || f.typ == "image"):
|
||||
// Many backends key off the submit button name; include the first one.
|
||||
if !submitAdded && f.value != "" {
|
||||
form.Fields[f.name] = f.value
|
||||
submitAdded = true
|
||||
}
|
||||
case f.tag == "button" && (f.typ == "submit" || f.typ == ""):
|
||||
if !submitAdded && f.value != "" {
|
||||
form.Fields[f.name] = f.value
|
||||
submitAdded = true
|
||||
}
|
||||
case f.value != "" && f.typ != "checkbox" && f.typ != "radio":
|
||||
// Preserve pre-filled defaults for other text-like inputs so we don't
|
||||
// drop values the server expects.
|
||||
form.Fields[f.name] = f.value
|
||||
}
|
||||
}
|
||||
|
||||
// Score: a real login form usually has both a username and a password field;
|
||||
// CSRF/hidden tokens raise confidence further. This lets DetectLoginForm
|
||||
// prefer the actual login form over, say, a newsletter box that happens to
|
||||
// share the page.
|
||||
score := 1
|
||||
if form.UsernameField != "" {
|
||||
score += 2
|
||||
}
|
||||
if len(form.Fields) > 0 {
|
||||
score++
|
||||
}
|
||||
return form, score
|
||||
}
|
||||
|
||||
// collectFields flattens the form controls within a form node in document order.
|
||||
func collectFields(n *html.Node, out *[]fieldNode) {
|
||||
for c := n.FirstChild; c != nil; c = c.NextSibling {
|
||||
if c.Type == html.ElementNode {
|
||||
switch c.Data {
|
||||
case "input", "select", "textarea", "button":
|
||||
*out = append(*out, fieldNode{
|
||||
tag: c.Data,
|
||||
typ: strings.ToLower(strings.TrimSpace(getAttr(c, "type"))),
|
||||
name: getAttr(c, "name"),
|
||||
id: getAttr(c, "id"),
|
||||
value: getAttr(c, "value"),
|
||||
autoComp: strings.ToLower(strings.TrimSpace(getAttr(c, "autocomplete"))),
|
||||
})
|
||||
}
|
||||
}
|
||||
collectFields(c, out)
|
||||
}
|
||||
}
|
||||
|
||||
// pickUsernameField selects the most likely username/identifier field. It
|
||||
// considers only text-like inputs appearing before the password field and
|
||||
// prefers ones whose name/id/autocomplete match a known hint token; otherwise
|
||||
// it falls back to the closest text-like input preceding the password.
|
||||
func pickUsernameField(fields []fieldNode, passwordIdx int) int {
|
||||
isCandidate := func(f fieldNode) bool {
|
||||
if f.tag != "input" {
|
||||
return false
|
||||
}
|
||||
switch f.typ {
|
||||
case "", "text", "email", "tel", "search":
|
||||
return f.name != ""
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// First pass: hint-token match among candidates preceding the password field.
|
||||
bestHint := -1
|
||||
bestHintRank := len(usernameHintTokens)
|
||||
for i := 0; i < passwordIdx; i++ {
|
||||
f := fields[i]
|
||||
if !isCandidate(f) {
|
||||
continue
|
||||
}
|
||||
hay := strings.ToLower(f.name + " " + f.id + " " + f.autoComp)
|
||||
for rank, tok := range usernameHintTokens {
|
||||
if strings.Contains(hay, tok) && rank < bestHintRank {
|
||||
bestHint = i
|
||||
bestHintRank = rank
|
||||
}
|
||||
}
|
||||
}
|
||||
if bestHint >= 0 {
|
||||
return bestHint
|
||||
}
|
||||
|
||||
// Second pass: closest text-like input before the password field.
|
||||
for i := passwordIdx - 1; i >= 0; i-- {
|
||||
if isCandidate(fields[i]) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: first text-like input anywhere in the form (covers oddly
|
||||
// ordered markup where the username sits after the password).
|
||||
for i := range fields {
|
||||
if i == passwordIdx {
|
||||
continue
|
||||
}
|
||||
if isCandidate(fields[i]) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
// resolveMethod returns the upper-cased form method, defaulting to POST. The
|
||||
// HTML default is GET, but credential-login forms virtually always POST, and a
|
||||
// GET login would leak credentials into the URL/query — so for an autonomous
|
||||
// login submitter POST is the safer default when unspecified.
|
||||
func resolveMethod(formNode *html.Node) string {
|
||||
m := strings.ToUpper(strings.TrimSpace(getAttr(formNode, "method")))
|
||||
if m == "" {
|
||||
return "POST"
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// resolveAction resolves the form action against the page URL. An empty/missing
|
||||
// action submits back to the page itself.
|
||||
func resolveAction(formNode *html.Node, pageURL *url.URL) string {
|
||||
action := strings.TrimSpace(getAttr(formNode, "action"))
|
||||
if action == "" {
|
||||
if pageURL != nil {
|
||||
return pageURL.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if pageURL == nil {
|
||||
return action
|
||||
}
|
||||
ref, err := url.Parse(action)
|
||||
if err != nil {
|
||||
return action
|
||||
}
|
||||
return pageURL.ResolveReference(ref).String()
|
||||
}
|
||||
|
||||
// getAttr returns the value of the named attribute (case-insensitive) on an
|
||||
// element node, or "" if absent.
|
||||
func getAttr(n *html.Node, key string) string {
|
||||
for _, a := range n.Attr {
|
||||
if strings.EqualFold(a.Key, key) {
|
||||
return a.Val
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
package autologin
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func mustURL(t *testing.T, raw string) *url.URL {
|
||||
t.Helper()
|
||||
u, err := url.Parse(raw)
|
||||
require.NoError(t, err)
|
||||
return u
|
||||
}
|
||||
|
||||
func TestDetectLoginForm_Simple(t *testing.T) {
|
||||
body := `<html><body>
|
||||
<form action="/login" method="post">
|
||||
<input type="text" name="username">
|
||||
<input type="password" name="password">
|
||||
<button type="submit">Log in</button>
|
||||
</form>
|
||||
</body></html>`
|
||||
|
||||
form, err := DetectLoginForm(body, mustURL(t, "https://app.example.com/auth"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "POST", form.Method)
|
||||
require.Equal(t, "https://app.example.com/login", form.Action)
|
||||
require.Equal(t, "username", form.UsernameField)
|
||||
require.Equal(t, "password", form.PasswordField)
|
||||
}
|
||||
|
||||
func TestDetectLoginForm_EmailAndCSRF(t *testing.T) {
|
||||
body := `<html><body>
|
||||
<form action="https://id.example.com/sessions" method="POST">
|
||||
<input type="hidden" name="csrf_token" value="abc123">
|
||||
<input type="email" name="email" autocomplete="username">
|
||||
<input type="password" name="pass">
|
||||
<input type="submit" name="commit" value="Sign in">
|
||||
</form>
|
||||
</body></html>`
|
||||
|
||||
form, err := DetectLoginForm(body, mustURL(t, "https://id.example.com/login"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://id.example.com/sessions", form.Action)
|
||||
require.Equal(t, "email", form.UsernameField)
|
||||
require.Equal(t, "pass", form.PasswordField)
|
||||
require.Equal(t, "abc123", form.Fields["csrf_token"], "CSRF hidden input must be carried")
|
||||
require.Equal(t, "Sign in", form.Fields["commit"], "named submit button should be carried")
|
||||
require.NotContains(t, form.Fields, "email", "username field must not be in extra fields")
|
||||
require.NotContains(t, form.Fields, "pass", "password field must not be in extra fields")
|
||||
}
|
||||
|
||||
func TestDetectLoginForm_RelativeActionResolution(t *testing.T) {
|
||||
body := `<form action="../session" method="post">
|
||||
<input type="text" name="user">
|
||||
<input type="password" name="pwd">
|
||||
</form>`
|
||||
|
||||
form, err := DetectLoginForm(body, mustURL(t, "https://example.com/app/login"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://example.com/session", form.Action)
|
||||
}
|
||||
|
||||
func TestDetectLoginForm_MissingActionUsesPageURL(t *testing.T) {
|
||||
body := `<form method="post">
|
||||
<input type="text" name="user">
|
||||
<input type="password" name="pwd">
|
||||
</form>`
|
||||
|
||||
form, err := DetectLoginForm(body, mustURL(t, "https://example.com/login?next=/home"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://example.com/login?next=/home", form.Action)
|
||||
}
|
||||
|
||||
func TestDetectLoginForm_DefaultMethodIsPost(t *testing.T) {
|
||||
body := `<form action="/login">
|
||||
<input type="text" name="user">
|
||||
<input type="password" name="pwd">
|
||||
</form>`
|
||||
|
||||
form, err := DetectLoginForm(body, mustURL(t, "https://example.com/login"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "POST", form.Method)
|
||||
}
|
||||
|
||||
func TestDetectLoginForm_PicksLoginFormAmongMany(t *testing.T) {
|
||||
body := `<html><body>
|
||||
<form action="/search" method="get">
|
||||
<input type="text" name="q">
|
||||
<input type="submit" value="Search">
|
||||
</form>
|
||||
<form action="/newsletter" method="post">
|
||||
<input type="email" name="newsletter_email">
|
||||
<input type="submit" value="Subscribe">
|
||||
</form>
|
||||
<form action="/login" method="post">
|
||||
<input type="hidden" name="_token" value="xyz">
|
||||
<input type="text" name="login" autocomplete="username">
|
||||
<input type="password" name="password">
|
||||
<input type="submit" value="Sign in">
|
||||
</form>
|
||||
</body></html>`
|
||||
|
||||
form, err := DetectLoginForm(body, mustURL(t, "https://example.com/"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "https://example.com/login", form.Action)
|
||||
require.Equal(t, "login", form.UsernameField)
|
||||
require.Equal(t, "password", form.PasswordField)
|
||||
require.Equal(t, "xyz", form.Fields["_token"])
|
||||
}
|
||||
|
||||
func TestDetectLoginForm_UsernameHintBeatsOrder(t *testing.T) {
|
||||
// Two text inputs precede the password; the one with a username hint should
|
||||
// win even though it is not the closest to the password field.
|
||||
body := `<form action="/login" method="post">
|
||||
<input type="text" name="email" autocomplete="username">
|
||||
<input type="text" name="captcha">
|
||||
<input type="password" name="password">
|
||||
</form>`
|
||||
|
||||
form, err := DetectLoginForm(body, mustURL(t, "https://example.com/login"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "email", form.UsernameField)
|
||||
require.NotContains(t, form.Fields, "captcha", "valueless non-username text input should not be injected")
|
||||
}
|
||||
|
||||
func TestDetectLoginForm_NoPasswordField(t *testing.T) {
|
||||
body := `<form action="/search" method="get">
|
||||
<input type="text" name="q">
|
||||
<input type="submit" value="Search">
|
||||
</form>`
|
||||
|
||||
_, err := DetectLoginForm(body, mustURL(t, "https://example.com/"))
|
||||
require.ErrorIs(t, err, ErrNoLoginForm)
|
||||
}
|
||||
|
||||
func TestDetectLoginForm_SkipsMultiPasswordForms(t *testing.T) {
|
||||
// A registration / change-password form (two password fields) must not be
|
||||
// treated as a login form.
|
||||
body := `<form action="/register" method="post">
|
||||
<input type="text" name="username">
|
||||
<input type="password" name="password">
|
||||
<input type="password" name="password_confirm">
|
||||
</form>`
|
||||
|
||||
_, err := DetectLoginForm(body, mustURL(t, "https://example.com/"))
|
||||
require.ErrorIs(t, err, ErrNoLoginForm)
|
||||
}
|
||||
|
||||
func TestDetectLoginForm_PasswordOnlyForm(t *testing.T) {
|
||||
// A password-only form (no username) is still a valid login form.
|
||||
body := `<form action="/unlock" method="post">
|
||||
<input type="hidden" name="token" value="t">
|
||||
<input type="password" name="password">
|
||||
</form>`
|
||||
|
||||
form, err := DetectLoginForm(body, mustURL(t, "https://example.com/unlock"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "", form.UsernameField)
|
||||
require.Equal(t, "password", form.PasswordField)
|
||||
require.Equal(t, "t", form.Fields["token"])
|
||||
}
|
||||
|
||||
func TestDetectLoginForm_DefaultTypeIsText(t *testing.T) {
|
||||
// An input with no type attribute defaults to text and should be eligible as
|
||||
// the username field.
|
||||
body := `<form action="/login" method="post">
|
||||
<input name="user">
|
||||
<input type="password" name="pwd">
|
||||
</form>`
|
||||
|
||||
form, err := DetectLoginForm(body, mustURL(t, "https://example.com/login"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "user", form.UsernameField)
|
||||
}
|
||||
@@ -1,649 +0,0 @@
|
||||
package autologin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-rod/rod"
|
||||
"github.com/go-rod/rod/lib/input"
|
||||
"github.com/go-rod/rod/lib/launcher"
|
||||
"github.com/go-rod/rod/lib/proto"
|
||||
"github.com/projectdiscovery/gologger"
|
||||
"github.com/projectdiscovery/utils/errkit"
|
||||
fileutil "github.com/projectdiscovery/utils/file"
|
||||
osutils "github.com/projectdiscovery/utils/os"
|
||||
)
|
||||
|
||||
// LoginHeadless performs a browser-driven auto-login using go-rod. Unlike the
|
||||
// HTTP engine (Login), it executes JavaScript, so it works on SPA/JS-rendered
|
||||
// login pages, client-side form construction and multi-step / SSO redirect
|
||||
// flows that the static HTTP form submitter cannot handle. It navigates to the
|
||||
// login page in a real (headless) browser, fills the detected credential
|
||||
// fields, submits, waits for the page to settle and captures the resulting
|
||||
// session (cookies, plus an optional token from the body or localStorage).
|
||||
//
|
||||
// It intentionally drives go-rod directly rather than nuclei's headless engine
|
||||
// package: that package imports authprovider, and autologin is (transitively)
|
||||
// imported by authprovider, so depending on it would create an import cycle.
|
||||
func LoginHeadless(ctx context.Context, cfg Config) (*Session, error) {
|
||||
if strings.TrimSpace(cfg.LoginURL) == "" {
|
||||
return nil, errkit.New("auto-login(headless): login-url is required")
|
||||
}
|
||||
loginURL, err := url.Parse(cfg.LoginURL)
|
||||
if err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login(headless): invalid login-url")
|
||||
}
|
||||
|
||||
var tokenRe *regexp.Regexp
|
||||
if cfg.TokenRegex != "" {
|
||||
if tokenRe, err = regexp.Compile(cfg.TokenRegex); err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login(headless): invalid token-regex")
|
||||
}
|
||||
}
|
||||
|
||||
timeout := cfg.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = 45 * time.Second
|
||||
}
|
||||
settle := cfg.SettleTime
|
||||
if settle == 0 {
|
||||
settle = 5 * time.Second
|
||||
}
|
||||
|
||||
browser, cleanup, err := launchBrowser(cfg)
|
||||
if err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login(headless): failed to launch browser")
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
// Use an incognito context for a clean, isolated cookie jar per login.
|
||||
incognito, err := browser.Incognito()
|
||||
if err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login(headless): failed to create incognito context")
|
||||
}
|
||||
|
||||
page, err := incognito.Page(proto.TargetCreateTarget{})
|
||||
if err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login(headless): failed to open page")
|
||||
}
|
||||
defer func() { _ = page.Close() }()
|
||||
page = page.Context(ctx).Timeout(timeout)
|
||||
|
||||
// Passively observe response headers so a token delivered via a response
|
||||
// header (Authorization, X-Auth-Token, ...) during an XHR login is captured,
|
||||
// mirroring the HTTP engine. This is observation-only (no request hijacking)
|
||||
// to avoid interfering with the login flow. The listener stops when the page
|
||||
// is closed (deferred above).
|
||||
//
|
||||
// Two safeguards keep this from latching onto the wrong token: we only honor
|
||||
// responses served by the login origin (a page loads many third-party
|
||||
// requests — CDNs, analytics — whose headers must not be mistaken for the
|
||||
// app's session token), and we keep the first match (first-party auth
|
||||
// responses come early; a later beacon must not clobber the real token).
|
||||
var (
|
||||
headerTokenMu sync.Mutex
|
||||
headerToken string
|
||||
)
|
||||
if err := (proto.NetworkEnable{}).Call(page); err == nil {
|
||||
go page.EachEvent(func(e *proto.NetworkResponseReceived) {
|
||||
if e.Response == nil {
|
||||
return
|
||||
}
|
||||
if !sameLoginOrigin(loginURL, e.Response.URL) {
|
||||
return
|
||||
}
|
||||
hdr := make(http.Header, len(e.Response.Headers))
|
||||
for k, v := range e.Response.Headers {
|
||||
hdr.Set(k, v.Str())
|
||||
}
|
||||
if tok := detectHeaderToken(hdr, tokenRe); tok != "" {
|
||||
headerTokenMu.Lock()
|
||||
if headerToken == "" {
|
||||
headerToken = tok
|
||||
}
|
||||
headerTokenMu.Unlock()
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
// Apply UA / custom headers before navigating so the login request carries
|
||||
// the same identity as the scan.
|
||||
if cfg.UserAgent != "" {
|
||||
if uaErr := page.SetUserAgent(&proto.NetworkSetUserAgentOverride{UserAgent: cfg.UserAgent}); uaErr != nil {
|
||||
return nil, errkit.Wrap(uaErr, "auto-login(headless): failed to set user-agent")
|
||||
}
|
||||
}
|
||||
if len(cfg.CustomHeaders) > 0 {
|
||||
pairs := make([]string, 0, len(cfg.CustomHeaders)*2)
|
||||
for k, v := range cfg.CustomHeaders {
|
||||
pairs = append(pairs, k, v)
|
||||
}
|
||||
if _, hErr := page.SetExtraHeaders(pairs); hErr != nil {
|
||||
return nil, errkit.Wrap(hErr, "auto-login(headless): failed to set custom headers")
|
||||
}
|
||||
}
|
||||
|
||||
if err := page.Navigate(cfg.LoginURL); err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login(headless): failed to navigate to login page")
|
||||
}
|
||||
if err := page.WaitLoad(); err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login(headless): login page failed to load")
|
||||
}
|
||||
// Give client-side scripts a chance to render the form.
|
||||
_ = rod.Try(func() { page.Timeout(settle).MustWaitStable() })
|
||||
|
||||
if len(cfg.Steps) > 0 {
|
||||
// Explicit multi-step flow (username-first / SSO / consent screens).
|
||||
if err := runLoginSteps(ctx, page, cfg, settle); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else if err := autoFillAndSubmit(ctx, page, cfg, loginURL, settle); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Wait for the post-submit navigation / SPA state to settle.
|
||||
_ = rod.Try(func() { page.Timeout(settle).MustWaitStable() })
|
||||
_ = page.WaitLoad()
|
||||
|
||||
session := &Session{}
|
||||
if info, ierr := page.Info(); ierr == nil {
|
||||
session.FinalURL = info.URL
|
||||
}
|
||||
|
||||
// Capture cookies and web storage from the browser for the login + final hosts.
|
||||
session.Cookies = capturePageCookies(page, cfg.LoginURL, session.FinalURL)
|
||||
session.CookieHeader = renderCookieHeader(session.Cookies)
|
||||
session.LocalStorage = readStorage(page, "localStorage")
|
||||
session.SessionStorage = readStorage(page, "sessionStorage")
|
||||
|
||||
// Token extraction: search the rendered body and web storage.
|
||||
if tokenRe != nil {
|
||||
var haystack strings.Builder
|
||||
if html, herr := page.HTML(); herr == nil {
|
||||
haystack.WriteString(html)
|
||||
}
|
||||
for _, store := range []map[string]string{session.LocalStorage, session.SessionStorage} {
|
||||
for k, v := range store {
|
||||
haystack.WriteString("\n")
|
||||
haystack.WriteString(k)
|
||||
haystack.WriteString("=")
|
||||
haystack.WriteString(v)
|
||||
}
|
||||
}
|
||||
if m := tokenRe.FindStringSubmatch(haystack.String()); len(m) > 1 {
|
||||
session.Token = m[1]
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: many SPAs keep their bearer token in web storage (e.g.
|
||||
// localStorage["token"]) rather than a cookie. When no explicit token-regex
|
||||
// matched, surface a JWT-shaped value from web storage as the session token
|
||||
// so HTTP scan requests carry an Authorization header without the user
|
||||
// having to specify a token-regex.
|
||||
if session.Token == "" {
|
||||
session.Token = detectStorageJWT(session.LocalStorage, session.SessionStorage)
|
||||
}
|
||||
|
||||
// Last resort: a token observed in a response header during the login (e.g.
|
||||
// an XHR auth call that returned Authorization / X-Auth-Token).
|
||||
if session.Token == "" {
|
||||
headerTokenMu.Lock()
|
||||
session.Token = headerToken
|
||||
headerTokenMu.Unlock()
|
||||
}
|
||||
|
||||
// Success heuristic: being re-prompted (final page still presents a login
|
||||
// form) with no token means the login failed.
|
||||
if session.Token == "" {
|
||||
if html, herr := page.HTML(); herr == nil {
|
||||
var finalParsed *url.URL
|
||||
if session.FinalURL != "" {
|
||||
finalParsed, _ = url.Parse(session.FinalURL)
|
||||
}
|
||||
if _, derr := DetectLoginForm(html, finalParsed); derr == nil {
|
||||
return nil, errkit.Wrapf(ErrLoginFailed, "still presented a login form at %s", session.FinalURL)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(session.Cookies) == 0 && session.Token == "" {
|
||||
return nil, errkit.Wrapf(ErrLoginFailed, "no cookies or token captured at %s", session.FinalURL)
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// sameLoginOrigin reports whether respURL is served by the same host as the
|
||||
// login page. It is used to ignore third-party (CDN/analytics) responses when
|
||||
// passively scavenging a session token from response headers. An unparsable
|
||||
// respURL is treated as not matching (fail closed).
|
||||
func sameLoginOrigin(loginURL *url.URL, respURL string) bool {
|
||||
if loginURL == nil || respURL == "" {
|
||||
return false
|
||||
}
|
||||
u, err := url.Parse(respURL)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(u.Hostname(), loginURL.Hostname())
|
||||
}
|
||||
|
||||
// jwtValueRe matches a JWT-shaped value (header.payload.signature in base64url).
|
||||
// It is intentionally not anchored so it also matches tokens wrapped in quotes
|
||||
// or embedded in a small JSON blob stored in web storage.
|
||||
var jwtValueRe = regexp.MustCompile(`eyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{4,}`)
|
||||
|
||||
// detectStorageJWT returns the first JWT-shaped value found across the supplied
|
||||
// web storage maps, or "" when none is present. Keys that conventionally hold an
|
||||
// auth token are inspected first (and otherwise keys are visited in sorted order)
|
||||
// so the result is deterministic across runs.
|
||||
func detectStorageJWT(stores ...map[string]string) string {
|
||||
preferredKeys := []string{"token", "access_token", "accesstoken", "jwt", "auth_token", "authtoken", "id_token", "idtoken", "bearer"}
|
||||
for _, store := range stores {
|
||||
if len(store) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, want := range preferredKeys {
|
||||
for k, v := range store {
|
||||
if strings.EqualFold(k, want) {
|
||||
if m := jwtValueRe.FindString(v); m != "" {
|
||||
return m
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, store := range stores {
|
||||
if len(store) == 0 {
|
||||
continue
|
||||
}
|
||||
keys := make([]string, 0, len(store))
|
||||
for k := range store {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
if m := jwtValueRe.FindString(store[k]); m != "" {
|
||||
return m
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// launchBrowser launches (or connects to) a browser and returns it with a
|
||||
// cleanup func. A system-installed Chrome is preferred when available to avoid
|
||||
// triggering a managed-browser download.
|
||||
func launchBrowser(cfg Config) (*rod.Browser, func(), error) {
|
||||
if cfg.CDPEndpoint != "" {
|
||||
b := rod.New().ControlURL(cfg.CDPEndpoint)
|
||||
if err := b.Connect(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return b, func() { _ = b.Close() }, nil
|
||||
}
|
||||
|
||||
l := launcher.New().
|
||||
Set("disable-gpu").
|
||||
Set("disable-notifications").
|
||||
Set("ignore-certificate-errors").
|
||||
Set("ignore-ssl-errors").
|
||||
Set("incognito").
|
||||
Headless(!cfg.ShowBrowser).
|
||||
Leakless(false)
|
||||
if osutils.IsLinux() {
|
||||
l = l.NoSandbox(true)
|
||||
}
|
||||
if cfg.Proxy != "" {
|
||||
l = l.Proxy(cfg.Proxy)
|
||||
}
|
||||
// Mirror nuclei's headless engine browser selection: on musl (most likely
|
||||
// Alpine) the managed Chromium download cannot run, so a system Chrome is
|
||||
// required — as it is when the user forces it. Otherwise prefer a system
|
||||
// Chrome when present and fall back to go-rod's managed browser, logging so a
|
||||
// one-time download is not surprising in a server/CI context.
|
||||
executablePath, _ := os.Executable()
|
||||
useMusl, _ := fileutil.UseMusl(executablePath)
|
||||
if path, ok := launcher.LookPath(); ok {
|
||||
l = l.Bin(path)
|
||||
} else if cfg.UseInstalledChrome || useMusl {
|
||||
return nil, nil, errkit.New("auto-login(headless): no chrome/chromium binary found (a system install is required here)")
|
||||
} else {
|
||||
gologger.Info().Msgf("auto-login(headless): no system chrome found, using go-rod managed browser (a one-time download may occur)")
|
||||
}
|
||||
|
||||
controlURL, err := l.Launch()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
b := rod.New().ControlURL(controlURL)
|
||||
if err := b.Connect(); err != nil {
|
||||
l.Kill()
|
||||
return nil, nil, err
|
||||
}
|
||||
return b, func() { _ = b.Close(); l.Kill() }, nil
|
||||
}
|
||||
|
||||
// autoFillAndSubmit runs the default single-shot login: detect the password
|
||||
// (and username) field, type the credentials and submit.
|
||||
func autoFillAndSubmit(ctx context.Context, page *rod.Page, cfg Config, loginURL *url.URL, settle time.Duration) error {
|
||||
// SPA forms are frequently injected after an async tick, so the password
|
||||
// field may not exist immediately after the initial settle. Wait (bounded by
|
||||
// settle) for it to appear before detecting, rather than failing outright.
|
||||
_ = waitVisible(ctx, page, `input[type="password"]`, settle)
|
||||
|
||||
// Prefer field names detected from the rendered DOM; fall back to type-based
|
||||
// selectors for SPA inputs that carry no name attribute.
|
||||
var detected *LoginForm
|
||||
if html, herr := page.HTML(); herr == nil {
|
||||
detected, _ = DetectLoginForm(html, loginURL)
|
||||
}
|
||||
|
||||
passEl := locatePasswordField(page, cfg, detected)
|
||||
if passEl == nil {
|
||||
return errkit.Wrap(ErrNoLoginForm, "auto-login(headless): no password field found in rendered page")
|
||||
}
|
||||
if userEl := locateUsernameField(page, cfg, detected); userEl != nil && cfg.Username != "" {
|
||||
if err := typeInto(userEl, cfg.Username); err != nil {
|
||||
return errkit.Wrap(err, "auto-login(headless): failed to type username")
|
||||
}
|
||||
}
|
||||
if err := typeInto(passEl, cfg.Password); err != nil {
|
||||
return errkit.Wrap(err, "auto-login(headless): failed to type password")
|
||||
}
|
||||
// Let the framework register the typed input and validate the form (which
|
||||
// often gates the submit button) before attempting to submit.
|
||||
_ = rod.Try(func() { page.Timeout(settle).MustWaitStable() })
|
||||
return submitForm(page, passEl, settle)
|
||||
}
|
||||
|
||||
// submitForm submits a login form. It prefers clicking a submit control, but
|
||||
// frameworks frequently keep that control disabled until the form validates, so
|
||||
// the click is bounded by interactable-wait: if the button never becomes
|
||||
// interactable (or the click fails) it falls back to pressing Enter in the
|
||||
// given field, which reliably triggers native/framework form submission.
|
||||
func submitForm(page *rod.Page, fallbackField *rod.Element, settle time.Duration) error {
|
||||
clickTimeout := settle
|
||||
if clickTimeout <= 0 {
|
||||
clickTimeout = 5 * time.Second
|
||||
}
|
||||
if btn := findVisible(page, `button[type="submit"]`, `input[type="submit"]`, `button:not([type])`, `[role="button"]`); btn != nil {
|
||||
// Bound the interactable-wait so a permanently-disabled button can't hang
|
||||
// the whole login; on failure we fall through to Enter-to-submit.
|
||||
clickErr := rod.Try(func() {
|
||||
b := btn.Timeout(clickTimeout)
|
||||
b.MustWaitInteractable()
|
||||
_ = b.ScrollIntoView()
|
||||
b.MustClick()
|
||||
})
|
||||
if clickErr == nil {
|
||||
return nil
|
||||
}
|
||||
if fallbackField == nil {
|
||||
return errkit.Wrapf(ErrLoginFailed, "auto-login(headless): submit button never became interactable: %s", clickErr)
|
||||
}
|
||||
// fall through to Enter-to-submit
|
||||
}
|
||||
if fallbackField != nil {
|
||||
if err := fallbackField.Type(input.Enter); err != nil {
|
||||
return errkit.Wrap(err, "auto-login(headless): failed to submit form")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return errkit.Wrap(ErrLoginFailed, "auto-login(headless): no submit control found and no field to submit")
|
||||
}
|
||||
|
||||
// runLoginSteps executes an explicit multi-step login flow.
|
||||
func runLoginSteps(ctx context.Context, page *rod.Page, cfg Config, settle time.Duration) error {
|
||||
for i, step := range cfg.Steps {
|
||||
action := strings.ToLower(strings.TrimSpace(step.Action))
|
||||
switch action {
|
||||
case "navigate":
|
||||
if err := page.Navigate(step.Value); err != nil {
|
||||
return errkit.Wrapf(err, "login step %d (navigate): failed", i)
|
||||
}
|
||||
_ = page.WaitLoad()
|
||||
case "fill", "input", "type":
|
||||
el := findVisible(page, byName(step.Selector), step.Selector)
|
||||
if el == nil {
|
||||
return errkit.Newf("login step %d (fill): element not found: %s", i, step.Selector)
|
||||
}
|
||||
if err := typeInto(el, expandCredentials(step.Value, cfg)); err != nil {
|
||||
return errkit.Wrapf(err, "login step %d (fill): failed", i)
|
||||
}
|
||||
case "click":
|
||||
el := findVisible(page, step.Selector)
|
||||
if el == nil {
|
||||
return errkit.Newf("login step %d (click): element not found: %s", i, step.Selector)
|
||||
}
|
||||
_ = el.ScrollIntoView()
|
||||
if err := el.Click(proto.InputMouseButtonLeft, 1); err != nil {
|
||||
return errkit.Wrapf(err, "login step %d (click): failed", i)
|
||||
}
|
||||
case "waitvisible":
|
||||
if err := waitVisible(ctx, page, step.Selector, settle); err != nil {
|
||||
return errkit.Wrapf(err, "login step %d (waitvisible)", i)
|
||||
}
|
||||
case "wait":
|
||||
d := settle
|
||||
if step.Value != "" {
|
||||
if pd, perr := time.ParseDuration(step.Value); perr == nil {
|
||||
d = pd
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-time.After(d):
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
case "press":
|
||||
key, kerr := keyFromName(step.Value)
|
||||
if kerr != nil {
|
||||
return errkit.Wrapf(kerr, "login step %d (press)", i)
|
||||
}
|
||||
if step.Selector != "" {
|
||||
el := findVisible(page, byName(step.Selector), step.Selector)
|
||||
if el == nil {
|
||||
return errkit.Newf("login step %d (press): element not found: %s", i, step.Selector)
|
||||
}
|
||||
if err := el.Type(key); err != nil {
|
||||
return errkit.Wrapf(err, "login step %d (press): failed", i)
|
||||
}
|
||||
} else if err := page.Keyboard.Type(key); err != nil {
|
||||
return errkit.Wrapf(err, "login step %d (press): failed", i)
|
||||
}
|
||||
case "submit":
|
||||
if err := submitForm(page, findVisible(page, `input[type="password"]`), settle); err != nil {
|
||||
return errkit.Wrapf(err, "login step %d (submit)", i)
|
||||
}
|
||||
default:
|
||||
return errkit.Newf("login step %d: unknown action %q", i, step.Action)
|
||||
}
|
||||
// Let the page settle between steps so the next selector is present.
|
||||
_ = rod.Try(func() { page.Timeout(settle).MustWaitStable() })
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// expandCredentials substitutes the {{username}}/{{password}} placeholders in a
|
||||
// step value with the configured credentials.
|
||||
func expandCredentials(value string, cfg Config) string {
|
||||
value = strings.ReplaceAll(value, "{{username}}", cfg.Username)
|
||||
value = strings.ReplaceAll(value, "{{password}}", cfg.Password)
|
||||
return value
|
||||
}
|
||||
|
||||
// keyFromName maps a friendly key name to a go-rod input key.
|
||||
func keyFromName(name string) (input.Key, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(name)) {
|
||||
case "", "enter", "return":
|
||||
return input.Enter, nil
|
||||
case "tab":
|
||||
return input.Tab, nil
|
||||
case "escape", "esc":
|
||||
return input.Escape, nil
|
||||
case "space":
|
||||
return input.Space, nil
|
||||
default:
|
||||
return 0, errkit.Newf("unsupported key %q (supported: enter, tab, escape, space)", name)
|
||||
}
|
||||
}
|
||||
|
||||
// waitVisible polls until the selector becomes visible or the timeout elapses.
|
||||
func waitVisible(ctx context.Context, page *rod.Page, selector string, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if findVisible(page, selector) != nil {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-time.After(150 * time.Millisecond):
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
return errkit.Newf("timeout waiting for element to become visible: %s", selector)
|
||||
}
|
||||
|
||||
// locatePasswordField finds the password input, honoring an explicit override
|
||||
// (treated as a field name first, then as a raw CSS selector), then the
|
||||
// detected name, then a generic selector.
|
||||
func locatePasswordField(page *rod.Page, cfg Config, detected *LoginForm) *rod.Element {
|
||||
if cfg.PasswordField != "" {
|
||||
if el := findVisible(page, byName(cfg.PasswordField), cfg.PasswordField); el != nil {
|
||||
return el
|
||||
}
|
||||
}
|
||||
if detected != nil && detected.PasswordField != "" {
|
||||
if el := findVisible(page, byName(detected.PasswordField)); el != nil {
|
||||
return el
|
||||
}
|
||||
}
|
||||
return findVisible(page, `input[type="password"]`)
|
||||
}
|
||||
|
||||
// locateUsernameField finds the username/identifier input using the same
|
||||
// override/detected/heuristic precedence as the password field.
|
||||
func locateUsernameField(page *rod.Page, cfg Config, detected *LoginForm) *rod.Element {
|
||||
if cfg.UsernameField != "" {
|
||||
if el := findVisible(page, byName(cfg.UsernameField), cfg.UsernameField); el != nil {
|
||||
return el
|
||||
}
|
||||
}
|
||||
if detected != nil && detected.UsernameField != "" {
|
||||
if el := findVisible(page, byName(detected.UsernameField)); el != nil {
|
||||
return el
|
||||
}
|
||||
}
|
||||
return findVisible(page,
|
||||
`input[type="email"]`,
|
||||
`input[autocomplete="username"]`,
|
||||
`input[name*="user" i]`,
|
||||
`input[name*="email" i]`,
|
||||
`input[name*="login" i]`,
|
||||
`input[type="text"]`,
|
||||
)
|
||||
}
|
||||
|
||||
// byName builds a CSS attribute selector matching an input by its name.
|
||||
func byName(name string) string {
|
||||
return fmt.Sprintf(`input[name=%q]`, name)
|
||||
}
|
||||
|
||||
// findVisible returns the first visible element matching any of the selectors
|
||||
// in priority order, or nil if none match.
|
||||
func findVisible(page *rod.Page, selectors ...string) *rod.Element {
|
||||
for _, sel := range selectors {
|
||||
els, err := queryElements(page, sel)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, el := range els {
|
||||
if vis, verr := el.Visible(); verr == nil && vis {
|
||||
return el
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// queryElements resolves a selector that may be CSS (default) or XPath. XPath
|
||||
// selectors are prefixed with "xpath=" (emitted by the recording importer) or
|
||||
// written as a raw "//"/"(//" expression. This lets recorded login flows, whose
|
||||
// most stable selector is often an XPath, replay reliably.
|
||||
func queryElements(page *rod.Page, sel string) (rod.Elements, error) {
|
||||
switch {
|
||||
case strings.HasPrefix(sel, "xpath="):
|
||||
return page.ElementsX(strings.TrimPrefix(sel, "xpath="))
|
||||
case strings.HasPrefix(sel, "//"), strings.HasPrefix(sel, "(//"):
|
||||
return page.ElementsX(sel)
|
||||
default:
|
||||
return page.Elements(sel)
|
||||
}
|
||||
}
|
||||
|
||||
// typeInto focuses the element, clears any existing value and types the text so
|
||||
// that JS frameworks observing input events (e.g. React controlled inputs) pick
|
||||
// up the change.
|
||||
func typeInto(el *rod.Element, text string) error {
|
||||
_ = el.ScrollIntoView()
|
||||
if err := el.Focus(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Clear any pre-filled value by selecting all then overwriting.
|
||||
_ = el.SelectAllText()
|
||||
return el.Input(text)
|
||||
}
|
||||
|
||||
// capturePageCookies pulls cookies the browser holds for the given URLs and
|
||||
// converts them to net/http cookies, de-duplicated by name.
|
||||
func capturePageCookies(page *rod.Page, urls ...string) []*http.Cookie {
|
||||
filtered := urls[:0]
|
||||
for _, u := range urls {
|
||||
if u != "" {
|
||||
filtered = append(filtered, u)
|
||||
}
|
||||
}
|
||||
cookies, err := page.Cookies(filtered)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var out []*http.Cookie
|
||||
for _, c := range cookies {
|
||||
if c == nil || seen[c.Name] {
|
||||
continue
|
||||
}
|
||||
seen[c.Name] = true
|
||||
out = append(out, &http.Cookie{Name: c.Name, Value: c.Value, Domain: c.Domain, Path: c.Path, HttpOnly: c.HTTPOnly, Secure: c.Secure})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// readStorage returns the given web storage area (localStorage/sessionStorage)
|
||||
// as a map, or nil on failure.
|
||||
func readStorage(page *rod.Page, area string) map[string]string {
|
||||
obj, err := page.Eval(fmt.Sprintf(`() => JSON.stringify(window.%s)`, area))
|
||||
if err != nil || obj == nil {
|
||||
return nil
|
||||
}
|
||||
raw := obj.Value.Str()
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
out := map[string]string{}
|
||||
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
||||
return nil
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,478 +0,0 @@
|
||||
package autologin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-rod/rod/lib/launcher"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// requireChrome skips the test unless a system Chrome/Chromium is installed, so
|
||||
// CI without a managed-browser download does not fail or hang.
|
||||
func requireChrome(t *testing.T) {
|
||||
t.Helper()
|
||||
if _, ok := launcher.LookPath(); !ok {
|
||||
t.Skip("no system chrome/chromium found; skipping headless auto-login test")
|
||||
}
|
||||
}
|
||||
|
||||
// jsLoginApp serves a login form that is constructed entirely by JavaScript:
|
||||
// the raw HTML has no <form>, so the static HTTP engine cannot detect or submit
|
||||
// it — only a real browser that executes JS can. This is the canonical case
|
||||
// that motivates headless login.
|
||||
type jsLoginApp struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]bool
|
||||
}
|
||||
|
||||
func newJSLoginApp() *jsLoginApp { return &jsLoginApp{sessions: map[string]bool{}} }
|
||||
|
||||
func (a *jsLoginApp) server() *httptest.Server {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/login", a.handleLogin)
|
||||
mux.HandleFunc("/dashboard", a.handleDashboard)
|
||||
return httptest.NewServer(mux)
|
||||
}
|
||||
|
||||
const jsLoginPage = `<html><head><title>Login</title></head><body>
|
||||
<div id="app"></div>
|
||||
<script>
|
||||
var f = document.createElement('form');
|
||||
f.setAttribute('action', '/login');
|
||||
f.setAttribute('method', 'post');
|
||||
f.innerHTML = '<input type="email" name="email" autocomplete="username">' +
|
||||
'<input type="password" name="password">' +
|
||||
'<button type="submit">Sign in</button>';
|
||||
document.getElementById('app').appendChild(f);
|
||||
</script></body></html>`
|
||||
|
||||
func (a *jsLoginApp) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprint(w, jsLoginPage)
|
||||
return
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
if r.PostFormValue("email") != "[email protected]" || r.PostFormValue("password") != "p@ss" {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprint(w, jsLoginPage) // re-render: still a (JS) login page
|
||||
return
|
||||
}
|
||||
token := "sess-dave"
|
||||
a.mu.Lock()
|
||||
a.sessions[token] = true
|
||||
a.mu.Unlock()
|
||||
http.SetCookie(w, &http.Cookie{Name: "session", Value: token, Path: "/"})
|
||||
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
||||
}
|
||||
|
||||
func (a *jsLoginApp) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := r.Cookie("session")
|
||||
if err != nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
ok := a.sessions[c.Value]
|
||||
a.mu.Unlock()
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprint(w, "<html><body><h1>welcome dave</h1></body></html>")
|
||||
}
|
||||
|
||||
func TestLoginHeadless_JSRenderedForm(t *testing.T) {
|
||||
requireChrome(t)
|
||||
|
||||
app := newJSLoginApp()
|
||||
srv := app.server()
|
||||
defer srv.Close()
|
||||
|
||||
// Sanity: the HTTP engine cannot handle this page because the form only
|
||||
// exists after JS runs — the raw HTML has no <form>.
|
||||
_, httpErr := Login(context.Background(), nil, Config{
|
||||
LoginURL: srv.URL + "/login",
|
||||
Username: "[email protected]",
|
||||
Password: "p@ss",
|
||||
})
|
||||
require.ErrorIs(t, httpErr, ErrNoLoginForm, "HTTP engine should fail on a JS-rendered form")
|
||||
|
||||
// Headless engine renders the JS, fills and submits the form, and captures
|
||||
// the session.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
session, err := LoginHeadless(ctx, Config{
|
||||
LoginURL: srv.URL + "/login",
|
||||
Username: "[email protected]",
|
||||
Password: "p@ss",
|
||||
SettleTime: 1500 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
names := map[string]string{}
|
||||
for _, c := range session.Cookies {
|
||||
names[c.Name] = c.Value
|
||||
}
|
||||
require.Contains(t, names, "session", "headless login should capture the session cookie")
|
||||
|
||||
// The captured session must authenticate against a protected route.
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/dashboard", nil)
|
||||
req.Header.Set("Cookie", session.CookieHeader)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
}
|
||||
|
||||
// twoStepLoginPage is a username-first flow: the password field is hidden until
|
||||
// the "Next" button is clicked, mimicking Google/Microsoft-style logins.
|
||||
const twoStepLoginPage = `<html><head><title>Login</title></head><body>
|
||||
<form id="f" action="/login" method="post">
|
||||
<input type="email" name="email" id="email">
|
||||
<button type="button" id="next">Next</button>
|
||||
<div id="pwwrap" style="display:none">
|
||||
<input type="password" name="password" id="password">
|
||||
<button type="submit" id="submit">Sign in</button>
|
||||
</div>
|
||||
</form>
|
||||
<script>
|
||||
document.getElementById('next').addEventListener('click', function () {
|
||||
document.getElementById('pwwrap').style.display = 'block';
|
||||
});
|
||||
</script></body></html>`
|
||||
|
||||
func TestLoginHeadless_MultiStep(t *testing.T) {
|
||||
requireChrome(t)
|
||||
|
||||
app := newJSLoginApp()
|
||||
srv := httptest.NewServer(func() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprint(w, twoStepLoginPage)
|
||||
return
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
if r.PostFormValue("email") != "[email protected]" || r.PostFormValue("password") != "p@ss" {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprint(w, twoStepLoginPage)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: "session", Value: "sess-dave", Path: "/"})
|
||||
app.mu.Lock()
|
||||
app.sessions["sess-dave"] = true
|
||||
app.mu.Unlock()
|
||||
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
||||
})
|
||||
mux.HandleFunc("/dashboard", app.handleDashboard)
|
||||
return mux
|
||||
}())
|
||||
defer srv.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
session, err := LoginHeadless(ctx, Config{
|
||||
LoginURL: srv.URL + "/login",
|
||||
Username: "[email protected]",
|
||||
Password: "p@ss",
|
||||
SettleTime: 1 * time.Second,
|
||||
Steps: []LoginStep{
|
||||
{Action: "fill", Selector: "#email", Value: "{{username}}"},
|
||||
{Action: "click", Selector: "#next"},
|
||||
{Action: "waitvisible", Selector: "#password"},
|
||||
{Action: "fill", Selector: "#password", Value: "{{password}}"},
|
||||
{Action: "click", Selector: "#submit"},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
names := map[string]string{}
|
||||
for _, c := range session.Cookies {
|
||||
names[c.Name] = c.Value
|
||||
}
|
||||
require.Contains(t, names, "session", "multi-step login should capture the session cookie")
|
||||
}
|
||||
|
||||
// spaTokenPage is a pure-SPA login: on submit JS validates the credentials
|
||||
// client-side, stores a JWT in localStorage (no cookie at all) and swaps in a
|
||||
// logged-in view. This is the canonical "token lives in web storage" case that
|
||||
// motivates storage capture/replay.
|
||||
const spaTokenPage = `<html><head><title>SPA</title></head><body>
|
||||
<form id="f">
|
||||
<input type="email" name="email" id="email">
|
||||
<input type="password" name="password" id="password">
|
||||
<button type="submit" id="submit">Sign in</button>
|
||||
</form>
|
||||
<script>
|
||||
document.getElementById('f').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var email = document.getElementById('email').value;
|
||||
var pw = document.getElementById('password').value;
|
||||
if (email === 'dave@example.com' && pw === 'p@ss') {
|
||||
window.localStorage.setItem('auth_token', 'eyJhbGciOiJI.payload.sig');
|
||||
window.sessionStorage.setItem('csrf', 'abc123');
|
||||
document.body.innerHTML = '<h1>welcome dave</h1>';
|
||||
} else {
|
||||
document.body.innerHTML += '<p>bad creds</p>';
|
||||
}
|
||||
});
|
||||
</script></body></html>`
|
||||
|
||||
func TestLoginHeadless_LocalStorageToken(t *testing.T) {
|
||||
requireChrome(t)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprint(w, spaTokenPage)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
session, err := LoginHeadless(ctx, Config{
|
||||
LoginURL: srv.URL + "/login",
|
||||
Username: "[email protected]",
|
||||
Password: "p@ss",
|
||||
TokenRegex: `(eyJ[A-Za-z0-9._-]+)`,
|
||||
SettleTime: 1500 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, session.Cookies, "SPA login sets no cookie")
|
||||
require.Equal(t, "eyJhbGciOiJI.payload.sig", session.Token, "token must be extracted from localStorage")
|
||||
require.Equal(t, "eyJhbGciOiJI.payload.sig", session.LocalStorage["auth_token"], "localStorage must be captured")
|
||||
require.Equal(t, "abc123", session.SessionStorage["csrf"], "sessionStorage must be captured")
|
||||
}
|
||||
|
||||
func TestLoginHeadless_WrongPasswordFails(t *testing.T) {
|
||||
requireChrome(t)
|
||||
|
||||
app := newJSLoginApp()
|
||||
srv := app.server()
|
||||
defer srv.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
_, err := LoginHeadless(ctx, Config{
|
||||
LoginURL: srv.URL + "/login",
|
||||
Username: "[email protected]",
|
||||
Password: "wrong",
|
||||
SettleTime: 1500 * time.Millisecond,
|
||||
})
|
||||
require.ErrorIs(t, err, ErrLoginFailed)
|
||||
}
|
||||
|
||||
// disabledSubmitPage mimics a framework-validated form (e.g. Angular/Juice
|
||||
// Shop): the submit button starts disabled and is only enabled once both fields
|
||||
// have received input events. A naive click-immediately submitter hangs on the
|
||||
// disabled button; the engine must settle (so the field input enables the
|
||||
// button) and wait for the button to become interactable before clicking.
|
||||
const disabledSubmitPage = `<html><head><title>Login</title></head><body>
|
||||
<form id="f" action="/login" method="post">
|
||||
<input type="email" name="email" id="email">
|
||||
<input type="password" name="password" id="password">
|
||||
<button type="submit" id="submit" disabled>Sign in</button>
|
||||
</form>
|
||||
<script>
|
||||
var email = document.getElementById('email');
|
||||
var pw = document.getElementById('password');
|
||||
var btn = document.getElementById('submit');
|
||||
function check() { btn.disabled = !(email.value && pw.value); }
|
||||
email.addEventListener('input', check);
|
||||
pw.addEventListener('input', check);
|
||||
</script></body></html>`
|
||||
|
||||
func TestLoginHeadless_SubmitDisabledUntilValid(t *testing.T) {
|
||||
requireChrome(t)
|
||||
|
||||
app := newJSLoginApp()
|
||||
srv := httptest.NewServer(func() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprint(w, disabledSubmitPage)
|
||||
return
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
if r.PostFormValue("email") != "[email protected]" || r.PostFormValue("password") != "p@ss" {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprint(w, disabledSubmitPage)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: "session", Value: "sess-dave", Path: "/"})
|
||||
app.mu.Lock()
|
||||
app.sessions["sess-dave"] = true
|
||||
app.mu.Unlock()
|
||||
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
||||
})
|
||||
mux.HandleFunc("/dashboard", app.handleDashboard)
|
||||
return mux
|
||||
}())
|
||||
defer srv.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
session, err := LoginHeadless(ctx, Config{
|
||||
LoginURL: srv.URL + "/login",
|
||||
Username: "[email protected]",
|
||||
Password: "p@ss",
|
||||
SettleTime: 1500 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err, "engine must wait for the submit button to become interactable")
|
||||
|
||||
names := map[string]string{}
|
||||
for _, c := range session.Cookies {
|
||||
names[c.Name] = c.Value
|
||||
}
|
||||
require.Contains(t, names, "session", "login should succeed once the button is enabled")
|
||||
}
|
||||
|
||||
// enterSubmitPage has no clickable submit control and submits only when Enter is
|
||||
// pressed in the password field (common in minimal SPA forms). It exercises the
|
||||
// engine's Enter-to-submit fallback when no submit button is found.
|
||||
const enterSubmitPage = `<html><head><title>Login</title></head><body>
|
||||
<form id="f" action="/login" method="post">
|
||||
<input type="email" name="email" id="email">
|
||||
<input type="password" name="password" id="password">
|
||||
</form>
|
||||
<script>
|
||||
document.getElementById('password').addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Enter') { document.getElementById('f').submit(); }
|
||||
});
|
||||
</script></body></html>`
|
||||
|
||||
func TestLoginHeadless_EnterSubmitFallback(t *testing.T) {
|
||||
requireChrome(t)
|
||||
|
||||
app := newJSLoginApp()
|
||||
srv := httptest.NewServer(func() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprint(w, enterSubmitPage)
|
||||
return
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
if r.PostFormValue("email") != "[email protected]" || r.PostFormValue("password") != "p@ss" {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprint(w, enterSubmitPage)
|
||||
return
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{Name: "session", Value: "sess-dave", Path: "/"})
|
||||
app.mu.Lock()
|
||||
app.sessions["sess-dave"] = true
|
||||
app.mu.Unlock()
|
||||
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
||||
})
|
||||
mux.HandleFunc("/dashboard", app.handleDashboard)
|
||||
return mux
|
||||
}())
|
||||
defer srv.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
session, err := LoginHeadless(ctx, Config{
|
||||
LoginURL: srv.URL + "/login",
|
||||
Username: "[email protected]",
|
||||
Password: "p@ss",
|
||||
SettleTime: 1500 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err, "engine must fall back to Enter-to-submit when no submit button exists")
|
||||
|
||||
names := map[string]string{}
|
||||
for _, c := range session.Cookies {
|
||||
names[c.Name] = c.Value
|
||||
}
|
||||
require.Contains(t, names, "session", "Enter-to-submit fallback should log in")
|
||||
}
|
||||
|
||||
func TestDetectStorageJWT(t *testing.T) {
|
||||
const jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkYXZlIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
|
||||
|
||||
t.Run("preferred key wins over other JWT-shaped values", func(t *testing.T) {
|
||||
other := "eyJraWQiOiJvdGhlciJ9.eyJub3RpdCI6dHJ1ZX0.zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"
|
||||
got := detectStorageJWT(map[string]string{"random_blob": other, "token": jwt})
|
||||
require.Equal(t, jwt, got, "the value under a preferred key should be chosen")
|
||||
})
|
||||
|
||||
t.Run("extracts JWT embedded in JSON", func(t *testing.T) {
|
||||
got := detectStorageJWT(map[string]string{"auth": `{"access_token":"` + jwt + `","exp":123}`})
|
||||
require.Equal(t, jwt, got)
|
||||
})
|
||||
|
||||
t.Run("extracts JWT wrapped in quotes", func(t *testing.T) {
|
||||
got := detectStorageJWT(map[string]string{"id_token": `"` + jwt + `"`})
|
||||
require.Equal(t, jwt, got)
|
||||
})
|
||||
|
||||
t.Run("falls back to sessionStorage when localStorage has none", func(t *testing.T) {
|
||||
got := detectStorageJWT(map[string]string{"theme": "dark"}, map[string]string{"jwt": jwt})
|
||||
require.Equal(t, jwt, got)
|
||||
})
|
||||
|
||||
t.Run("returns empty when no JWT present", func(t *testing.T) {
|
||||
require.Empty(t, detectStorageJWT(map[string]string{"theme": "dark", "lang": "en"}))
|
||||
require.Empty(t, detectStorageJWT(nil, nil))
|
||||
})
|
||||
|
||||
t.Run("ignores non-JWT values that merely start with eyJ", func(t *testing.T) {
|
||||
require.Empty(t, detectStorageJWT(map[string]string{"x": "eyJustabase64ishstring"}))
|
||||
})
|
||||
}
|
||||
|
||||
// spaAutoTokenPage stores a realistic JWT in localStorage under a conventional
|
||||
// key and sets no cookie, mirroring a token-in-web-storage SPA. It is used to
|
||||
// verify that the engine surfaces the token even without an explicit TokenRegex.
|
||||
const spaAutoTokenPage = `<html><head><title>SPA</title></head><body>
|
||||
<form id="f">
|
||||
<input type="email" name="email" id="email">
|
||||
<input type="password" name="password" id="password">
|
||||
<button type="submit" id="submit">Sign in</button>
|
||||
</form>
|
||||
<script>
|
||||
document.getElementById('f').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var email = document.getElementById('email').value;
|
||||
var pw = document.getElementById('password').value;
|
||||
if (email === 'dave@example.com' && pw === 'p@ss') {
|
||||
window.localStorage.setItem('token', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkYXZlIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c');
|
||||
document.body.innerHTML = '<h1>welcome dave</h1>';
|
||||
} else {
|
||||
document.body.innerHTML += '<p>bad creds</p>';
|
||||
}
|
||||
});
|
||||
</script></body></html>`
|
||||
|
||||
func TestLoginHeadless_AutoDetectStorageJWTWithoutRegex(t *testing.T) {
|
||||
requireChrome(t)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprint(w, spaAutoTokenPage)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
session, err := LoginHeadless(ctx, Config{
|
||||
LoginURL: srv.URL + "/login",
|
||||
Username: "[email protected]",
|
||||
Password: "p@ss",
|
||||
SettleTime: 1500 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, session.Cookies, "SPA login sets no cookie")
|
||||
require.Equal(t,
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkYXZlIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
|
||||
session.Token,
|
||||
"JWT in localStorage must be auto-detected as the session token without a TokenRegex")
|
||||
}
|
||||
@@ -1,429 +0,0 @@
|
||||
package autologin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/cookiejar"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/projectdiscovery/gologger"
|
||||
"github.com/projectdiscovery/utils/errkit"
|
||||
"golang.org/x/net/proxy"
|
||||
)
|
||||
|
||||
// maxBodyRead caps how much of the login/landing page we read into memory for
|
||||
// form detection and success checks.
|
||||
const maxBodyRead = 4 << 20 // 4MB
|
||||
|
||||
var (
|
||||
// ErrLoginFailed is returned when the login flow completed but produced no
|
||||
// usable session (no cookies, no token) — typically wrong credentials.
|
||||
ErrLoginFailed = errkit.New("auto-login produced no session (check credentials)")
|
||||
)
|
||||
|
||||
// Config describes a single auto-login attempt.
|
||||
type Config struct {
|
||||
// LoginURL is the page that serves the login form (required).
|
||||
LoginURL string
|
||||
// Username / Password are the credentials to submit. Username may be empty
|
||||
// for password-only forms.
|
||||
Username string
|
||||
Password string
|
||||
|
||||
// The following are optional overrides for cases where auto-detection would
|
||||
// guess wrong.
|
||||
|
||||
// UsernameField forces the form field name that receives the username.
|
||||
UsernameField string
|
||||
// PasswordField forces the form field name that receives the password.
|
||||
PasswordField string
|
||||
// ExtraFields are additional form values submitted verbatim; they override
|
||||
// any detected field of the same name (e.g. a fixed TOTP or tenant id).
|
||||
ExtraFields map[string]string
|
||||
// TokenRegex, when set, is applied to the final response body (and, for the
|
||||
// HTTP engine, response headers) to extract a bearer/session token (first
|
||||
// capture group). This enables token-based logins that return the token in
|
||||
// the response body rather than a cookie. For headless logins it is also
|
||||
// matched against window.localStorage.
|
||||
TokenRegex string
|
||||
// Timeout bounds the whole login flow. Defaults to 30s when zero.
|
||||
Timeout time.Duration
|
||||
|
||||
// The following fields apply only to the headless engine (LoginHeadless).
|
||||
|
||||
// Headless drives a real browser (go-rod) instead of the HTTP engine,
|
||||
// enabling JS-rendered / SPA / multi-step login pages.
|
||||
Headless bool
|
||||
// ShowBrowser runs the browser headful (useful for debugging).
|
||||
ShowBrowser bool
|
||||
// UseInstalledChrome forces using a system-installed Chrome binary.
|
||||
UseInstalledChrome bool
|
||||
// Proxy routes browser traffic through the given proxy URL.
|
||||
Proxy string
|
||||
// CDPEndpoint connects to an existing browser over the Chrome DevTools
|
||||
// Protocol instead of launching one.
|
||||
CDPEndpoint string
|
||||
// UserAgent overrides the browser's User-Agent for the login navigation.
|
||||
UserAgent string
|
||||
// CustomHeaders are extra headers sent with the headless login requests
|
||||
// (e.g. global -H headers from the scan).
|
||||
CustomHeaders map[string]string
|
||||
// SettleTime is how long to wait for the post-submit navigation / SPA to
|
||||
// settle before capturing the session. Defaults to 5s.
|
||||
SettleTime time.Duration
|
||||
// Steps, when non-empty, drives an explicit multi-step headless login
|
||||
// (e.g. username-first / SSO consent flows) instead of the single-shot
|
||||
// auto-detect-and-submit path. Ignored by the HTTP engine.
|
||||
Steps []LoginStep
|
||||
}
|
||||
|
||||
// LoginStep is a single action in an explicit multi-step headless login flow.
|
||||
// In Value, the placeholders {{username}} and {{password}} are substituted with
|
||||
// the configured credentials so secrets stay out of the step list.
|
||||
type LoginStep struct {
|
||||
// Action is one of: navigate, fill, click, waitvisible, wait, press, submit.
|
||||
Action string `json:"action" yaml:"action"`
|
||||
// Selector is a CSS selector (or, for fill/press, an input name) the action
|
||||
// targets. Unused by navigate/wait.
|
||||
Selector string `json:"selector" yaml:"selector"`
|
||||
// Value is the action argument: a URL (navigate), text to type (fill), a key
|
||||
// name such as "enter"/"tab" (press) or a duration like "2s" (wait).
|
||||
Value string `json:"value" yaml:"value"`
|
||||
}
|
||||
|
||||
// Session is the captured result of a successful auto-login.
|
||||
type Session struct {
|
||||
// Cookies are the session cookies captured after login (name/value only).
|
||||
Cookies []*http.Cookie
|
||||
// CookieHeader is the cookies rendered as a single "k=v; k2=v2" header value.
|
||||
CookieHeader string
|
||||
// Token is a token extracted via Config.TokenRegex, if any.
|
||||
Token string
|
||||
// LocalStorage / SessionStorage hold the browser storage captured after a
|
||||
// headless login (always empty for the HTTP engine). They let token-based
|
||||
// SPAs surface their session even when it lives in web storage.
|
||||
LocalStorage map[string]string
|
||||
SessionStorage map[string]string
|
||||
// FinalURL / FinalStatus describe where the login flow landed.
|
||||
FinalURL string
|
||||
FinalStatus int
|
||||
}
|
||||
|
||||
// Login performs a form-based auto-login: it fetches the login page, detects the
|
||||
// login form, submits the supplied credentials (carrying any hidden CSRF tokens
|
||||
// and the cookie jar), follows redirects and captures the resulting session.
|
||||
//
|
||||
// base is an optional template client whose Transport is reused (for proxy/TLS
|
||||
// settings); its cookie jar and redirect policy are intentionally NOT reused so
|
||||
// the login runs with a clean, dedicated jar. Pass nil to use defaults.
|
||||
func Login(ctx context.Context, base *http.Client, cfg Config) (*Session, error) {
|
||||
if strings.TrimSpace(cfg.LoginURL) == "" {
|
||||
return nil, errkit.New("auto-login: login-url is required")
|
||||
}
|
||||
loginURL, err := url.Parse(cfg.LoginURL)
|
||||
if err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login: invalid login-url")
|
||||
}
|
||||
|
||||
var tokenRe *regexp.Regexp
|
||||
if cfg.TokenRegex != "" {
|
||||
tokenRe, err = regexp.Compile(cfg.TokenRegex)
|
||||
if err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login: invalid token-regex")
|
||||
}
|
||||
}
|
||||
|
||||
jar, err := cookiejar.New(nil)
|
||||
if err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login: failed to create cookie jar")
|
||||
}
|
||||
timeout := cfg.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = 30 * time.Second
|
||||
}
|
||||
client := &http.Client{Jar: jar, Timeout: timeout}
|
||||
switch {
|
||||
case base != nil && base.Transport != nil:
|
||||
// Reuse the template client's transport (proxy/TLS) when provided.
|
||||
client.Transport = base.Transport
|
||||
case cfg.Proxy != "":
|
||||
// Otherwise honor an explicitly configured proxy so a proxied scan does
|
||||
// not bypass the proxy during HTTP auto-login.
|
||||
tr, perr := proxyTransport(cfg.Proxy)
|
||||
if perr != nil {
|
||||
return nil, perr
|
||||
}
|
||||
client.Transport = tr
|
||||
}
|
||||
|
||||
// 1. Fetch the login page (this also seeds the jar with any pre-login CSRF
|
||||
// cookie the server sets).
|
||||
pageBody, err := doRequest(ctx, client, http.MethodGet, loginURL.String(), "", "")
|
||||
if err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login: failed to fetch login page")
|
||||
}
|
||||
|
||||
// 2. Detect the login form.
|
||||
form, err := DetectLoginForm(pageBody, loginURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 3. Build the submission values: detected hidden/default fields first, then
|
||||
// the credentials, then caller overrides.
|
||||
values := url.Values{}
|
||||
for k, v := range form.Fields {
|
||||
values.Set(k, v)
|
||||
}
|
||||
userField := form.UsernameField
|
||||
if cfg.UsernameField != "" {
|
||||
userField = cfg.UsernameField
|
||||
}
|
||||
passField := form.PasswordField
|
||||
if cfg.PasswordField != "" {
|
||||
passField = cfg.PasswordField
|
||||
}
|
||||
if userField != "" {
|
||||
values.Set(userField, cfg.Username)
|
||||
}
|
||||
values.Set(passField, cfg.Password)
|
||||
for k, v := range cfg.ExtraFields {
|
||||
values.Set(k, v)
|
||||
}
|
||||
|
||||
// 4. Submit the form.
|
||||
var finalBody, finalURLStr string
|
||||
var finalStatus int
|
||||
var finalHeaders http.Header
|
||||
if form.Method == http.MethodGet {
|
||||
// A GET login places the password in the query string, where it can leak
|
||||
// into server/proxy logs and browser history. We still honor it (some
|
||||
// apps genuinely use GET), but warn so the operator is aware.
|
||||
if cfg.Password != "" {
|
||||
gologger.Warning().Msgf("auto-login: login form at %s uses GET; credentials will be sent in the URL query string", form.Action)
|
||||
}
|
||||
actionURL, perr := url.Parse(form.Action)
|
||||
if perr != nil {
|
||||
return nil, errkit.Wrap(perr, "auto-login: invalid form action")
|
||||
}
|
||||
q := actionURL.Query()
|
||||
for k, vs := range values {
|
||||
for _, v := range vs {
|
||||
q.Set(k, v)
|
||||
}
|
||||
}
|
||||
actionURL.RawQuery = q.Encode()
|
||||
finalBody, finalURLStr, finalStatus, finalHeaders, err = doRequestFull(ctx, client, http.MethodGet, actionURL.String(), "", "")
|
||||
} else {
|
||||
finalBody, finalURLStr, finalStatus, finalHeaders, err = doRequestFull(ctx, client, form.Method, form.Action, "application/x-www-form-urlencoded", values.Encode())
|
||||
}
|
||||
if err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login: failed to submit login form")
|
||||
}
|
||||
|
||||
// 5. Capture the session from the jar across the login/action/final hosts.
|
||||
session := &Session{FinalURL: finalURLStr, FinalStatus: finalStatus}
|
||||
session.Cookies = collectCookies(jar, loginURL, form.Action, finalURLStr)
|
||||
session.CookieHeader = renderCookieHeader(session.Cookies)
|
||||
|
||||
// Token extraction: the configured regex against the body first, then the
|
||||
// response headers (regex + well-known auth headers). Many token-based APIs
|
||||
// return the session token in a header (Authorization, X-Auth-Token, ...)
|
||||
// rather than the body or a cookie.
|
||||
if tokenRe != nil {
|
||||
if m := tokenRe.FindStringSubmatch(finalBody); len(m) > 1 {
|
||||
session.Token = m[1]
|
||||
}
|
||||
}
|
||||
if session.Token == "" {
|
||||
session.Token = detectHeaderToken(finalHeaders, tokenRe)
|
||||
}
|
||||
|
||||
// 6. Decide success.
|
||||
//
|
||||
// "Captured a cookie" is NOT a sufficient signal: servers commonly set a
|
||||
// pre-login CSRF cookie on the GET that survives a failed POST. The strongest
|
||||
// generic failure signal is being re-prompted — i.e. the page we land on
|
||||
// after submitting still presents a login form (a password field). When that
|
||||
// happens and we did not extract a token, the login failed (e.g. wrong
|
||||
// credentials). NOTE: this can misfire on apps that render a login form on
|
||||
// every page even when authenticated; such cases can use TokenRegex or field
|
||||
// overrides to disambiguate.
|
||||
if session.Token == "" {
|
||||
var finalURLParsed *url.URL
|
||||
if u, perr := url.Parse(finalURLStr); perr == nil {
|
||||
finalURLParsed = u
|
||||
}
|
||||
if _, derr := DetectLoginForm(finalBody, finalURLParsed); derr == nil {
|
||||
return nil, errkit.Wrapf(ErrLoginFailed, "still presented a login form (final status %d at %s)", finalStatus, finalURLStr)
|
||||
}
|
||||
}
|
||||
|
||||
// A session must ultimately carry at least one cookie or an extracted token.
|
||||
if len(session.Cookies) == 0 && session.Token == "" {
|
||||
return nil, errkit.Wrapf(ErrLoginFailed, "final status %d at %s", finalStatus, finalURLStr)
|
||||
}
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// proxyTransport builds an http.Transport that routes through the given proxy.
|
||||
// It supports http/https proxies (via CONNECT) and socks5/socks5h proxies.
|
||||
func proxyTransport(rawProxy string) (*http.Transport, error) {
|
||||
u, err := url.Parse(rawProxy)
|
||||
if err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login: invalid proxy url")
|
||||
}
|
||||
switch strings.ToLower(u.Scheme) {
|
||||
case "http", "https":
|
||||
return &http.Transport{Proxy: http.ProxyURL(u)}, nil
|
||||
case "socks5", "socks5h":
|
||||
var auth *proxy.Auth
|
||||
if u.User != nil {
|
||||
password, _ := u.User.Password()
|
||||
auth = &proxy.Auth{User: u.User.Username(), Password: password}
|
||||
}
|
||||
dialer, derr := proxy.SOCKS5("tcp", u.Host, auth, proxy.Direct)
|
||||
if derr != nil {
|
||||
return nil, errkit.Wrap(derr, "auto-login: invalid socks5 proxy")
|
||||
}
|
||||
tr := &http.Transport{}
|
||||
if ctxDialer, ok := dialer.(proxy.ContextDialer); ok {
|
||||
tr.DialContext = ctxDialer.DialContext
|
||||
} else {
|
||||
tr.DialContext = func(_ context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialer.Dial(network, addr)
|
||||
}
|
||||
}
|
||||
return tr, nil
|
||||
default:
|
||||
return nil, errkit.Newf("auto-login: unsupported proxy scheme %q", u.Scheme)
|
||||
}
|
||||
}
|
||||
|
||||
// collectCookies gathers unique cookies the jar holds for any of the given URLs.
|
||||
func collectCookies(jar http.CookieJar, loginURL *url.URL, action, final string) []*http.Cookie {
|
||||
urls := []*url.URL{loginURL}
|
||||
if u, err := url.Parse(action); err == nil {
|
||||
urls = append(urls, u)
|
||||
}
|
||||
if u, err := url.Parse(final); err == nil {
|
||||
urls = append(urls, u)
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
var out []*http.Cookie
|
||||
for _, u := range urls {
|
||||
if u == nil {
|
||||
continue
|
||||
}
|
||||
for _, c := range jar.Cookies(u) {
|
||||
if seen[c.Name] {
|
||||
continue
|
||||
}
|
||||
seen[c.Name] = true
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renderCookieHeader renders cookies as a single Cookie header value.
|
||||
func renderCookieHeader(cookies []*http.Cookie) string {
|
||||
parts := make([]string, 0, len(cookies))
|
||||
for _, c := range cookies {
|
||||
parts = append(parts, c.Name+"="+c.Value)
|
||||
}
|
||||
return strings.Join(parts, "; ")
|
||||
}
|
||||
|
||||
// doRequest issues a request and returns only the body (used for the GET page).
|
||||
func doRequest(ctx context.Context, client *http.Client, method, rawURL, contentType, body string) (string, error) {
|
||||
b, _, _, _, err := doRequestFull(ctx, client, method, rawURL, contentType, body)
|
||||
return b, err
|
||||
}
|
||||
|
||||
// doRequestFull issues a request and returns the (capped) body, the final URL
|
||||
// after redirects, the final status code and the final response headers.
|
||||
func doRequestFull(ctx context.Context, client *http.Client, method, rawURL, contentType, body string) (string, string, int, http.Header, error) {
|
||||
var reader io.Reader
|
||||
if body != "" {
|
||||
reader = strings.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, rawURL, reader)
|
||||
if err != nil {
|
||||
return "", "", 0, nil, err
|
||||
}
|
||||
if contentType != "" {
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", "", 0, nil, err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyRead))
|
||||
if err != nil {
|
||||
return "", "", 0, nil, err
|
||||
}
|
||||
finalURL := rawURL
|
||||
if resp.Request != nil && resp.Request.URL != nil {
|
||||
finalURL = resp.Request.URL.String()
|
||||
}
|
||||
return string(data), finalURL, resp.StatusCode, resp.Header, nil
|
||||
}
|
||||
|
||||
// tokenHeaderNames are response headers commonly used to deliver a session /
|
||||
// bearer token, checked in priority order.
|
||||
var tokenHeaderNames = []string{
|
||||
"Authorization",
|
||||
"X-Auth-Token",
|
||||
"X-Access-Token",
|
||||
"Access-Token",
|
||||
"X-Subject-Token",
|
||||
"X-Session-Token",
|
||||
"X-Api-Token",
|
||||
}
|
||||
|
||||
// detectHeaderToken extracts a session/bearer token from response headers. A
|
||||
// configured token-regex is tried first (so a custom header can be targeted),
|
||||
// then the well-known token headers in priority order. A leading "Bearer "
|
||||
// scheme is stripped from the value.
|
||||
func detectHeaderToken(h http.Header, tokenRe *regexp.Regexp) string {
|
||||
if len(h) == 0 {
|
||||
return ""
|
||||
}
|
||||
if tokenRe != nil {
|
||||
names := make([]string, 0, len(h))
|
||||
for name := range h {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
for _, v := range h[name] {
|
||||
if m := tokenRe.FindStringSubmatch(v); len(m) > 1 {
|
||||
return m[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, name := range tokenHeaderNames {
|
||||
v := strings.TrimSpace(h.Get(name))
|
||||
if v == "" {
|
||||
continue
|
||||
}
|
||||
// Strip a leading auth scheme (e.g. "Bearer <token>").
|
||||
if i := strings.IndexByte(v, ' '); i > 0 && strings.EqualFold(v[:i], "bearer") {
|
||||
v = strings.TrimSpace(v[i+1:])
|
||||
}
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -1,373 +0,0 @@
|
||||
package autologin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"regexp"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// loginApp is a hermetic web app with a realistic, CSRF-protected, cookie-based
|
||||
// login flow used to exercise the auto-login engine end to end:
|
||||
// - GET /login sets a per-visitor csrf cookie and renders a form echoing it in
|
||||
// a hidden input.
|
||||
// - POST /login validates the csrf (hidden field must equal the cookie),
|
||||
// checks the credentials, and on success sets a `session` cookie and 302s to
|
||||
// /dashboard.
|
||||
// - GET /dashboard returns 200 only when a valid session cookie is presented.
|
||||
type loginApp struct {
|
||||
mu sync.Mutex
|
||||
sessions map[string]bool // valid session tokens
|
||||
user string
|
||||
pass string
|
||||
}
|
||||
|
||||
func newLoginApp(user, pass string) *loginApp {
|
||||
return &loginApp{sessions: map[string]bool{}, user: user, pass: pass}
|
||||
}
|
||||
|
||||
func (a *loginApp) server() *httptest.Server {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/login", a.handleLogin)
|
||||
mux.HandleFunc("/dashboard", a.handleDashboard)
|
||||
return httptest.NewServer(mux)
|
||||
}
|
||||
|
||||
func (a *loginApp) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
// Seed a CSRF cookie and echo it into the form.
|
||||
csrf := "csrf-" + fmt.Sprint(len(a.sessions)) + "-tok"
|
||||
http.SetCookie(w, &http.Cookie{Name: "csrftoken", Value: csrf, Path: "/"})
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprintf(w, `<html><body>
|
||||
<form action="/login" method="post">
|
||||
<input type="hidden" name="csrf" value="%s">
|
||||
<input type="text" name="email" autocomplete="username">
|
||||
<input type="password" name="password">
|
||||
<input type="submit" name="commit" value="Sign in">
|
||||
</form>
|
||||
</body></html>`, csrf)
|
||||
return
|
||||
}
|
||||
|
||||
// POST: validate CSRF against the cookie, then credentials.
|
||||
_ = r.ParseForm()
|
||||
csrfCookie, err := r.Cookie("csrftoken")
|
||||
if err != nil || r.PostFormValue("csrf") != csrfCookie.Value {
|
||||
http.Error(w, "csrf mismatch", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
if r.PostFormValue("email") != a.user || r.PostFormValue("password") != a.pass {
|
||||
// Re-render the login form on failure (no session set).
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = fmt.Fprint(w, `<html><body><p>Invalid credentials</p>
|
||||
<form action="/login" method="post">
|
||||
<input type="text" name="email">
|
||||
<input type="password" name="password">
|
||||
</form></body></html>`)
|
||||
return
|
||||
}
|
||||
token := "sess-" + r.PostFormValue("email")
|
||||
a.mu.Lock()
|
||||
a.sessions[token] = true
|
||||
a.mu.Unlock()
|
||||
http.SetCookie(w, &http.Cookie{Name: "session", Value: token, Path: "/"})
|
||||
http.Redirect(w, r, "/dashboard", http.StatusFound)
|
||||
}
|
||||
|
||||
func (a *loginApp) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := r.Cookie("session")
|
||||
if err != nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
a.mu.Lock()
|
||||
ok := a.sessions[c.Value]
|
||||
a.mu.Unlock()
|
||||
if !ok {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprint(w, "welcome to your dashboard")
|
||||
}
|
||||
|
||||
func TestLogin_CSRFFormAndSessionCookie(t *testing.T) {
|
||||
app := newLoginApp("[email protected]", "s3cr3t")
|
||||
srv := app.server()
|
||||
defer srv.Close()
|
||||
|
||||
session, err := Login(context.Background(), nil, Config{
|
||||
LoginURL: srv.URL + "/login",
|
||||
Username: "[email protected]",
|
||||
Password: "s3cr3t",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, session.Cookies, "should capture session cookie")
|
||||
|
||||
// The captured session must actually authenticate against a protected route.
|
||||
names := map[string]string{}
|
||||
for _, c := range session.Cookies {
|
||||
names[c.Name] = c.Value
|
||||
}
|
||||
require.Contains(t, names, "session", "session cookie must be captured")
|
||||
|
||||
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/dashboard", nil)
|
||||
req.Header.Set("Cookie", session.CookieHeader)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, "captured session should grant access to /dashboard")
|
||||
}
|
||||
|
||||
func TestLogin_WrongPasswordFails(t *testing.T) {
|
||||
app := newLoginApp("[email protected]", "s3cr3t")
|
||||
srv := app.server()
|
||||
defer srv.Close()
|
||||
|
||||
_, err := Login(context.Background(), nil, Config{
|
||||
LoginURL: srv.URL + "/login",
|
||||
Username: "[email protected]",
|
||||
Password: "wrong-password",
|
||||
})
|
||||
require.ErrorIs(t, err, ErrLoginFailed)
|
||||
}
|
||||
|
||||
func TestLogin_FieldOverride(t *testing.T) {
|
||||
// Force the username field name via override; detection would pick "email"
|
||||
// anyway, but this verifies the override path submits correctly.
|
||||
app := newLoginApp("[email protected]", "hunter2")
|
||||
srv := app.server()
|
||||
defer srv.Close()
|
||||
|
||||
session, err := Login(context.Background(), nil, Config{
|
||||
LoginURL: srv.URL + "/login",
|
||||
Username: "[email protected]",
|
||||
Password: "hunter2",
|
||||
UsernameField: "email",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, session.Cookies)
|
||||
}
|
||||
|
||||
// tokenApp returns a JSON token in the login response body instead of setting a
|
||||
// cookie, to exercise TokenRegex-based capture.
|
||||
func tokenApp() *httptest.Server {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprint(w, `<form action="/api/login" method="post">
|
||||
<input type="text" name="user">
|
||||
<input type="password" name="password">
|
||||
</form>`)
|
||||
return
|
||||
}
|
||||
})
|
||||
mux.HandleFunc("/api/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r.ParseForm()
|
||||
if r.PostFormValue("user") == "carol" && r.PostFormValue("password") == "pw" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = fmt.Fprint(w, `{"access_token":"eyJhbGciOiJI.payload.sig","expires_in":3600}`)
|
||||
return
|
||||
}
|
||||
http.Error(w, "bad creds", http.StatusUnauthorized)
|
||||
})
|
||||
return httptest.NewServer(mux)
|
||||
}
|
||||
|
||||
func TestLogin_TokenExtraction(t *testing.T) {
|
||||
srv := tokenApp()
|
||||
defer srv.Close()
|
||||
|
||||
session, err := Login(context.Background(), nil, Config{
|
||||
LoginURL: srv.URL + "/login",
|
||||
Username: "carol",
|
||||
Password: "pw",
|
||||
TokenRegex: `"access_token":"([^"]+)"`,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "eyJhbGciOiJI.payload.sig", session.Token)
|
||||
}
|
||||
|
||||
// getFormApp serves a GET-method login form (credentials travel in the query
|
||||
// string). It exercises the GET submission branch of Login.
|
||||
func getFormApp(user, pass string) *httptest.Server {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
// A submit is any GET carrying the password param.
|
||||
if q.Get("password") != "" {
|
||||
if q.Get("user") == user && q.Get("password") == pass && q.Get("realm") == "corp" {
|
||||
http.SetCookie(w, &http.Cookie{Name: "session", Value: "ok", Path: "/"})
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprint(w, `<html><body>welcome, no form here</body></html>`)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprint(w, `<html><body><form method="get" action="/login">
|
||||
<input type="text" name="user"><input type="password" name="password"></form></body></html>`)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprint(w, `<html><body><form method="get" action="/login">
|
||||
<input type="hidden" name="realm" value="corp">
|
||||
<input type="text" name="user" autocomplete="username">
|
||||
<input type="password" name="password">
|
||||
</form></body></html>`)
|
||||
})
|
||||
return httptest.NewServer(mux)
|
||||
}
|
||||
|
||||
func TestLogin_GetMethodForm(t *testing.T) {
|
||||
srv := getFormApp("dave", "pw123")
|
||||
defer srv.Close()
|
||||
|
||||
session, err := Login(context.Background(), nil, Config{
|
||||
LoginURL: srv.URL + "/login",
|
||||
Username: "dave",
|
||||
Password: "pw123",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, session.Cookies, "GET-form login should still capture the session cookie")
|
||||
// The hidden 'realm' default must have been carried into the query string.
|
||||
require.Contains(t, session.FinalURL, "realm=corp")
|
||||
}
|
||||
|
||||
// extraFieldApp requires an additional non-form field (e.g. a tenant id) that is
|
||||
// not present in the markup, so it must be supplied via Config.ExtraFields.
|
||||
func extraFieldApp(user, pass string) *httptest.Server {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprint(w, `<form action="/login" method="post">
|
||||
<input type="text" name="user"><input type="password" name="password"></form>`)
|
||||
return
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
if r.PostFormValue("user") == user && r.PostFormValue("password") == pass && r.PostFormValue("tenant") == "acme" {
|
||||
http.SetCookie(w, &http.Cookie{Name: "session", Value: "ok", Path: "/"})
|
||||
_, _ = fmt.Fprint(w, "<html><body>signed in</body></html>")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprint(w, `<form action="/login" method="post"><input type="password" name="password"></form>`)
|
||||
})
|
||||
return httptest.NewServer(mux)
|
||||
}
|
||||
|
||||
func TestLogin_ExtraFields(t *testing.T) {
|
||||
srv := extraFieldApp("erin", "pw")
|
||||
defer srv.Close()
|
||||
|
||||
// Without the extra field the login must fail (re-prompted).
|
||||
_, err := Login(context.Background(), nil, Config{
|
||||
LoginURL: srv.URL + "/login", Username: "erin", Password: "pw",
|
||||
})
|
||||
require.ErrorIs(t, err, ErrLoginFailed)
|
||||
|
||||
// Supplying the tenant via ExtraFields must succeed.
|
||||
session, err := Login(context.Background(), nil, Config{
|
||||
LoginURL: srv.URL + "/login",
|
||||
Username: "erin",
|
||||
Password: "pw",
|
||||
ExtraFields: map[string]string{"tenant": "acme"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, session.Cookies)
|
||||
}
|
||||
|
||||
func TestProxyTransport(t *testing.T) {
|
||||
tr, err := proxyTransport("http://127.0.0.1:8080")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, tr.Proxy, "http proxy must set Transport.Proxy")
|
||||
|
||||
tr, err = proxyTransport("socks5://127.0.0.1:1080")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, tr.DialContext, "socks5 proxy must set Transport.DialContext")
|
||||
|
||||
_, err = proxyTransport("ftp://127.0.0.1:21")
|
||||
require.Error(t, err, "unsupported proxy scheme must error")
|
||||
}
|
||||
|
||||
// TestLogin_HTTPProxy proves the HTTP engine actually routes through cfg.Proxy:
|
||||
// the login target host is unresolvable, so the flow can only succeed if every
|
||||
// request is sent to the proxy (which serves the login app by path).
|
||||
func TestLogin_HTTPProxy(t *testing.T) {
|
||||
var proxyHits int32
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&proxyHits, 1)
|
||||
if r.Method == http.MethodGet {
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = fmt.Fprint(w, `<form action="/login" method="post">
|
||||
<input type="text" name="user"><input type="password" name="password"></form>`)
|
||||
return
|
||||
}
|
||||
_ = r.ParseForm()
|
||||
if r.PostFormValue("user") == "frank" && r.PostFormValue("password") == "pw" {
|
||||
http.SetCookie(w, &http.Cookie{Name: "session", Value: "ok", Path: "/"})
|
||||
_, _ = fmt.Fprint(w, "<html><body>signed in</body></html>")
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprint(w, `<form action="/login" method="post"><input type="password" name="password"></form>`)
|
||||
})
|
||||
proxySrv := httptest.NewServer(mux)
|
||||
defer proxySrv.Close()
|
||||
|
||||
session, err := Login(context.Background(), nil, Config{
|
||||
// Unresolvable host: only reachable if the proxy is honored.
|
||||
LoginURL: "http://login.invalid.test/login",
|
||||
Username: "frank",
|
||||
Password: "pw",
|
||||
Proxy: proxySrv.URL,
|
||||
})
|
||||
require.NoError(t, err, "login should succeed via the proxy")
|
||||
require.NotEmpty(t, session.Cookies)
|
||||
require.GreaterOrEqual(t, atomic.LoadInt32(&proxyHits), int32(2), "both GET and POST must traverse the proxy")
|
||||
}
|
||||
|
||||
func TestLogin_NoFormIsError(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = fmt.Fprint(w, "<html><body>no form here</body></html>")
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := Login(context.Background(), nil, Config{
|
||||
LoginURL: srv.URL + "/login",
|
||||
Username: "x",
|
||||
Password: "y",
|
||||
})
|
||||
require.ErrorIs(t, err, ErrNoLoginForm)
|
||||
}
|
||||
|
||||
func TestDetectHeaderToken(t *testing.T) {
|
||||
t.Run("strips bearer scheme from Authorization", func(t *testing.T) {
|
||||
h := http.Header{"Authorization": {"Bearer abc.def.ghi"}}
|
||||
require.Equal(t, "abc.def.ghi", detectHeaderToken(h, nil))
|
||||
})
|
||||
t.Run("reads well-known token header", func(t *testing.T) {
|
||||
h := http.Header{"X-Auth-Token": {"tok-123"}}
|
||||
require.Equal(t, "tok-123", detectHeaderToken(h, nil))
|
||||
})
|
||||
t.Run("priority order prefers Authorization over X-Auth-Token", func(t *testing.T) {
|
||||
h := http.Header{"Authorization": {"Bearer first"}, "X-Auth-Token": {"second"}}
|
||||
require.Equal(t, "first", detectHeaderToken(h, nil))
|
||||
})
|
||||
t.Run("token-regex targets a custom header", func(t *testing.T) {
|
||||
h := http.Header{"X-Custom": {"session=zzz999;"}}
|
||||
re := regexp.MustCompile(`session=([^;]+)`)
|
||||
require.Equal(t, "zzz999", detectHeaderToken(h, re))
|
||||
})
|
||||
t.Run("no token headers yields empty", func(t *testing.T) {
|
||||
require.Empty(t, detectHeaderToken(http.Header{"Content-Type": {"text/html"}}, nil))
|
||||
require.Empty(t, detectHeaderToken(nil, nil))
|
||||
})
|
||||
}
|
||||
@@ -1,401 +0,0 @@
|
||||
package autologin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/internal/fuzzplayground"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// playgroundServer starts the in-repo auth playground and returns its base URL.
|
||||
func playgroundServer(t *testing.T) string {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(fuzzplayground.GetPlaygroundServer())
|
||||
t.Cleanup(srv.Close)
|
||||
return srv.URL
|
||||
}
|
||||
|
||||
// assertSessionAuthenticates verifies a captured session actually authenticates
|
||||
// against the playground's protected /auth/whoami endpoint, trying both the
|
||||
// cookie and bearer-token representations.
|
||||
func assertSessionAuthenticates(t *testing.T, base string, s *Session) {
|
||||
t.Helper()
|
||||
apply := func(req *http.Request) {
|
||||
if s.CookieHeader != "" {
|
||||
req.Header.Set("Cookie", s.CookieHeader)
|
||||
}
|
||||
if s.Token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+s.Token)
|
||||
}
|
||||
}
|
||||
req, _ := http.NewRequest(http.MethodGet, base+"/auth/whoami", nil)
|
||||
apply(req)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, "whoami should accept the captured session; body=%s", body)
|
||||
require.Contains(t, string(body), fuzzplayground.AuthUsername, "whoami should identify the logged-in user")
|
||||
}
|
||||
|
||||
func cookieNames(s *Session) map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, c := range s.Cookies {
|
||||
out[c.Name] = c.Value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- HTTP engine styles ----------------------------------------------------
|
||||
|
||||
func TestPlayground_HTTPFormLogin(t *testing.T) {
|
||||
base := playgroundServer(t)
|
||||
session, err := Login(context.Background(), nil, Config{
|
||||
LoginURL: base + "/auth/form-login",
|
||||
Username: fuzzplayground.AuthUsername,
|
||||
Password: fuzzplayground.AuthPassword,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, cookieNames(session), "PSESSION", "form login should set a session cookie")
|
||||
assertSessionAuthenticates(t, base, session)
|
||||
}
|
||||
|
||||
func TestPlayground_HTTPCSRFLogin(t *testing.T) {
|
||||
base := playgroundServer(t)
|
||||
session, err := Login(context.Background(), nil, Config{
|
||||
LoginURL: base + "/auth/csrf-login",
|
||||
Username: fuzzplayground.AuthUsername,
|
||||
Password: fuzzplayground.AuthPassword,
|
||||
})
|
||||
require.NoError(t, err, "HTTP engine must capture and replay the hidden CSRF token")
|
||||
require.Contains(t, cookieNames(session), "PSESSION")
|
||||
assertSessionAuthenticates(t, base, session)
|
||||
}
|
||||
|
||||
func TestPlayground_HTTPWrongPasswordFails(t *testing.T) {
|
||||
base := playgroundServer(t)
|
||||
_, err := Login(context.Background(), nil, Config{
|
||||
LoginURL: base + "/auth/form-login",
|
||||
Username: fuzzplayground.AuthUsername,
|
||||
Password: "wrong",
|
||||
})
|
||||
require.ErrorIs(t, err, ErrLoginFailed)
|
||||
}
|
||||
|
||||
// TestPlayground_HTTPHeaderTokenLogin proves the HTTP engine extracts a session
|
||||
// token delivered in a response header (no cookie, no body token).
|
||||
func TestPlayground_HTTPHeaderTokenLogin(t *testing.T) {
|
||||
base := playgroundServer(t)
|
||||
session, err := Login(context.Background(), nil, Config{
|
||||
LoginURL: base + "/auth/header-token-login",
|
||||
Username: fuzzplayground.AuthUsername,
|
||||
Password: fuzzplayground.AuthPassword,
|
||||
})
|
||||
require.NoError(t, err, "the engine must extract the token from the response header")
|
||||
require.Empty(t, session.Cookies, "header-token login sets no cookie")
|
||||
require.NotEmpty(t, session.Token, "token must be read from the X-Auth-Token header")
|
||||
assertSessionAuthenticates(t, base, session)
|
||||
}
|
||||
|
||||
// --- Headless styles -------------------------------------------------------
|
||||
|
||||
func TestPlayground_HeadlessSPALogin(t *testing.T) {
|
||||
requireChrome(t)
|
||||
base := playgroundServer(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// The static HTTP engine cannot log in here (the form is built by JS).
|
||||
_, httpErr := Login(context.Background(), nil, Config{
|
||||
LoginURL: base + "/auth/spa-login",
|
||||
Username: fuzzplayground.AuthUsername,
|
||||
Password: fuzzplayground.AuthPassword,
|
||||
})
|
||||
require.ErrorIs(t, httpErr, ErrNoLoginForm)
|
||||
|
||||
session, err := LoginHeadless(ctx, Config{
|
||||
LoginURL: base + "/auth/spa-login",
|
||||
Username: fuzzplayground.AuthUsername,
|
||||
Password: fuzzplayground.AuthPassword,
|
||||
SettleTime: 1500 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, cookieNames(session), "PSESSION")
|
||||
assertSessionAuthenticates(t, base, session)
|
||||
}
|
||||
|
||||
func TestPlayground_HeadlessMultiStep(t *testing.T) {
|
||||
requireChrome(t)
|
||||
base := playgroundServer(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
session, err := LoginHeadless(ctx, Config{
|
||||
LoginURL: base + "/auth/multistep-login",
|
||||
Username: fuzzplayground.AuthUsername,
|
||||
Password: fuzzplayground.AuthPassword,
|
||||
SettleTime: 1 * time.Second,
|
||||
Steps: []LoginStep{
|
||||
{Action: "fill", Selector: "#username", Value: "{{username}}"},
|
||||
{Action: "click", Selector: "#next"},
|
||||
{Action: "waitvisible", Selector: "#password"},
|
||||
{Action: "fill", Selector: "#password", Value: "{{password}}"},
|
||||
{Action: "click", Selector: "#submit"},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, cookieNames(session), "PSESSION")
|
||||
assertSessionAuthenticates(t, base, session)
|
||||
}
|
||||
|
||||
// TestPlayground_HeadlessStrictSubmit is the regression for the disabled-until-
|
||||
// valid submit button: the engine must settle (so JS enables the button) and/or
|
||||
// fall back to Enter rather than hang on the disabled control.
|
||||
func TestPlayground_HeadlessStrictSubmit(t *testing.T) {
|
||||
requireChrome(t)
|
||||
base := playgroundServer(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
session, err := LoginHeadless(ctx, Config{
|
||||
LoginURL: base + "/auth/strict-login",
|
||||
Username: fuzzplayground.AuthUsername,
|
||||
Password: fuzzplayground.AuthPassword,
|
||||
SettleTime: 1500 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, cookieNames(session), "PSESSION")
|
||||
assertSessionAuthenticates(t, base, session)
|
||||
}
|
||||
|
||||
func TestPlayground_HeadlessSPAToken(t *testing.T) {
|
||||
requireChrome(t)
|
||||
base := playgroundServer(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
session, err := LoginHeadless(ctx, Config{
|
||||
LoginURL: base + "/auth/spa-token-login",
|
||||
Username: fuzzplayground.AuthUsername,
|
||||
Password: fuzzplayground.AuthPassword,
|
||||
TokenRegex: `(eyJ[A-Za-z0-9._-]+\.[A-Za-z0-9._-]+\.[A-Za-z0-9._-]+)`,
|
||||
SettleTime: 1500 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, session.Cookies, "SPA-token login sets no cookie")
|
||||
require.NotEmpty(t, session.Token, "token must be extracted from localStorage")
|
||||
require.NotEmpty(t, session.LocalStorage["access_token"], "localStorage must be captured")
|
||||
// The captured bearer token must authenticate the protected API.
|
||||
assertSessionAuthenticates(t, base, session)
|
||||
}
|
||||
|
||||
// TestPlayground_HeadlessSPATokenAutoDetect proves the engine surfaces a JWT
|
||||
// kept in web storage as the session token even when no token-regex is given
|
||||
// (the JWT is stored under the conventional "access_token" key).
|
||||
func TestPlayground_HeadlessSPATokenAutoDetect(t *testing.T) {
|
||||
requireChrome(t)
|
||||
base := playgroundServer(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
session, err := LoginHeadless(ctx, Config{
|
||||
LoginURL: base + "/auth/spa-token-login",
|
||||
Username: fuzzplayground.AuthUsername,
|
||||
Password: fuzzplayground.AuthPassword,
|
||||
SettleTime: 1500 * time.Millisecond,
|
||||
// Intentionally no TokenRegex: rely on web-storage JWT auto-detection.
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, session.Token, "JWT in localStorage must be auto-detected without a token-regex")
|
||||
require.NotEmpty(t, session.LocalStorage["access_token"], "localStorage must be captured")
|
||||
assertSessionAuthenticates(t, base, session)
|
||||
}
|
||||
|
||||
// TestPlayground_HTTPMultiFormPicksLogin proves the form detector selects the
|
||||
// credential form on a page that also contains a decoy search form (no
|
||||
// password), rather than blindly taking the first <form>.
|
||||
func TestPlayground_HTTPMultiFormPicksLogin(t *testing.T) {
|
||||
base := playgroundServer(t)
|
||||
session, err := Login(context.Background(), nil, Config{
|
||||
LoginURL: base + "/auth/multiform-login",
|
||||
Username: fuzzplayground.AuthUsername,
|
||||
Password: fuzzplayground.AuthPassword,
|
||||
})
|
||||
require.NoError(t, err, "detector must pick the login form over the decoy search form")
|
||||
require.Contains(t, cookieNames(session), "PSESSION")
|
||||
assertSessionAuthenticates(t, base, session)
|
||||
}
|
||||
|
||||
// TestPlayground_HeadlessJSCookieLogin proves cookies set client-side via
|
||||
// document.cookie (no Set-Cookie header) are captured from the browser jar.
|
||||
func TestPlayground_HeadlessJSCookieLogin(t *testing.T) {
|
||||
requireChrome(t)
|
||||
base := playgroundServer(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
session, err := LoginHeadless(ctx, Config{
|
||||
LoginURL: base + "/auth/jscookie-login",
|
||||
Username: fuzzplayground.AuthUsername,
|
||||
Password: fuzzplayground.AuthPassword,
|
||||
SettleTime: 1500 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, cookieNames(session), "PSESSION", "JS-set cookie must be captured from the browser")
|
||||
assertSessionAuthenticates(t, base, session)
|
||||
}
|
||||
|
||||
// TestPlayground_HeadlessDelayedFormLogin proves the engine waits for a login
|
||||
// form that is injected asynchronously instead of failing immediately.
|
||||
func TestPlayground_HeadlessDelayedFormLogin(t *testing.T) {
|
||||
requireChrome(t)
|
||||
base := playgroundServer(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
session, err := LoginHeadless(ctx, Config{
|
||||
LoginURL: base + "/auth/delayed-login",
|
||||
Username: fuzzplayground.AuthUsername,
|
||||
Password: fuzzplayground.AuthPassword,
|
||||
SettleTime: 2 * time.Second,
|
||||
})
|
||||
require.NoError(t, err, "engine must wait for the asynchronously-rendered form")
|
||||
require.Contains(t, cookieNames(session), "PSESSION")
|
||||
assertSessionAuthenticates(t, base, session)
|
||||
}
|
||||
|
||||
// TestPlayground_HeadlessHeaderTokenLogin proves the headless engine recovers a
|
||||
// token that is only ever delivered in an XHR response header (never stored in
|
||||
// a cookie or web storage) via passive response-header interception.
|
||||
func TestPlayground_HeadlessHeaderTokenLogin(t *testing.T) {
|
||||
requireChrome(t)
|
||||
base := playgroundServer(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
session, err := LoginHeadless(ctx, Config{
|
||||
LoginURL: base + "/auth/spa-header-token-login",
|
||||
Username: fuzzplayground.AuthUsername,
|
||||
Password: fuzzplayground.AuthPassword,
|
||||
SettleTime: 1500 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, session.Cookies, "header-token SPA sets no cookie")
|
||||
require.Empty(t, session.LocalStorage["token"], "the SPA does not store the token")
|
||||
require.NotEmpty(t, session.Token, "token must be captured from the XHR response header")
|
||||
assertSessionAuthenticates(t, base, session)
|
||||
}
|
||||
|
||||
func TestPlayground_HeadlessOAuthRedirect(t *testing.T) {
|
||||
requireChrome(t)
|
||||
base := playgroundServer(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Pure redirect SSO: visiting authorize logs the user in; there is no form,
|
||||
// so a single settle step lets the redirect chain complete before capture.
|
||||
session, err := LoginHeadless(ctx, Config{
|
||||
LoginURL: base + "/auth/oauth/authorize",
|
||||
SettleTime: 1 * time.Second,
|
||||
Steps: []LoginStep{{Action: "wait", Value: "1s"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, cookieNames(session), "PSESSION")
|
||||
assertSessionAuthenticates(t, base, session)
|
||||
}
|
||||
|
||||
// TestPlayground_RecordingReplay imports a Chrome Recorder export (with the base
|
||||
// URL templated in), verifies the recorded credential literals are parameterized
|
||||
// out, and replays the compiled steps headlessly to obtain a real session.
|
||||
func TestPlayground_RecordingReplay(t *testing.T) {
|
||||
requireChrome(t)
|
||||
base := playgroundServer(t)
|
||||
|
||||
raw, err := os.ReadFile(filepath.Join("testdata", "recordings", "spa_login.json"))
|
||||
require.NoError(t, err)
|
||||
rec := strings.ReplaceAll(string(raw), "__BASE__", base)
|
||||
recFile := filepath.Join(t.TempDir(), "spa_login.json")
|
||||
require.NoError(t, os.WriteFile(recFile, []byte(rec), 0o600))
|
||||
|
||||
steps, err := StepsFromRecordingFile(recFile, fuzzplayground.AuthUsername, fuzzplayground.AuthPassword)
|
||||
require.NoError(t, err)
|
||||
// Credentials must never survive in the compiled steps.
|
||||
for _, s := range steps {
|
||||
require.NotContains(t, s.Value, fuzzplayground.AuthPassword, "password literal leaked into steps")
|
||||
require.NotContains(t, s.Value, fuzzplayground.AuthUsername, "username literal leaked into steps")
|
||||
}
|
||||
require.Contains(t, stepValues(steps), "{{username}}")
|
||||
require.Contains(t, stepValues(steps), "{{password}}")
|
||||
// An xpath-only selector must compile to the engine's xpath= form.
|
||||
require.Contains(t, stepSelectors(steps), "xpath=//input[@id='email']")
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
session, err := LoginHeadless(ctx, Config{
|
||||
LoginURL: FirstNavigateURL(steps),
|
||||
Username: fuzzplayground.AuthUsername,
|
||||
Password: fuzzplayground.AuthPassword,
|
||||
Steps: steps,
|
||||
SettleTime: 1500 * time.Millisecond,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, cookieNames(session), "PSESSION")
|
||||
assertSessionAuthenticates(t, base, session)
|
||||
}
|
||||
|
||||
func stepValues(steps []LoginStep) []string {
|
||||
var out []string
|
||||
for _, s := range steps {
|
||||
out = append(out, s.Value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func stepSelectors(steps []LoginStep) []string {
|
||||
var out []string
|
||||
for _, s := range steps {
|
||||
out = append(out, s.Selector)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- Authenticated fuzzing target -----------------------------------------
|
||||
|
||||
// TestPlayground_AuthenticatedEndpointBehindLogin proves the authenticated,
|
||||
// fuzzable endpoint is only reachable with a captured session and reflects input
|
||||
// (a genuine target behind the login wall for authenticated scanning).
|
||||
func TestPlayground_AuthenticatedEndpointBehindLogin(t *testing.T) {
|
||||
base := playgroundServer(t)
|
||||
session, err := Login(context.Background(), nil, Config{
|
||||
LoginURL: base + "/auth/form-login",
|
||||
Username: fuzzplayground.AuthUsername,
|
||||
Password: fuzzplayground.AuthPassword,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Unauthenticated: blocked.
|
||||
resp, err := http.Get(base + "/auth/api/search?q=hello")
|
||||
require.NoError(t, err)
|
||||
_ = resp.Body.Close()
|
||||
require.Equal(t, http.StatusUnauthorized, resp.StatusCode, "search must require auth")
|
||||
|
||||
// Authenticated: reachable and reflects the payload unsanitized.
|
||||
req, _ := http.NewRequest(http.MethodGet, base+"/auth/api/search?q="+payloadXSS, nil)
|
||||
req.Header.Set("Cookie", session.CookieHeader)
|
||||
resp2, err := http.DefaultClient.Do(req)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp2.Body.Close() }()
|
||||
body, _ := io.ReadAll(resp2.Body)
|
||||
require.Equal(t, http.StatusOK, resp2.StatusCode)
|
||||
require.True(t, strings.Contains(string(body), payloadXSS), "authenticated endpoint should reflect the payload")
|
||||
}
|
||||
|
||||
const payloadXSS = "<script>alert(1)</script>"
|
||||
@@ -1,146 +0,0 @@
|
||||
package autologin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Real-application harness. These tests are skipped unless NUCLEI_REALAPP_LOGIN_URL
|
||||
// is set, so normal CI is unaffected. They drive the headless engine against a
|
||||
// live app to flush out engine bugs that synthetic httptest servers miss
|
||||
// (heavy SPA bootstrap, XHR logins, hash routes, token-in-web-storage, etc.).
|
||||
//
|
||||
// Example (OWASP Juice Shop):
|
||||
//
|
||||
// docker run -d -p 3000:3000 bkimminich/juice-shop
|
||||
// NUCLEI_REALAPP_LOGIN_URL='http://localhost:3000/#/login' \
|
||||
// NUCLEI_REALAPP_USERNAME='[email protected]' \
|
||||
// NUCLEI_REALAPP_PASSWORD='admin123' \
|
||||
// NUCLEI_REALAPP_TOKEN_REGEX='(eyJ[A-Za-z0-9._-]+\.[A-Za-z0-9._-]+\.[A-Za-z0-9._-]+)' \
|
||||
// NUCLEI_REALAPP_EXPECT_STORAGE_KEY='token' \
|
||||
// go test ./pkg/authprovider/autologin/ -run TestRealApp_HeadlessLogin -v
|
||||
func realAppConfig(t *testing.T) (Config, bool) {
|
||||
t.Helper()
|
||||
loginURL := os.Getenv("NUCLEI_REALAPP_LOGIN_URL")
|
||||
if loginURL == "" {
|
||||
t.Skip("set NUCLEI_REALAPP_LOGIN_URL to run the real-app login harness")
|
||||
return Config{}, false
|
||||
}
|
||||
cfg := Config{
|
||||
LoginURL: loginURL,
|
||||
Username: os.Getenv("NUCLEI_REALAPP_USERNAME"),
|
||||
Password: os.Getenv("NUCLEI_REALAPP_PASSWORD"),
|
||||
UsernameField: os.Getenv("NUCLEI_REALAPP_USERNAME_FIELD"),
|
||||
PasswordField: os.Getenv("NUCLEI_REALAPP_PASSWORD_FIELD"),
|
||||
TokenRegex: os.Getenv("NUCLEI_REALAPP_TOKEN_REGEX"),
|
||||
Proxy: os.Getenv("NUCLEI_REALAPP_PROXY"),
|
||||
SettleTime: 8 * time.Second,
|
||||
Timeout: 90 * time.Second,
|
||||
}
|
||||
if steps := os.Getenv("NUCLEI_REALAPP_STEPS"); steps != "" {
|
||||
require.NoError(t, json.Unmarshal([]byte(steps), &cfg.Steps), "NUCLEI_REALAPP_STEPS must be a JSON array of LoginStep")
|
||||
}
|
||||
return cfg, true
|
||||
}
|
||||
|
||||
func logSession(t *testing.T, s *Session) {
|
||||
t.Helper()
|
||||
t.Logf("final url: %s", s.FinalURL)
|
||||
t.Logf("cookies (%d):", len(s.Cookies))
|
||||
for _, c := range s.Cookies {
|
||||
v := c.Value
|
||||
if len(v) > 24 {
|
||||
v = v[:24] + "...(truncated)"
|
||||
}
|
||||
t.Logf(" %s = %s", c.Name, v)
|
||||
}
|
||||
if s.Token != "" {
|
||||
tok := s.Token
|
||||
if len(tok) > 32 {
|
||||
tok = tok[:32] + "...(truncated)"
|
||||
}
|
||||
t.Logf("token: %s", tok)
|
||||
}
|
||||
t.Logf("localStorage keys: %d, sessionStorage keys: %d", len(s.LocalStorage), len(s.SessionStorage))
|
||||
for k := range s.LocalStorage {
|
||||
t.Logf(" local[%s]", k)
|
||||
}
|
||||
for k := range s.SessionStorage {
|
||||
t.Logf(" session[%s]", k)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealApp_HeadlessLogin(t *testing.T) {
|
||||
requireChrome(t)
|
||||
cfg, ok := realAppConfig(t)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
session, err := LoginHeadless(ctx, cfg)
|
||||
require.NoError(t, err, "headless login against real app failed")
|
||||
logSession(t, session)
|
||||
|
||||
require.True(t, len(session.Cookies) > 0 || session.Token != "" || len(session.LocalStorage) > 0,
|
||||
"expected to capture a session (cookie, token or web storage)")
|
||||
|
||||
if key := os.Getenv("NUCLEI_REALAPP_EXPECT_STORAGE_KEY"); key != "" {
|
||||
_, inLocal := session.LocalStorage[key]
|
||||
_, inSession := session.SessionStorage[key]
|
||||
require.True(t, inLocal || inSession, "expected web storage key %q to be captured", key)
|
||||
}
|
||||
if name := os.Getenv("NUCLEI_REALAPP_EXPECT_COOKIE"); name != "" {
|
||||
found := false
|
||||
for _, c := range session.Cookies {
|
||||
if c.Name == name {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
require.True(t, found, "expected cookie %q to be captured", name)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRealApp_RecordingReplay drives a recorder-JSON login flow (compiled to
|
||||
// steps, with credential parameterization) against the live app, exercising the
|
||||
// full record->replay path end to end. Set NUCLEI_REALAPP_RECORDING to a Chrome
|
||||
// DevTools Recorder export to run it.
|
||||
func TestRealApp_RecordingReplay(t *testing.T) {
|
||||
requireChrome(t)
|
||||
cfg, ok := realAppConfig(t)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
recording := os.Getenv("NUCLEI_REALAPP_RECORDING")
|
||||
if recording == "" {
|
||||
t.Skip("set NUCLEI_REALAPP_RECORDING to a recorder json to run the replay harness")
|
||||
}
|
||||
|
||||
steps, err := StepsFromRecordingFile(recording, cfg.Username, cfg.Password)
|
||||
require.NoError(t, err, "failed to compile recording")
|
||||
t.Logf("compiled %d steps from recording", len(steps))
|
||||
for _, s := range steps {
|
||||
t.Logf(" %s selector=%q value=%q", s.Action, s.Selector, s.Value)
|
||||
}
|
||||
cfg.Steps = steps
|
||||
if cfg.LoginURL == "" {
|
||||
cfg.LoginURL = FirstNavigateURL(steps)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
session, err := LoginHeadless(ctx, cfg)
|
||||
require.NoError(t, err, "recorded login replay against real app failed")
|
||||
logSession(t, session)
|
||||
require.True(t, len(session.Cookies) > 0 || session.Token != "" || len(session.LocalStorage) > 0,
|
||||
"expected recorded login to capture a session")
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
package autologin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/projectdiscovery/utils/errkit"
|
||||
)
|
||||
|
||||
// recordingFile is the Chrome DevTools Recorder / @puppeteer/replay export
|
||||
// schema (the two are identical). A user records their real login in Chrome
|
||||
// (DevTools -> Recorder) and exports it as JSON; we compile it down to the same
|
||||
// LoginStep list the headless engine already replays, so complex SSO/MFA logins
|
||||
// can be authored by recording instead of hand-writing steps or templates.
|
||||
type recordingFile struct {
|
||||
Title string `json:"title"`
|
||||
Steps []recordingStep `json:"steps"`
|
||||
}
|
||||
|
||||
// recordingStep is a single recorded user action. Selectors is a list of
|
||||
// alternative selector strategies for the target element, each itself a list
|
||||
// (Chrome groups shadow/frame piercing selectors); we use the first entry of
|
||||
// each group as a candidate.
|
||||
type recordingStep struct {
|
||||
Type string `json:"type"`
|
||||
URL string `json:"url"`
|
||||
Selectors [][]string `json:"selectors"`
|
||||
Value string `json:"value"`
|
||||
Key string `json:"key"`
|
||||
Target string `json:"target"`
|
||||
Expression string `json:"expression"`
|
||||
}
|
||||
|
||||
// StepsFromRecordingFile reads and converts a recorder export file into
|
||||
// LoginSteps. username/password are used to parameterize the recording so the
|
||||
// captured credential literals are replaced with {{username}}/{{password}}
|
||||
// placeholders (kept out of the committed flow file).
|
||||
func StepsFromRecordingFile(path, username, password string) ([]LoginStep, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login: failed to read recording file")
|
||||
}
|
||||
return StepsFromRecording(data, username, password)
|
||||
}
|
||||
|
||||
// StepsFromRecording converts a recorder export (JSON) into LoginSteps.
|
||||
func StepsFromRecording(data []byte, username, password string) ([]LoginStep, error) {
|
||||
var rec recordingFile
|
||||
if err := json.Unmarshal(data, &rec); err != nil {
|
||||
return nil, errkit.Wrap(err, "auto-login: invalid recording json")
|
||||
}
|
||||
if len(rec.Steps) == 0 {
|
||||
return nil, errkit.New("auto-login: recording contains no steps")
|
||||
}
|
||||
|
||||
var steps []LoginStep
|
||||
for _, rs := range rec.Steps {
|
||||
switch strings.ToLower(strings.TrimSpace(rs.Type)) {
|
||||
case "navigate":
|
||||
if rs.URL != "" {
|
||||
steps = append(steps, LoginStep{Action: "navigate", Value: rs.URL})
|
||||
}
|
||||
case "click", "doubleclick":
|
||||
sel := pickSelector(rs.Selectors)
|
||||
if sel == "" {
|
||||
continue
|
||||
}
|
||||
steps = append(steps, LoginStep{Action: "click", Selector: sel})
|
||||
case "change":
|
||||
sel := pickSelector(rs.Selectors)
|
||||
if sel == "" {
|
||||
continue
|
||||
}
|
||||
steps = append(steps, LoginStep{
|
||||
Action: "fill",
|
||||
Selector: sel,
|
||||
Value: parameterizeValue(rs.Value, sel, username, password),
|
||||
})
|
||||
case "keydown":
|
||||
// Only emit actionable keys; character keys are captured by `change`,
|
||||
// and the paired keyUp event is ignored to avoid double submission.
|
||||
if key := normalizeKey(rs.Key); key != "" {
|
||||
steps = append(steps, LoginStep{Action: "press", Value: key, Selector: pickSelector(rs.Selectors)})
|
||||
}
|
||||
case "waitforelement":
|
||||
sel := pickSelector(rs.Selectors)
|
||||
if sel == "" {
|
||||
continue
|
||||
}
|
||||
steps = append(steps, LoginStep{Action: "waitvisible", Selector: sel})
|
||||
case "waitforexpression":
|
||||
// We can't evaluate arbitrary expressions in the step engine; fall back
|
||||
// to a settle wait so the page can reach the expected state.
|
||||
steps = append(steps, LoginStep{Action: "wait"})
|
||||
case "setviewport", "keyup", "scroll", "close", "emulatenetworkconditions", "hover", "":
|
||||
// Not relevant to (or not supported for) headless login replay.
|
||||
continue
|
||||
default:
|
||||
// Unknown step types are skipped rather than failing the whole import.
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if len(steps) == 0 {
|
||||
return nil, errkit.New("auto-login: recording produced no replayable steps")
|
||||
}
|
||||
return steps, nil
|
||||
}
|
||||
|
||||
// FirstNavigateURL returns the URL of the first navigate step in a recording, or
|
||||
// "" if none. It lets the caller derive the login URL from the recording.
|
||||
func FirstNavigateURL(steps []LoginStep) string {
|
||||
for _, s := range steps {
|
||||
if strings.EqualFold(s.Action, "navigate") && s.Value != "" {
|
||||
return s.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// normalizeKey maps recorder key names to the engine's supported press keys.
|
||||
// Character keys (length 1) return "" because the text they produce is already
|
||||
// captured by `change` steps.
|
||||
func normalizeKey(key string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(key)) {
|
||||
case "enter", "return", "numpadenter":
|
||||
return "enter"
|
||||
case "tab":
|
||||
return "tab"
|
||||
case "escape", "esc":
|
||||
return "escape"
|
||||
case "space":
|
||||
return "space"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// parameterizeValue replaces a recorded credential literal with a placeholder so
|
||||
// secrets never live in the recording file. It masks by exact match against the
|
||||
// configured credentials and, defensively, masks any value typed into a field
|
||||
// whose selector looks like a password input.
|
||||
func parameterizeValue(value, selector, username, password string) string {
|
||||
if password != "" && value == password {
|
||||
return "{{password}}"
|
||||
}
|
||||
if username != "" && value == username {
|
||||
return "{{username}}"
|
||||
}
|
||||
if looksLikePasswordSelector(selector) {
|
||||
return "{{password}}"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func looksLikePasswordSelector(selector string) bool {
|
||||
s := strings.ToLower(selector)
|
||||
return strings.Contains(s, "password") || strings.Contains(s, "passwd") || strings.Contains(s, "type=\"password\"") || strings.Contains(s, "type=password")
|
||||
}
|
||||
|
||||
// pickSelector chooses the most engine-friendly selector from the recorder's
|
||||
// alternatives, preferring native CSS, then XPath, then an aria-label CSS
|
||||
// approximation, then a text-content XPath. Shadow-piercing (pierce/) selectors
|
||||
// are skipped because the engine cannot resolve them.
|
||||
func pickSelector(groups [][]string) string {
|
||||
var css, xpath, aria, text string
|
||||
for _, group := range groups {
|
||||
for _, s := range group {
|
||||
s = strings.TrimSpace(s)
|
||||
switch {
|
||||
case s == "":
|
||||
continue
|
||||
case strings.HasPrefix(s, "xpath/"):
|
||||
if xpath == "" {
|
||||
xpath = "xpath=" + strings.TrimPrefix(s, "xpath/")
|
||||
}
|
||||
case strings.HasPrefix(s, "aria/"):
|
||||
if aria == "" {
|
||||
aria = ariaToCSS(strings.TrimPrefix(s, "aria/"))
|
||||
}
|
||||
case strings.HasPrefix(s, "text/"):
|
||||
if text == "" {
|
||||
text = textToXPath(strings.TrimPrefix(s, "text/"))
|
||||
}
|
||||
case strings.HasPrefix(s, "pierce/"):
|
||||
continue
|
||||
default:
|
||||
if css == "" {
|
||||
css = s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, candidate := range []string{css, xpath, aria, text} {
|
||||
if candidate != "" {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ariaToCSS approximates an accessibility-name selector as an aria-label CSS
|
||||
// attribute selector. It is best-effort; recordings usually also carry a CSS or
|
||||
// XPath selector that is preferred over this.
|
||||
func ariaToCSS(name string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || strings.Contains(name, `"`) {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(`[aria-label="%s"]`, name)
|
||||
}
|
||||
|
||||
// textToXPath converts a text-content selector into an XPath the engine can run.
|
||||
func textToXPath(t string) string {
|
||||
t = strings.TrimSpace(t)
|
||||
if t == "" || strings.Contains(t, `"`) {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf(`xpath=//*[contains(normalize-space(.), "%s")]`, t)
|
||||
}
|
||||
Loaded 100 of 277 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user