mirror of
https://github.com/projectdiscovery/nuclei.git
synced 2026-11-13 05:41:25 -10:00
Compare commits
203
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
746e92e675 | ||
|
|
3f88aad2cc | ||
|
|
0eb52d1a07 | ||
|
|
b0eeadc554 | ||
|
|
5670af4c66 | ||
|
|
6c4fa11c96 | ||
|
|
88f169d56d | ||
|
|
7c0cb98e9b | ||
|
|
f6f8786531 | ||
|
|
c041c5b1e4 | ||
|
|
f3b817b818 | ||
|
|
be87581d1e | ||
|
|
e647e92e67 | ||
|
|
68049c5985 | ||
|
|
6cdfcffc6d | ||
|
|
151115fad2 | ||
|
|
6b79ff1a68 | ||
|
|
e78e9ddea8 | ||
|
|
339578d415 | ||
|
|
bdcbf5d1ae | ||
|
|
a3109229fd | ||
|
|
190fb46436 | ||
|
|
97f09fa509 | ||
|
|
f51ae596be | ||
|
|
91456915e9 | ||
|
|
9666293e9f | ||
|
|
a5555a72c2 | ||
|
|
dfe70666a3 | ||
|
|
69f4fe915b | ||
|
|
c8a0d45d38 | ||
|
|
10f1967cdc | ||
|
|
767655e306 | ||
|
|
0bd50f957a | ||
|
|
e264bd82f9 | ||
|
|
6a42a7bf52 | ||
|
|
788973d974 | ||
|
|
7989011cdf | ||
|
|
a151a1d54f | ||
|
|
1db63fdb42 | ||
|
|
af61439dc5 | ||
|
|
9a13e64144 | ||
|
|
a5715288aa | ||
|
|
cc5645e674 | ||
|
|
40075b0b20 | ||
|
|
90a0cce807 | ||
|
|
1caa955c77 | ||
|
|
830e012a10 | ||
|
|
9b368f3eaa | ||
|
|
59e6ffa6fb | ||
|
|
b870cdcd67 | ||
|
|
3f776b5ff0 | ||
|
|
ad74f0094c | ||
|
|
4137bedd31 | ||
|
|
a48a58ef59 | ||
|
|
0de51237b7 | ||
|
|
4857883667 | ||
|
|
bccaa4410d | ||
|
|
d8253b97e9 | ||
|
|
65f60dd0a6 | ||
|
|
1115f18b68 | ||
|
|
458367feb7 | ||
|
|
0af8c98679 | ||
|
|
0217339d5d | ||
|
|
d23849e996 | ||
|
|
59dad1b619 | ||
|
|
889c8ff9c5 | ||
|
|
c990724404 | ||
|
|
50e0afd188 | ||
|
|
04d0caa646 | ||
|
|
80a001ca0f | ||
|
|
1b080a7d19 | ||
|
|
a847374f2c | ||
|
|
9c9247895d | ||
|
|
37052a7e3f | ||
|
|
c4825e98c9 | ||
|
|
0c0e886c2c | ||
|
|
b3e02efa7f | ||
|
|
aa3c7a361c | ||
|
|
afb2afaf4f | ||
|
|
6fde18d311 | ||
|
|
62721e589a | ||
|
|
66ef296067 | ||
|
|
496589e7d0 | ||
|
|
bd510ac6bf | ||
|
|
71457b4f69 | ||
|
|
97c8233b72 | ||
|
|
6bb5b5dbd1 | ||
|
|
8f23a8633f | ||
|
|
28d5b3e8c0 | ||
|
|
c2d38083dc | ||
|
|
1884ed920f | ||
|
|
d28dd2c745 | ||
|
|
3336556187 | ||
|
|
2c8f4a0318 | ||
|
|
8c8a1a9d78 | ||
|
|
81749cf750 | ||
|
|
846f4425b0 | ||
|
|
fa876a4d92 | ||
|
|
0ae69a7d53 | ||
|
|
80b58e6e9b | ||
|
|
4d7e91e4e0 | ||
|
|
6b9208cdbf | ||
|
|
26b28c69a4 | ||
|
|
a14b3de2a4 | ||
|
|
3c434fb73a | ||
|
|
f40fa195b6 | ||
|
|
a2b6d2abc7 | ||
|
|
b87b28d7ad | ||
|
|
4d9f2f9867 | ||
|
|
4177ca752b | ||
|
|
8ffccaf639 | ||
|
|
fd1da307b6 | ||
|
|
5674792a7c | ||
|
|
a80d0b881a | ||
|
|
0e243e3edb | ||
|
|
54f9310945 | ||
|
|
3a827b8c8b | ||
|
|
97e13fae6e | ||
|
|
1b8e3515bf | ||
|
|
0de1d12cc9 | ||
|
|
47f74706a5 | ||
|
|
dad93f0ed6 | ||
|
|
3adff6347b | ||
|
|
c5611b9ac4 | ||
|
|
77d3b6d314 | ||
|
|
c36316de36 | ||
|
|
010ee044b2 | ||
|
|
71f9782b2c | ||
|
|
536e427c01 | ||
|
|
ae203e7aad | ||
|
|
2b6b1d3371 | ||
|
|
3fdd8ca091 | ||
|
|
af77e344f0 | ||
|
|
1b244b2330 | ||
|
|
fb1a318c6a | ||
|
|
dbc69f7bf0 | ||
|
|
aed2cb4643 | ||
|
|
15e2d88ca5 | ||
|
|
4d967cba5c | ||
|
|
dece1c5ea9 | ||
|
|
5e5758b7d3 | ||
|
|
06fb71ebf2 | ||
|
|
c50f92fdd3 | ||
|
|
5b6510fb79 | ||
|
|
a5b59a9a4e | ||
|
|
14283b0e13 | ||
|
|
322a40e1db | ||
|
|
aa457f1c44 | ||
|
|
bd0c398887 | ||
|
|
b4a5cdab02 | ||
|
|
1981edbaac | ||
|
|
0e2fb326d6 | ||
|
|
ef0faa7d6f | ||
|
|
e8b53505e5 | ||
|
|
1d6b532bbc | ||
|
|
b6cea874d3 | ||
|
|
36dafa988b | ||
|
|
d7bdb9c058 | ||
|
|
f3c12c43eb | ||
|
|
4dad173f0a | ||
|
|
1b557852c4 | ||
|
|
e5052eb771 | ||
|
|
56411349d2 | ||
|
|
77dda01e67 | ||
|
|
a7faddf748 | ||
|
|
d47ace7701 | ||
|
|
37c06098ad | ||
|
|
12fd62163b | ||
|
|
748ff1381e | ||
|
|
026a1890b8 | ||
|
|
e14933f765 | ||
|
|
66068dbec2 | ||
|
|
bd639a5aff | ||
|
|
fcba75d191 | ||
|
|
33067508b1 | ||
|
|
d4a40d2b04 | ||
|
|
c063d77d2d | ||
|
|
ddb2edb2e9 | ||
|
|
5347be3255 | ||
|
|
59f4635e8c | ||
|
|
8f4c1fff90 | ||
|
|
6138a17677 | ||
|
|
fe14cf5d2c | ||
|
|
89db6cf43a | ||
|
|
a78ea81854 | ||
|
|
666dde153a | ||
|
|
6dfd955715 | ||
|
|
7b14d3db3c | ||
|
|
b69bf17ce1 | ||
|
|
a04aba1022 | ||
|
|
f91f5bb405 | ||
|
|
1cbdf4f4c7 | ||
|
|
97f24a1c3e | ||
|
|
e39543e57b | ||
|
|
c12b7f21c3 | ||
|
|
2d1914d808 | ||
|
|
7c4d90f9d3 | ||
|
|
88f78206b1 | ||
|
|
12aa925acc | ||
|
|
f2e1f2c830 | ||
|
|
37076fdc95 | ||
|
|
87cd437d67 | ||
|
|
e30c800e8c |
No files matched your search
@@ -13,7 +13,7 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
- uses: projectdiscovery/actions/setup/go/compat-checks@v1
|
||||
with:
|
||||
release-test: true
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
TARGET_URL: "http://honey.scanme.sh/-/?foo=bar"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
- uses: projectdiscovery/actions/setup/go@v1
|
||||
- uses: projectdiscovery/nuclei-action/cache@v3
|
||||
- run: make build
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
outputs:
|
||||
matrix: ${{ steps.discover.outputs.matrix }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
- uses: projectdiscovery/actions/setup/go@v1
|
||||
with:
|
||||
go-version-file: go.tool.mod
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
GOFUZZ_PACKAGE: ${{ matrix.pkg }}
|
||||
FUZZ_DURATION: 15m
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
- uses: projectdiscovery/actions/setup/go@v1
|
||||
with:
|
||||
go-version-file: go.tool.mod
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
if: ${{ !endsWith(github.actor, '[bot]') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
- uses: projectdiscovery/actions/setup/go@v1
|
||||
- uses: projectdiscovery/actions/setup/git@v1
|
||||
- run: make syntax-docs
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
TARGET_LIST: "/tmp/targets.txt"
|
||||
PROFILE_MEM: "/tmp/nuclei-profile"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
- uses: projectdiscovery/actions/setup/go@v1
|
||||
- uses: projectdiscovery/nuclei-action/cache@v3
|
||||
- run: |
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
env:
|
||||
OUTPUT: "/tmp/results.sarif"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
- uses: projectdiscovery/actions/setup/go@v1
|
||||
with:
|
||||
go-version-file: go.tool.mod
|
||||
|
||||
@@ -14,7 +14,7 @@ jobs:
|
||||
if: ${{ github.repository == 'projectdiscovery/nuclei' && !endsWith(github.actor, '[bot]') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
- uses: projectdiscovery/actions/setup/go@v1
|
||||
- uses: projectdiscovery/actions/setup/git@v1
|
||||
- run: make memogen
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
env:
|
||||
BENCH_OUT: "/tmp/bench.out"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
- uses: projectdiscovery/actions/setup/go@v1
|
||||
- uses: projectdiscovery/nuclei-action/cache@v3
|
||||
- run: make build-test
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
needs: ["pgo"]
|
||||
runs-on: ubuntu-latest-16-cores
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/download-artifact@v8
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
# A workflow can initially run from the branch that adds this file. The
|
||||
# API analyzes the repository default branch, so check out that same
|
||||
# branch before calculating the HEAD we wait for.
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
|
||||
|
||||
@@ -17,12 +17,14 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# lint gates every other job; none of them uses another's output, so they run
|
||||
# in parallel once it passes instead of queueing behind the unit tests
|
||||
lint:
|
||||
name: "Lint"
|
||||
if: ${{ !endsWith(github.actor, '[bot]') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
- uses: projectdiscovery/actions/setup/go@v1
|
||||
- uses: projectdiscovery/nuclei-action/cache@v3
|
||||
- uses: projectdiscovery/actions/golangci-lint/v2@v1
|
||||
@@ -42,19 +44,9 @@ jobs:
|
||||
os: [ubuntu-latest, windows-latest, macOS-latest]
|
||||
runs-on: "${{ matrix.os }}"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
- uses: projectdiscovery/actions/setup/go@v1
|
||||
- uses: projectdiscovery/nuclei-action/cache@v3
|
||||
- uses: projectdiscovery/actions/free-disk-space@v1
|
||||
with:
|
||||
llvm: 'false'
|
||||
php: 'false'
|
||||
mongodb: 'false'
|
||||
mysql: 'false'
|
||||
misc-packages: 'false'
|
||||
docker-images: 'false'
|
||||
tools-cache: 'false'
|
||||
- run: make build
|
||||
# The data-race detector is OS-independent, so we only pay its ~2-3x cost
|
||||
# on ubuntu; windows/macOS run the same suite without instrumentation.
|
||||
- name: "Unit tests (race)"
|
||||
@@ -85,12 +77,12 @@ jobs:
|
||||
|
||||
sdk:
|
||||
name: "Run example SDK"
|
||||
needs: ["tests"]
|
||||
needs: ["lint"]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GITHUB_TOKEN: "${{ github.token }}"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
- uses: projectdiscovery/actions/setup/go@v1
|
||||
- uses: projectdiscovery/nuclei-action/cache@v3
|
||||
- name: "Simple"
|
||||
@@ -106,7 +98,7 @@ jobs:
|
||||
|
||||
integration:
|
||||
name: "Integration tests"
|
||||
needs: ["tests"]
|
||||
needs: ["lint"]
|
||||
env:
|
||||
GITHUB_TOKEN: "${{ github.token }}"
|
||||
strategy:
|
||||
@@ -115,10 +107,22 @@ jobs:
|
||||
os: [ubuntu-latest, windows-latest, macOS-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
- uses: projectdiscovery/actions/setup/python@v1
|
||||
- uses: projectdiscovery/actions/setup/go@v1
|
||||
- uses: projectdiscovery/nuclei-action/cache@v3
|
||||
# The suite launches one nuclei process per case, ~175 of them, and the
|
||||
# binary is around 200MB. This job takes roughly four times longer on
|
||||
# windows than on linux for the same work; Defender scanning an executable
|
||||
# on every launch is the leading suspect for the difference.
|
||||
- name: Exclude the workspace from Defender scanning
|
||||
if: ${{ matrix.os == 'windows-latest' }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
Add-MpPreference -ExclusionPath "${{ github.workspace }}"
|
||||
Add-MpPreference -ExclusionPath "$env:USERPROFILE\go"
|
||||
Add-MpPreference -ExclusionProcess "nuclei.exe"
|
||||
Add-MpPreference -ExclusionProcess "go.exe"
|
||||
- run: make integration
|
||||
env:
|
||||
PDCP_API_KEY: "${{ secrets.PDCP_API_KEY }}"
|
||||
@@ -126,7 +130,7 @@ jobs:
|
||||
|
||||
functional:
|
||||
name: "Functional tests"
|
||||
needs: ["tests"]
|
||||
needs: ["lint"]
|
||||
env:
|
||||
GITHUB_TOKEN: "${{ github.token }}"
|
||||
strategy:
|
||||
@@ -135,7 +139,7 @@ jobs:
|
||||
os: [ubuntu-latest, windows-latest, macOS-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
- uses: projectdiscovery/actions/setup/python@v1
|
||||
- uses: projectdiscovery/actions/setup/go@v1
|
||||
- uses: projectdiscovery/nuclei-action/cache@v3
|
||||
@@ -147,26 +151,26 @@ jobs:
|
||||
|
||||
validate:
|
||||
name: "Template validate"
|
||||
needs: ["tests"]
|
||||
needs: ["lint"]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GITHUB_TOKEN: "${{ github.token }}"
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
- uses: projectdiscovery/actions/setup/go@v1
|
||||
- uses: projectdiscovery/nuclei-action/cache@v3
|
||||
- run: make template-validate
|
||||
|
||||
codeql:
|
||||
name: "CodeQL analysis"
|
||||
needs: ["tests"]
|
||||
needs: ["lint"]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
- uses: github/codeql-action/init@v4
|
||||
with:
|
||||
languages: 'go'
|
||||
@@ -201,16 +205,16 @@ jobs:
|
||||
|
||||
release:
|
||||
name: "Release test"
|
||||
needs: ["tests"]
|
||||
needs: ["lint"]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v7.0.1
|
||||
- uses: projectdiscovery/actions/setup/go@v1
|
||||
- uses: projectdiscovery/actions/goreleaser@v1
|
||||
|
||||
flamegraph:
|
||||
name: "Flamegraph"
|
||||
needs: ["tests"]
|
||||
needs: ["lint"]
|
||||
uses: ./.github/workflows/flamegraph.yaml
|
||||
|
||||
# perf-regression:
|
||||
|
||||
@@ -91,8 +91,10 @@ test: GOFLAGS = $(RACE) -v -timeout 1h -count 1
|
||||
test:
|
||||
$(GOTEST) $(GOFLAGS) ./...
|
||||
|
||||
# Keep this under the GitHub Actions step timeout (50m) so a hung nuclei process
|
||||
# is reported by go test instead of the runner killing the job with no test name.
|
||||
integration:
|
||||
$(GOTEST) -tags=integration -timeout 1h ./internal/tests/integration
|
||||
$(GOTEST) -tags=integration -timeout 40m -v ./internal/tests/integration
|
||||
|
||||
integration-debug:
|
||||
$(GOTEST) -tags=integration ./internal/tests/integration -v $(GO_TEST_ARGS) -args $(INTEGRATION_ARGS)
|
||||
@@ -110,7 +112,7 @@ functional: build
|
||||
exit 1; \
|
||||
fi; \
|
||||
RELEASE_BINARY="$$release_binary" DEV_BINARY="$(PWD)/bin/nuclei" \
|
||||
$(GOTEST) -tags=functional -timeout 1h ./internal/tests/functional
|
||||
$(GOTEST) -tags=functional -timeout 1h -v ./internal/tests/functional
|
||||
|
||||
tidy:
|
||||
$(GOMOD) tidy
|
||||
|
||||
@@ -145,6 +145,8 @@ TARGET:
|
||||
-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
|
||||
-iv, -ip-version string[] IP version to scan of hostname (4,6) - (default 4)
|
||||
-dun, -disable-url-normalization keep targets exactly as supplied instead of collapsing urls that address the same resource
|
||||
-mup, -max-urls-per-pattern int scan at most this many urls sharing one structural shape, e.g. /user/1 and /user/2 (0 to scan all)
|
||||
|
||||
TARGET-FORMAT:
|
||||
-im, -input-mode string mode of input file (list, burp, jsonl, yaml, openapi, swagger, http) (default "list")
|
||||
@@ -173,6 +175,14 @@ TEMPLATES:
|
||||
-esc, -enable-self-contained enable loading self-contained templates
|
||||
-egm, -enable-global-matchers enable loading global matchers templates
|
||||
-file enable loading file templates
|
||||
-llm enable llm matchers and extractors (semantic matching)
|
||||
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together) (default "openai")
|
||||
-llm-base-url string openai-compatible endpoint for semantic matching (overrides -llm-provider, e.g. a local model)
|
||||
-llm-model string model used for semantic matching
|
||||
-llm-timeout int time in seconds to wait for a single llm call (default 30)
|
||||
-llm-max-calls int maximum llm calls per scan (0 for unlimited) (default 5000)
|
||||
-llm-concurrency int maximum concurrent llm calls (default 4)
|
||||
-llm-cache cache llm responses within a scan (default true)
|
||||
|
||||
FILTERING:
|
||||
-a, -author string[] templates to run based on authors (comma-separated, file)
|
||||
@@ -235,7 +245,7 @@ CONFIGURATIONS:
|
||||
-ztls use ztls library with autofallback to standard one for tls13 [Deprecated] autofallback to ztls is enabled by default
|
||||
-sni string tls sni hostname to use (default: input domain name)
|
||||
-dka, -dialer-keep-alive value keep-alive duration for network requests.
|
||||
-lfa, -allow-local-file-access allows file (payload) access anywhere on the system
|
||||
-lfa, -allow-local-file-access allow all templates in the run to read any file on the system (not just payloads)
|
||||
-lna, -restrict-local-network-access blocks connections to the local / private network
|
||||
-i, -interface string network interface to use for network scan
|
||||
-at, -attack-type string type of payload combinations to perform (batteringram,pitchfork,clusterbomb)
|
||||
@@ -264,6 +274,9 @@ FUZZING:
|
||||
-dtr, -dast-report write dast scan report to file
|
||||
-dtst, -dast-server-token string dast server token (optional)
|
||||
-dtsa, -dast-server-address string dast server address (default "localhost:9055")
|
||||
-dtp, -dast-proxy enable dast proxy mode (fuzz live traffic proxied through nuclei)
|
||||
-dtpa, -dast-proxy-address string dast proxy listen address (default "127.0.0.1:9056")
|
||||
-dtpau, -dast-proxy-auth string dast proxy basic auth in user:pass format (required for non-loopback address)
|
||||
-dfp, -display-fuzz-points display fuzz points in the output for debugging
|
||||
-fuzz-param-frequency int frequency of uninteresting parameters for fuzzing before skipping (default 10)
|
||||
-fa, -fuzz-aggression string fuzzing aggression level controls payload count for fuzz (low, medium, high) (default "low")
|
||||
@@ -281,6 +294,8 @@ 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)
|
||||
-host-backoff slow down a host that starts blocking (429, repeated 403s, connection failures)
|
||||
-host-backoff-max value maximum delay applied to a host that is blocking (default 30s)
|
||||
-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)
|
||||
@@ -324,6 +339,7 @@ DEBUG:
|
||||
-dresp, -debug-resp show all received responses
|
||||
-p, -proxy string[] list of http/socks5 proxy to use (comma separated or file input)
|
||||
-pi, -proxy-internal proxy all internal requests
|
||||
-rp, -replay-proxy string http/socks5 proxy to re-send requests that produced a finding through (e.g. burp)
|
||||
-ldf, -list-dsl-function list all supported DSL function signatures
|
||||
-tlog, -trace-log string file to write sent requests trace log
|
||||
-elog, -error-log string file to write sent requests error log
|
||||
|
||||
+12
-1
@@ -173,6 +173,14 @@ TEMPLATES:
|
||||
-esc, -enable-self-contained enable loading self-contained templates
|
||||
-egm, -enable-global-matchers enable loading global matchers templates
|
||||
-file enable loading file templates
|
||||
-llm enable llm matchers and extractors (semantic matching)
|
||||
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together) (default "openai")
|
||||
-llm-base-url string openai-compatible endpoint for semantic matching (overrides -llm-provider, e.g. a local model)
|
||||
-llm-model string model used for semantic matching
|
||||
-llm-timeout int time in seconds to wait for a single llm call (default 30)
|
||||
-llm-max-calls int maximum llm calls per scan (0 for unlimited) (default 5000)
|
||||
-llm-concurrency int maximum concurrent llm calls (default 4)
|
||||
-llm-cache cache llm responses within a scan (default true)
|
||||
|
||||
FILTERING:
|
||||
-a, -author string[] templates to run based on authors (comma-separated, file)
|
||||
@@ -235,7 +243,7 @@ CONFIGURATIONS:
|
||||
-ztls use ztls library with autofallback to standard one for tls13 [Deprecated] autofallback to ztls is enabled by default
|
||||
-sni string tls sni hostname to use (default: input domain name)
|
||||
-dka, -dialer-keep-alive value keep-alive duration for network requests.
|
||||
-lfa, -allow-local-file-access allows file (payload) access anywhere on the system
|
||||
-lfa, -allow-local-file-access allow all templates in the run to read any file on the system (not just payloads)
|
||||
-lna, -restrict-local-network-access blocks connections to the local / private network
|
||||
-i, -interface string network interface to use for network scan
|
||||
-at, -attack-type string type of payload combinations to perform (batteringram,pitchfork,clusterbomb)
|
||||
@@ -264,6 +272,9 @@ FUZZING:
|
||||
-dtr, -dast-report write dast scan report to file
|
||||
-dtst, -dast-server-token string dast server token (optional)
|
||||
-dtsa, -dast-server-address string dast server address (default "localhost:9055")
|
||||
-dtp, -dast-proxy enable dast proxy mode (fuzz live traffic proxied through nuclei)
|
||||
-dtpa, -dast-proxy-address string dast proxy listen address (default "127.0.0.1:9056")
|
||||
-dtpau, -dast-proxy-auth string dast proxy basic auth in user:pass format (required for non-loopback address)
|
||||
-dfp, -display-fuzz-points display fuzz points in the output for debugging
|
||||
-fuzz-param-frequency int frequency of uninteresting parameters for fuzzing before skipping (default 10)
|
||||
-fa, -fuzz-aggression string fuzzing aggression level controls payload count for fuzz (low, medium, high) (default "low")
|
||||
|
||||
+12
-1
@@ -173,6 +173,14 @@ TEMPLATES:
|
||||
-esc, -enable-self-contained enable loading self-contained templates
|
||||
-egm, -enable-global-matchers enable loading global matchers templates
|
||||
-file enable loading file templates
|
||||
-llm enable llm matchers and extractors (semantic matching)
|
||||
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together) (default "openai")
|
||||
-llm-base-url string openai-compatible endpoint for semantic matching (overrides -llm-provider, e.g. a local model)
|
||||
-llm-model string model used for semantic matching
|
||||
-llm-timeout int time in seconds to wait for a single llm call (default 30)
|
||||
-llm-max-calls int maximum llm calls per scan (0 for unlimited) (default 5000)
|
||||
-llm-concurrency int maximum concurrent llm calls (default 4)
|
||||
-llm-cache cache llm responses within a scan (default true)
|
||||
|
||||
FILTERING:
|
||||
-a, -author string[] templates to run based on authors (comma-separated, file)
|
||||
@@ -235,7 +243,7 @@ CONFIGURATIONS:
|
||||
-ztls use ztls library with autofallback to standard one for tls13 [Deprecated] autofallback to ztls is enabled by default
|
||||
-sni string tls sni hostname to use (default: input domain name)
|
||||
-dka, -dialer-keep-alive value keep-alive duration for network requests.
|
||||
-lfa, -allow-local-file-access allows file (payload) access anywhere on the system
|
||||
-lfa, -allow-local-file-access allow all templates in the run to read any file on the system (not just payloads)
|
||||
-lna, -restrict-local-network-access blocks connections to the local / private network
|
||||
-i, -interface string network interface to use for network scan
|
||||
-at, -attack-type string type of payload combinations to perform (batteringram,pitchfork,clusterbomb)
|
||||
@@ -264,6 +272,9 @@ FUZZING:
|
||||
-dtr, -dast-report write dast scan report to file
|
||||
-dtst, -dast-server-token string dast server token (optional)
|
||||
-dtsa, -dast-server-address string dast server address (default "localhost:9055")
|
||||
-dtp, -dast-proxy enable dast proxy mode (fuzz live traffic proxied through nuclei)
|
||||
-dtpa, -dast-proxy-address string dast proxy listen address (default "127.0.0.1:9056")
|
||||
-dtpau, -dast-proxy-auth string dast proxy basic auth in user:pass format (required for non-loopback address)
|
||||
-dfp, -display-fuzz-points display fuzz points in the output for debugging
|
||||
-fuzz-param-frequency int frequency of uninteresting parameters for fuzzing before skipping (default 10)
|
||||
-fa, -fuzz-aggression string fuzzing aggression level controls payload count for fuzz (low, medium, high) (default "low")
|
||||
|
||||
+12
-1
@@ -173,6 +173,14 @@ TEMPLATES:
|
||||
-esc, -enable-self-contained enable loading self-contained templates
|
||||
-egm, -enable-global-matchers enable loading global matchers templates
|
||||
-file enable loading file templates
|
||||
-llm enable llm matchers and extractors (semantic matching)
|
||||
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together) (default "openai")
|
||||
-llm-base-url string openai-compatible endpoint for semantic matching (overrides -llm-provider, e.g. a local model)
|
||||
-llm-model string model used for semantic matching
|
||||
-llm-timeout int time in seconds to wait for a single llm call (default 30)
|
||||
-llm-max-calls int maximum llm calls per scan (0 for unlimited) (default 5000)
|
||||
-llm-concurrency int maximum concurrent llm calls (default 4)
|
||||
-llm-cache cache llm responses within a scan (default true)
|
||||
|
||||
FILTERING:
|
||||
-a, -author string[] templates to run based on authors (comma-separated, file)
|
||||
@@ -235,7 +243,7 @@ CONFIGURATIONS:
|
||||
-ztls use ztls library with autofallback to standard one for tls13 [Deprecated] autofallback to ztls is enabled by default
|
||||
-sni string tls sni hostname to use (default: input domain name)
|
||||
-dka, -dialer-keep-alive value keep-alive duration for network requests.
|
||||
-lfa, -allow-local-file-access allows file (payload) access anywhere on the system
|
||||
-lfa, -allow-local-file-access allow all templates in the run to read any file on the system (not just payloads)
|
||||
-lna, -restrict-local-network-access blocks connections to the local / private network
|
||||
-i, -interface string network interface to use for network scan
|
||||
-at, -attack-type string type of payload combinations to perform (batteringram,pitchfork,clusterbomb)
|
||||
@@ -264,6 +272,9 @@ FUZZING:
|
||||
-dtr, -dast-report write dast scan report to file
|
||||
-dtst, -dast-server-token string dast server token (optional)
|
||||
-dtsa, -dast-server-address string dast server address (default "localhost:9055")
|
||||
-dtp, -dast-proxy enable dast proxy mode (fuzz live traffic proxied through nuclei)
|
||||
-dtpa, -dast-proxy-address string dast proxy listen address (default "127.0.0.1:9056")
|
||||
-dtpau, -dast-proxy-auth string dast proxy basic auth in user:pass format (required for non-loopback address)
|
||||
-dfp, -display-fuzz-points display fuzz points in the output for debugging
|
||||
-fuzz-param-frequency int frequency of uninteresting parameters for fuzzing before skipping (default 10)
|
||||
-fa, -fuzz-aggression string fuzzing aggression level controls payload count for fuzz (low, medium, high) (default "low")
|
||||
|
||||
+12
-1
@@ -173,6 +173,14 @@ TEMPLATES:
|
||||
-esc, -enable-self-contained enable loading self-contained templates
|
||||
-egm, -enable-global-matchers enable loading global matchers templates
|
||||
-file enable loading file templates
|
||||
-llm enable llm matchers and extractors (semantic matching)
|
||||
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together) (default "openai")
|
||||
-llm-base-url string openai-compatible endpoint for semantic matching (overrides -llm-provider, e.g. a local model)
|
||||
-llm-model string model used for semantic matching
|
||||
-llm-timeout int time in seconds to wait for a single llm call (default 30)
|
||||
-llm-max-calls int maximum llm calls per scan (0 for unlimited) (default 5000)
|
||||
-llm-concurrency int maximum concurrent llm calls (default 4)
|
||||
-llm-cache cache llm responses within a scan (default true)
|
||||
|
||||
FILTERING:
|
||||
-a, -author string[] templates to run based on authors (comma-separated, file)
|
||||
@@ -235,7 +243,7 @@ CONFIGURATIONS:
|
||||
-ztls use ztls library with autofallback to standard one for tls13 [Deprecated] autofallback to ztls is enabled by default
|
||||
-sni string tls sni hostname to use (default: input domain name)
|
||||
-dka, -dialer-keep-alive value keep-alive duration for network requests.
|
||||
-lfa, -allow-local-file-access allows file (payload) access anywhere on the system
|
||||
-lfa, -allow-local-file-access allow all templates in the run to read any file on the system (not just payloads)
|
||||
-lna, -restrict-local-network-access blocks connections to the local / private network
|
||||
-i, -interface string network interface to use for network scan
|
||||
-at, -attack-type string type of payload combinations to perform (batteringram,pitchfork,clusterbomb)
|
||||
@@ -264,6 +272,9 @@ FUZZING:
|
||||
-dtr, -dast-report write dast scan report to file
|
||||
-dtst, -dast-server-token string dast server token (optional)
|
||||
-dtsa, -dast-server-address string dast server address (default "localhost:9055")
|
||||
-dtp, -dast-proxy enable dast proxy mode (fuzz live traffic proxied through nuclei)
|
||||
-dtpa, -dast-proxy-address string dast proxy listen address (default "127.0.0.1:9056")
|
||||
-dtpau, -dast-proxy-auth string dast proxy basic auth in user:pass format (required for non-loopback address)
|
||||
-dfp, -display-fuzz-points display fuzz points in the output for debugging
|
||||
-fuzz-param-frequency int frequency of uninteresting parameters for fuzzing before skipping (default 10)
|
||||
-fa, -fuzz-aggression string fuzzing aggression level controls payload count for fuzz (low, medium, high) (default "low")
|
||||
|
||||
+12
-1
@@ -173,6 +173,14 @@ TEMPLATES:
|
||||
-esc, -enable-self-contained enable loading self-contained templates
|
||||
-egm, -enable-global-matchers enable loading global matchers templates
|
||||
-file enable loading file templates
|
||||
-llm enable llm matchers and extractors (semantic matching)
|
||||
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together) (default "openai")
|
||||
-llm-base-url string openai-compatible endpoint for semantic matching (overrides -llm-provider, e.g. a local model)
|
||||
-llm-model string model used for semantic matching
|
||||
-llm-timeout int time in seconds to wait for a single llm call (default 30)
|
||||
-llm-max-calls int maximum llm calls per scan (0 for unlimited) (default 5000)
|
||||
-llm-concurrency int maximum concurrent llm calls (default 4)
|
||||
-llm-cache cache llm responses within a scan (default true)
|
||||
|
||||
FILTERING:
|
||||
-a, -author string[] templates to run based on authors (comma-separated, file)
|
||||
@@ -235,7 +243,7 @@ CONFIGURATIONS:
|
||||
-ztls use ztls library with autofallback to standard one for tls13 [Deprecated] autofallback to ztls is enabled by default
|
||||
-sni string tls sni hostname to use (default: input domain name)
|
||||
-dka, -dialer-keep-alive value keep-alive duration for network requests.
|
||||
-lfa, -allow-local-file-access allows file (payload) access anywhere on the system
|
||||
-lfa, -allow-local-file-access allow all templates in the run to read any file on the system (not just payloads)
|
||||
-lna, -restrict-local-network-access blocks connections to the local / private network
|
||||
-i, -interface string network interface to use for network scan
|
||||
-at, -attack-type string type of payload combinations to perform (batteringram,pitchfork,clusterbomb)
|
||||
@@ -264,6 +272,9 @@ FUZZING:
|
||||
-dtr, -dast-report write dast scan report to file
|
||||
-dtst, -dast-server-token string dast server token (optional)
|
||||
-dtsa, -dast-server-address string dast server address (default "localhost:9055")
|
||||
-dtp, -dast-proxy enable dast proxy mode (fuzz live traffic proxied through nuclei)
|
||||
-dtpa, -dast-proxy-address string dast proxy listen address (default "127.0.0.1:9056")
|
||||
-dtpau, -dast-proxy-auth string dast proxy basic auth in user:pass format (required for non-loopback address)
|
||||
-dfp, -display-fuzz-points display fuzz points in the output for debugging
|
||||
-fuzz-param-frequency int frequency of uninteresting parameters for fuzzing before skipping (default 10)
|
||||
-fa, -fuzz-aggression string fuzzing aggression level controls payload count for fuzz (low, medium, high) (default "low")
|
||||
|
||||
+12
-1
@@ -173,6 +173,14 @@ TEMPLATES:
|
||||
-esc, -enable-self-contained enable loading self-contained templates
|
||||
-egm, -enable-global-matchers enable loading global matchers templates
|
||||
-file enable loading file templates
|
||||
-llm enable llm matchers and extractors (semantic matching)
|
||||
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together) (default "openai")
|
||||
-llm-base-url string openai-compatible endpoint for semantic matching (overrides -llm-provider, e.g. a local model)
|
||||
-llm-model string model used for semantic matching
|
||||
-llm-timeout int time in seconds to wait for a single llm call (default 30)
|
||||
-llm-max-calls int maximum llm calls per scan (0 for unlimited) (default 5000)
|
||||
-llm-concurrency int maximum concurrent llm calls (default 4)
|
||||
-llm-cache cache llm responses within a scan (default true)
|
||||
|
||||
FILTERING:
|
||||
-a, -author string[] templates to run based on authors (comma-separated, file)
|
||||
@@ -235,7 +243,7 @@ CONFIGURATIONS:
|
||||
-ztls use ztls library with autofallback to standard one for tls13 [Deprecated] autofallback to ztls is enabled by default
|
||||
-sni string tls sni hostname to use (default: input domain name)
|
||||
-dka, -dialer-keep-alive value keep-alive duration for network requests.
|
||||
-lfa, -allow-local-file-access allows file (payload) access anywhere on the system
|
||||
-lfa, -allow-local-file-access allow all templates in the run to read any file on the system (not just payloads)
|
||||
-lna, -restrict-local-network-access blocks connections to the local / private network
|
||||
-i, -interface string network interface to use for network scan
|
||||
-at, -attack-type string type of payload combinations to perform (batteringram,pitchfork,clusterbomb)
|
||||
@@ -264,6 +272,9 @@ FUZZING:
|
||||
-dtr, -dast-report write dast scan report to file
|
||||
-dtst, -dast-server-token string dast server token (optional)
|
||||
-dtsa, -dast-server-address string dast server address (default "localhost:9055")
|
||||
-dtp, -dast-proxy enable dast proxy mode (fuzz live traffic proxied through nuclei)
|
||||
-dtpa, -dast-proxy-address string dast proxy listen address (default "127.0.0.1:9056")
|
||||
-dtpau, -dast-proxy-auth string dast proxy basic auth in user:pass format (required for non-loopback address)
|
||||
-dfp, -display-fuzz-points display fuzz points in the output for debugging
|
||||
-fuzz-param-frequency int frequency of uninteresting parameters for fuzzing before skipping (default 10)
|
||||
-fa, -fuzz-aggression string fuzzing aggression level controls payload count for fuzz (low, medium, high) (default "low")
|
||||
|
||||
+12
-1
@@ -173,6 +173,14 @@ TEMPLATES:
|
||||
-esc, -enable-self-contained enable loading self-contained templates
|
||||
-egm, -enable-global-matchers enable loading global matchers templates
|
||||
-file enable loading file templates
|
||||
-llm enable llm matchers and extractors (semantic matching)
|
||||
-llm-provider string llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together) (default "openai")
|
||||
-llm-base-url string openai-compatible endpoint for semantic matching (overrides -llm-provider, e.g. a local model)
|
||||
-llm-model string model used for semantic matching
|
||||
-llm-timeout int time in seconds to wait for a single llm call (default 30)
|
||||
-llm-max-calls int maximum llm calls per scan (0 for unlimited) (default 5000)
|
||||
-llm-concurrency int maximum concurrent llm calls (default 4)
|
||||
-llm-cache cache llm responses within a scan (default true)
|
||||
|
||||
FILTERING:
|
||||
-a, -author string[] templates to run based on authors (comma-separated, file)
|
||||
@@ -235,7 +243,7 @@ CONFIGURATIONS:
|
||||
-ztls use ztls library with autofallback to standard one for tls13 [Deprecated] autofallback to ztls is enabled by default
|
||||
-sni string tls sni hostname to use (default: input domain name)
|
||||
-dka, -dialer-keep-alive value keep-alive duration for network requests.
|
||||
-lfa, -allow-local-file-access allows file (payload) access anywhere on the system
|
||||
-lfa, -allow-local-file-access allow all templates in the run to read any file on the system (not just payloads)
|
||||
-lna, -restrict-local-network-access blocks connections to the local / private network
|
||||
-i, -interface string network interface to use for network scan
|
||||
-at, -attack-type string type of payload combinations to perform (batteringram,pitchfork,clusterbomb)
|
||||
@@ -264,6 +272,9 @@ FUZZING:
|
||||
-dtr, -dast-report write dast scan report to file
|
||||
-dtst, -dast-server-token string dast server token (optional)
|
||||
-dtsa, -dast-server-address string dast server address (default "localhost:9055")
|
||||
-dtp, -dast-proxy enable dast proxy mode (fuzz live traffic proxied through nuclei)
|
||||
-dtpa, -dast-proxy-address string dast proxy listen address (default "127.0.0.1:9056")
|
||||
-dtpau, -dast-proxy-auth string dast proxy basic auth in user:pass format (required for non-loopback address)
|
||||
-dfp, -display-fuzz-points display fuzz points in the output for debugging
|
||||
-fuzz-param-frequency int frequency of uninteresting parameters for fuzzing before skipping (default 10)
|
||||
-fa, -fuzz-aggression string fuzzing aggression level controls payload count for fuzz (low, medium, high) (default "low")
|
||||
|
||||
@@ -2475,6 +2475,136 @@ xpath:
|
||||
```
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="dd">
|
||||
|
||||
<code>prompt</code> <i>string</i>
|
||||
|
||||
</div>
|
||||
<div class="dt">
|
||||
|
||||
Prompt is the natural-language question for an llm matcher. The model
|
||||
judges the selected response part and returns a verdict; the matcher
|
||||
fires when the verdict equals Expect with at least MinConfidence.
|
||||
|
||||
{{...}} placeholders are interpolated from the template variables,
|
||||
-var and target. Response derived values are not interpolated, since
|
||||
the response is attacker controlled.
|
||||
|
||||
|
||||
|
||||
Examples:
|
||||
|
||||
|
||||
```yaml
|
||||
prompt: Is this a working admin login form rather than a marketing page?
|
||||
```
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="dd">
|
||||
|
||||
<code>expect</code> <i>string</i>
|
||||
|
||||
</div>
|
||||
<div class="dt">
|
||||
|
||||
Expect is the verdict that counts as a match for an llm matcher.
|
||||
Defaults to "yes".
|
||||
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="dd">
|
||||
|
||||
<code>options</code> <i>[]string</i>
|
||||
|
||||
</div>
|
||||
<div class="dt">
|
||||
|
||||
Options restricts the model to a fixed set of verdicts for an llm
|
||||
matcher. Defaults to yes/no.
|
||||
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="dd">
|
||||
|
||||
<code>min-confidence</code> <i>float64</i>
|
||||
|
||||
</div>
|
||||
<div class="dt">
|
||||
|
||||
MinConfidence is the minimum confidence (0-1) an llm verdict needs to
|
||||
count as a match. Defaults to 0.
|
||||
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="dd">
|
||||
|
||||
<code>inputs</code> <i>[]string</i>
|
||||
|
||||
</div>
|
||||
<div class="dt">
|
||||
|
||||
Inputs compares several responses instead of a single part, for
|
||||
checks that are about the difference between responses (blind
|
||||
boolean injection, user enumeration, IDOR). Each entry is resolved
|
||||
against the runtime values, so {{body_1}} and {{body_2}} work with
|
||||
req-condition, and each response is framed separately for the model.
|
||||
Part is ignored when Inputs is set.
|
||||
|
||||
|
||||
|
||||
Examples:
|
||||
|
||||
|
||||
```yaml
|
||||
inputs:
|
||||
- '{{body_1}}'
|
||||
- '{{body_2}}'
|
||||
```
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="dd">
|
||||
|
||||
<code>max-input-tokens</code> <i>int</i>
|
||||
|
||||
</div>
|
||||
<div class="dt">
|
||||
|
||||
MaxInputTokens caps how much of the response part is sent to the model
|
||||
for an llm matcher, as an approximate token count.
|
||||
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="dd">
|
||||
|
||||
<code>allow-sole</code> <i>bool</i>
|
||||
|
||||
</div>
|
||||
<div class="dt">
|
||||
|
||||
AllowSole permits an llm matcher to be the only matcher on a high or
|
||||
critical template. Without it such a template is rejected, so a model is
|
||||
never the sole arbiter of a severe finding by accident.
|
||||
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
@@ -2600,6 +2730,8 @@ Enum Values:
|
||||
- <code>dsl</code>
|
||||
|
||||
- <code>xpath</code>
|
||||
|
||||
- <code>llm</code>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
+17
-1
@@ -273,6 +273,8 @@ on extensive configurability, massive extensibility and ease of use.`)
|
||||
flagSet.StringVar(&options.Resume, "resume", "", "resume scan from and save to specified file (clustering will be disabled)"),
|
||||
flagSet.BoolVarP(&options.ScanAllIPs, "scan-all-ips", "sa", false, "scan all the IP's associated with dns record"),
|
||||
flagSet.StringSliceVarP(&options.IPVersion, "ip-version", "iv", nil, "IP version to scan of hostname (4,6) - (default 4)", goflags.CommaSeparatedStringSliceOptions),
|
||||
flagSet.BoolVarP(&options.DisableURLNormalization, "disable-url-normalization", "dun", false, "keep targets exactly as supplied instead of collapsing urls that address the same resource"),
|
||||
flagSet.IntVarP(&options.MaxURLsPerPattern, "max-urls-per-pattern", "mup", 0, "scan at most this many urls sharing one structural shape, e.g. /user/1 and /user/2 (0 to scan all)"),
|
||||
)
|
||||
|
||||
flagSet.CreateGroup("target-format", "Target-Format",
|
||||
@@ -304,6 +306,14 @@ on extensive configurability, massive extensibility and ease of use.`)
|
||||
flagSet.BoolVarP(&options.EnableSelfContainedTemplates, "enable-self-contained", "esc", false, "enable loading self-contained templates"),
|
||||
flagSet.BoolVarP(&options.EnableGlobalMatchersTemplates, "enable-global-matchers", "egm", false, "enable loading global matchers templates"),
|
||||
flagSet.BoolVar(&options.EnableFileTemplates, "file", false, "enable loading file templates"),
|
||||
flagSet.BoolVar(&options.EnableLLM, "llm", false, "enable llm matchers and extractors (semantic matching)"),
|
||||
flagSet.StringVar(&options.LLMProvider, "llm-provider", "openai", "llm provider for semantic matching (openai, ollama, llamacpp, vllm, lmstudio, groq, openrouter, together)"),
|
||||
flagSet.StringVar(&options.LLMBaseURL, "llm-base-url", "", "openai-compatible endpoint for semantic matching (overrides -llm-provider, e.g. a local model)"),
|
||||
flagSet.StringVar(&options.LLMModel, "llm-model", "", "model used for semantic matching"),
|
||||
flagSet.IntVar(&options.LLMTimeout, "llm-timeout", 30, "time in seconds to wait for a single llm call"),
|
||||
flagSet.IntVar(&options.LLMMaxCalls, "llm-max-calls", 5000, "maximum llm calls per scan (0 for unlimited)"),
|
||||
flagSet.IntVar(&options.LLMConcurrency, "llm-concurrency", 4, "maximum concurrent llm calls"),
|
||||
flagSet.BoolVar(&options.LLMCache, "llm-cache", true, "cache llm responses within a scan"),
|
||||
)
|
||||
|
||||
flagSet.CreateGroup("filters", "Filtering",
|
||||
@@ -369,7 +379,7 @@ on extensive configurability, massive extensibility and ease of use.`)
|
||||
flagSet.BoolVar(&options.ZTLS, "ztls", false, "use ztls library with autofallback to standard one for tls13 [Deprecated] autofallback to ztls is enabled by default"), //nolint:all
|
||||
flagSet.StringVar(&options.SNI, "sni", "", "tls sni hostname to use (default: input domain name)"),
|
||||
flagSet.DurationVarP(&options.DialerKeepAlive, "dialer-keep-alive", "dka", 0, "keep-alive duration for network requests."),
|
||||
flagSet.BoolVarP(&options.AllowLocalFileAccess, "allow-local-file-access", "lfa", false, "allows file (payload) access anywhere on the system"),
|
||||
flagSet.BoolVarP(&options.AllowLocalFileAccess, "allow-local-file-access", "lfa", false, "allow all templates in the run to read any file on the system (not just payloads)"),
|
||||
flagSet.BoolVarP(&options.RestrictLocalNetworkAccess, "restrict-local-network-access", "lna", false, "blocks connections to the local / private network"),
|
||||
flagSet.StringVarP(&options.Interface, "interface", "i", "", "network interface to use for network scan"),
|
||||
flagSet.StringVarP(&options.AttackType, "attack-type", "at", "", "type of payload combinations to perform (batteringram,pitchfork,clusterbomb)"),
|
||||
@@ -400,6 +410,9 @@ on extensive configurability, massive extensibility and ease of use.`)
|
||||
flagSet.BoolVarP(&options.DASTReport, "dast-report", "dtr", false, "write dast scan report to file"),
|
||||
flagSet.StringVarP(&options.DASTServerToken, "dast-server-token", "dtst", "", "dast server token (optional)"),
|
||||
flagSet.StringVarP(&options.DASTServerAddress, "dast-server-address", "dtsa", "localhost:9055", "dast server address"),
|
||||
flagSet.BoolVarP(&options.DASTProxy, "dast-proxy", "dtp", false, "enable intercepting dast proxy (CA/stats on -dtsa; POST /fuzz needs -dast-server)"),
|
||||
flagSet.StringVarP(&options.DASTProxyAddress, "dast-proxy-address", "dtpa", "127.0.0.1:9056", "dast proxy listen address"),
|
||||
flagSet.StringVarP(&options.DASTProxyAuth, "dast-proxy-auth", "dtpau", "", "dast proxy basic auth in user:pass format (required for non-loopback address)"),
|
||||
flagSet.BoolVarP(&options.DisplayFuzzPoints, "display-fuzz-points", "dfp", false, "display fuzz points in the output for debugging"),
|
||||
flagSet.IntVar(&options.FuzzParamFrequency, "fuzz-param-frequency", 10, "frequency of uninteresting parameters for fuzzing before skipping"),
|
||||
flagSet.StringVarP(&options.FuzzAggressionLevel, "fuzz-aggression", "fa", "low", "fuzzing aggression level controls payload count for fuzz (low, medium, high)"),
|
||||
@@ -419,6 +432,8 @@ on extensive configurability, massive extensibility and ease of use.`)
|
||||
flagSet.CreateGroup("rate-limit", "Rate-Limit",
|
||||
flagSet.IntVarP(&options.RateLimit, "rate-limit", "rl", 150, "maximum number of requests to send per second"),
|
||||
flagSet.DurationVarP(&options.RateLimitDuration, "rate-limit-duration", "rld", time.Second, "maximum number of requests to send per second"),
|
||||
flagSet.BoolVar(&options.HostBackoff, "host-backoff", false, "slow down a host that starts blocking (429, repeated 403s, connection failures)"),
|
||||
flagSet.DurationVar(&options.HostBackoffMax, "host-backoff-max", 30*time.Second, "maximum delay applied to a host that is blocking"),
|
||||
flagSet.BoolVar(&options.PerHostRateLimit, "per-host-rate-limit", false, "enable per-host rate limiting (global rate limit becomes unlimited when enabled)"),
|
||||
flagSet.IntVarP(&options.RateLimitMinute, "rate-limit-minute", "rlm", 0, "maximum number of requests to send per minute (DEPRECATED)"),
|
||||
flagSet.IntVarP(&options.BulkSize, "bulk-size", "bs", 25, "maximum number of hosts to be analyzed in parallel per template"),
|
||||
@@ -469,6 +484,7 @@ on extensive configurability, massive extensibility and ease of use.`)
|
||||
flagSet.BoolVarP(&options.DebugResponse, "debug-resp", "dresp", false, "show all received responses"),
|
||||
flagSet.StringSliceVarP(&options.Proxy, "proxy", "p", nil, "list of http/socks5 proxy to use (comma separated or file input)", goflags.FileCommaSeparatedStringSliceOptions),
|
||||
flagSet.BoolVarP(&options.ProxyInternal, "proxy-internal", "pi", false, "proxy all internal requests"),
|
||||
flagSet.StringVarP(&options.ReplayProxy, "replay-proxy", "rp", "", "http/socks5 proxy to re-send requests that produced a finding through (e.g. burp)"),
|
||||
flagSet.BoolVarP(&options.ListDslSignatures, "list-dsl-function", "ldf", false, "list all supported DSL function signatures"),
|
||||
flagSet.StringVarP(&options.TraceLogFile, "trace-log", "tlog", "", "file to write sent requests trace log"),
|
||||
flagSet.StringVarP(&options.ErrorLogFile, "error-log", "elog", "", "file to write sent requests error log"),
|
||||
|
||||
@@ -1,280 +0,0 @@
|
||||
# Nuclei Runtime Reuse Milestone 1 Implementation Plan
|
||||
|
||||
> Execute this plan test-first. Do not begin scheduler rechunking until the baseline and worker-runtime gates at the end of this plan pass.
|
||||
|
||||
**Goal:** Remove confirmed repeated parsing/compilation and unbounded per-chunk compiled-cache retention from Aurora's Nuclei execution path, while producing trustworthy before/after timing, allocation, memory, and coverage evidence.
|
||||
|
||||
**Architecture:** A long-lived worker owns the parsed template cache and safe protocol infrastructure. Each concurrent scan execution receives a private parser with a private compiled cache, while sharing only immutable parsed template source. The execution cache is explicitly released at execution completion. Aurora's rate-limited batch loop uses the same ownership model and stops before starting another batch after cancellation.
|
||||
|
||||
**Tech stack:** Go, Nuclei SDK/core, Aurora worker, Prometheus, Go benchmarks, race detector, deterministic local HTTP/TLS fixtures.
|
||||
|
||||
**Design reference:** `docs/superpowers/specs/2026-09-08-aurora-nuclei-performance-design.md` in the Aurora `codex/aurora-nuclei-performance` worktree.
|
||||
|
||||
## Task 1: Add an explicit Nuclei execution-parser lifecycle
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `pkg/templates/parser.go`
|
||||
- Modify: `pkg/templates/parser_purge_test.go`
|
||||
- Modify: `pkg/templates/compile_bench_test.go`
|
||||
|
||||
### Step 1: Write failing lifecycle tests
|
||||
|
||||
Add tests proving that an execution parser:
|
||||
|
||||
- shares the parent's parsed cache;
|
||||
- starts with an empty, private compiled cache;
|
||||
- preserves parser validation/strictness settings;
|
||||
- can purge its compiled cache without purging the parent's parsed cache;
|
||||
- does not expose its compiled entries to a sibling execution parser.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./pkg/templates -run 'TestParser.*Execution|TestParserPurgeCompiled' -count=1
|
||||
```
|
||||
|
||||
Expected: FAIL because the explicit execution-parser and compiled-only purge APIs do not exist.
|
||||
|
||||
### Step 2: Implement the minimum lifecycle API
|
||||
|
||||
Add:
|
||||
|
||||
```go
|
||||
func NewExecutionParser(parent *Parser) *Parser
|
||||
func (p *Parser) PurgeCompiled()
|
||||
```
|
||||
|
||||
`NewExecutionParser` must share only `parsedTemplatesCache`, copy parser behaviour flags, and allocate a new compiled cache. Reject or safely handle a nil parent. `PurgeCompiled` must never clear the shared parsed cache.
|
||||
|
||||
### Step 3: Run focused tests and race tests
|
||||
|
||||
```bash
|
||||
go test ./pkg/templates -run 'TestParser.*Execution|TestParserPurge' -count=1
|
||||
go test -race ./pkg/templates -run 'TestParser.*Execution|TestParserPurge' -count=1
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Step 4: Extend the existing cache benchmark
|
||||
|
||||
Update `BenchmarkParseAcrossEngineLocalCaches` to use the public execution-parser lifecycle. Report allocation and timing results before and after the change without claiming scan-level improvement from this microbenchmark.
|
||||
|
||||
```bash
|
||||
go test ./pkg/templates -run '^$' -bench BenchmarkParseAcrossEngineLocalCaches -benchmem -count=5
|
||||
```
|
||||
|
||||
### Step 5: Commit
|
||||
|
||||
```bash
|
||||
git add pkg/templates/parser.go pkg/templates/parser_purge_test.go pkg/templates/compile_bench_test.go
|
||||
git commit -m "feat(templates): add execution-local parser lifecycle"
|
||||
```
|
||||
|
||||
## Task 2: Make the thread-safe Nuclei SDK reuse parsed templates safely
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `lib/multi.go`
|
||||
- Modify: `lib/multi_internal_test.go`
|
||||
- Modify: `lib/multi_bench_test.go`
|
||||
- Verify: `lib/result_callback_test.go`
|
||||
|
||||
### Step 1: Write failing SDK ownership tests
|
||||
|
||||
Test `createEphemeralObjects` directly and prove:
|
||||
|
||||
- each call receives a distinct execution parser;
|
||||
- both parsers share the base parsed cache but not a compiled cache;
|
||||
- caching is enabled within that isolated execution;
|
||||
- closing ephemeral objects clears that execution's compiled cache;
|
||||
- base and sibling caches remain intact;
|
||||
- concurrent per-call output callbacks remain isolated.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./lib -run 'TestEphemeral.*Parser|TestThreadSafe.*Callback' -count=1
|
||||
```
|
||||
|
||||
Expected: FAIL because the current code reuses the base parser and sets `DoNotCache=true`.
|
||||
|
||||
### Step 2: Implement execution-local parser ownership
|
||||
|
||||
Create an execution parser from the long-lived engine parser in `createEphemeralObjects`, store its ownership in `unsafeOptions`, set it on `ExecutorOptions`, and allow caching inside the private execution cache. In `closeEphemeralObjects`, purge only that private compiled cache before removing inherited references.
|
||||
|
||||
Do not share compiled request objects across concurrent executions. Do not move output writers, credentials, variables, global matchers, workflow state, or rate limiters into the shared parser.
|
||||
|
||||
### Step 3: Run correctness and race tests
|
||||
|
||||
```bash
|
||||
go test ./lib -run 'TestEphemeral.*Parser|TestThreadSafe|TestResult' -count=1
|
||||
go test -race ./lib -run 'TestEphemeral.*Parser|TestThreadSafe|TestResult' -count=1
|
||||
```
|
||||
|
||||
Expected: PASS with no race reports.
|
||||
|
||||
### Step 4: Capture SDK benchmark evidence
|
||||
|
||||
Run the existing repeated-execution benchmark on the parent revision and candidate revision with identical fixture and machine settings:
|
||||
|
||||
```bash
|
||||
go test ./lib -run '^$' -bench BenchmarkExecuteNucleiWithOptsCtx -benchmem -count=10
|
||||
```
|
||||
|
||||
Compare with `benchstat`. Record sec/op, B/op, and allocs/op. This is an SDK setup/execute benchmark, not yet an Aurora end-to-end result.
|
||||
|
||||
### Step 5: Commit
|
||||
|
||||
```bash
|
||||
git add lib/multi.go lib/multi_internal_test.go lib/multi_bench_test.go
|
||||
git commit -m "perf(lib): reuse parsed templates across isolated executions"
|
||||
```
|
||||
|
||||
## Task 3: Add Aurora phase and terminal-reason observability
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `internal/agent/metrics/metrics.go`
|
||||
- Modify: `internal/agent/nuclei/nuclei.go`
|
||||
- Modify: `internal/agent/tasks.go`
|
||||
- Modify: `internal/agent/tasks_test.go` or the nearest existing scan-settlement test
|
||||
- Add: `internal/agent/nuclei/execution_metrics_test.go`
|
||||
|
||||
### Step 1: Write failing metric/terminal-reason tests
|
||||
|
||||
Tests must distinguish:
|
||||
|
||||
- Aurora chunk execution deadline;
|
||||
- outer worker lifecycle timeout/cancellation;
|
||||
- Nuclei core/batch deadline;
|
||||
- target network timeout counted as an executed scan attempt rather than an orchestration timeout;
|
||||
- normal completion and non-deadline execution failure.
|
||||
|
||||
Avoid new high-cardinality labels such as scan ID, target, or template ID.
|
||||
|
||||
### Step 2: Add bounded-cardinality metrics
|
||||
|
||||
Add counters/histograms for terminal reason and phase duration using fixed labels such as stream, phase, and reason. Time at least template discovery/load, compile/load-store, execute, output flush, and cleanup. Emit the same reason in structured logs.
|
||||
|
||||
### Step 3: Verify
|
||||
|
||||
```bash
|
||||
GOWORK=off go test ./internal/agent/nuclei ./internal/agent -run 'Test.*ExecutionMetric|Test.*Settlement|Test.*Deadline' -count=1
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Step 4: Commit
|
||||
|
||||
```bash
|
||||
git add internal/agent/metrics/metrics.go internal/agent/nuclei internal/agent/tasks.go internal/agent/*test.go
|
||||
git commit -m "feat(agent): expose nuclei execution phases and terminal reasons"
|
||||
```
|
||||
|
||||
## Task 4: Bound Aurora rate-limited batch memory and honor cancellation
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `internal/agent/nuclei/nuclei.go`
|
||||
- Add: `internal/agent/nuclei/rate_limited_batches_test.go`
|
||||
|
||||
### Step 1: Extract a testable sequential batch executor
|
||||
|
||||
Extract only the rate-limited batch loop behind a small internal function. Pass the loader/engine execution seam as a function so unit tests do not need live network services or a full Nuclei corpus.
|
||||
|
||||
### Step 2: Write failing behavioural tests
|
||||
|
||||
Cover only the confirmed boundaries:
|
||||
|
||||
- cancellation before a batch prevents that batch and every later batch from loading or executing;
|
||||
- cancellation during a batch prevents the next batch from starting;
|
||||
- each batch gets a private compiled cache backed by the process parsed cache;
|
||||
- the previous batch compiled cache is empty/released before the next batch starts;
|
||||
- every template path appears exactly once when no cancellation occurs;
|
||||
- private templates/workflows retain the existing execute-once semantics.
|
||||
|
||||
### Step 3: Implement the bounded lifecycle
|
||||
|
||||
Before each batch, check `context.Cause(ctx)`. Create an execution parser with a private compiled cache for the batch, execute it, and explicitly purge the compiled cache on every exit path. Remove forced `runtime.GC()` from the hot loop only after the lifecycle tests prove cached objects are no longer retained.
|
||||
|
||||
Keep the current batch size initially. Changing batch size and cache ownership simultaneously would make the performance result ambiguous.
|
||||
|
||||
### Step 4: Verify
|
||||
|
||||
```bash
|
||||
GOWORK=off go test ./internal/agent/nuclei -run 'TestRateLimitedBatch' -count=1
|
||||
GOWORK=off go test -race ./internal/agent/nuclei -run 'TestRateLimitedBatch' -count=1
|
||||
```
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
### Step 5: Commit
|
||||
|
||||
```bash
|
||||
git add internal/agent/nuclei/nuclei.go internal/agent/nuclei/rate_limited_batches_test.go
|
||||
git commit -m "fix(agent): bound nuclei batch cache lifetime"
|
||||
```
|
||||
|
||||
## Task 5: Build the deterministic milestone benchmark
|
||||
|
||||
**Files:**
|
||||
|
||||
- Add: `internal/agent/nuclei/tests/perfserver/server.go`
|
||||
- Add: `internal/agent/nuclei/runtime_benchmark_test.go`
|
||||
- Add: `scripts/scan-performance/run-milestone-1.sh`
|
||||
- Add: `docs/benchmarks/aurora-nuclei-milestone-1.md`
|
||||
|
||||
### Step 1: Implement deterministic fixtures
|
||||
|
||||
Provide fixed fast, 100-ms, 500-ms, timeout, connection-reset, TLS, redirect, and multi-request endpoints. Track accepted connections and request identities. Use fixed seeds and local-only addresses.
|
||||
|
||||
### Step 2: Define the milestone coverage oracle
|
||||
|
||||
For this milestone, generate a seeded template/target manifest and compare expected server-observed attempts with worker terminal accounting. A target network timeout counts as a terminal attempt; missing or duplicated attempts fail. Full workflow/payload canonical-ledger support remains Workstream 1's next milestone and is not claimed here.
|
||||
|
||||
### Step 3: Capture baseline and candidate
|
||||
|
||||
Run at least five cold and ten warm repetitions using the same rate limit, template set, and concurrency. Capture:
|
||||
|
||||
- complete/missing/duplicate attempts;
|
||||
- full-run wall time and attempts/second;
|
||||
- process CPU time;
|
||||
- peak RSS and RSS byte-seconds;
|
||||
- heap allocations and GC count/pause;
|
||||
- connections created and reused;
|
||||
- compiled/parsed cache counts after each batch and run;
|
||||
- Aurora versus target timeout reasons.
|
||||
|
||||
### Step 4: Apply the milestone gate
|
||||
|
||||
Proceed to deadline-fit scheduler work only if:
|
||||
|
||||
- the candidate has zero missing or duplicate attempts;
|
||||
- no Aurora/core deadline expires in the deterministic matrix;
|
||||
- repeated-run compiled-cache size is bounded by the active batch/execution;
|
||||
- wall time and attempts/second do not regress;
|
||||
- CPU-seconds and RSS byte-seconds improve.
|
||||
|
||||
### Step 5: Commit evidence
|
||||
|
||||
```bash
|
||||
git add internal/agent/nuclei/tests/perfserver internal/agent/nuclei/runtime_benchmark_test.go scripts/scan-performance docs/benchmarks/aurora-nuclei-milestone-1.md
|
||||
git commit -m "test(agent): add nuclei runtime performance benchmark"
|
||||
```
|
||||
|
||||
## Task 6: Milestone verification and handoff
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
go test ./pkg/templates ./lib -count=1
|
||||
go test -race ./pkg/templates ./lib -count=1
|
||||
GOWORK=off go test ./internal/agent/nuclei ./internal/agent -count=1
|
||||
GOWORK=off go test -race ./internal/agent/nuclei -count=1
|
||||
```
|
||||
|
||||
Then run the deterministic benchmark and record the exact commands, revisions, machine limits, and raw summary in the benchmark document.
|
||||
|
||||
Do not claim the overall zero-deadline goal at this milestone. The milestone claim is limited to safe runtime reuse, bounded batch memory, cancellation correctness, and demonstrated local performance. The next implementation plan covers canonical attempt accounting, deadline-fit chunking, capacity-aware admission, and work-based fleet sizing.
|
||||
|
||||
@@ -1,46 +1,11 @@
|
||||
module github.com/projectdiscovery/nuclei/v3
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/andygrunwald/go-jira v1.16.1
|
||||
github.com/antchfx/htmlquery v1.3.5
|
||||
github.com/go-playground/validator/v10 v10.26.0
|
||||
github.com/go-rod/rod v0.116.2
|
||||
github.com/gobwas/ws v1.4.0
|
||||
github.com/invopop/jsonschema v0.13.0
|
||||
github.com/itchyny/gojq v0.12.17
|
||||
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.72
|
||||
github.com/olekukonko/tablewriter v1.0.8
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/projectdiscovery/clistats v0.1.6
|
||||
github.com/projectdiscovery/fastdialer v0.5.19
|
||||
github.com/projectdiscovery/hmap v0.0.102
|
||||
github.com/projectdiscovery/interactsh v1.3.1
|
||||
github.com/projectdiscovery/rawhttp v0.1.92
|
||||
github.com/projectdiscovery/retryabledns v1.0.116
|
||||
github.com/projectdiscovery/retryablehttp-go v1.3.25
|
||||
github.com/projectdiscovery/yamldoc-go v1.0.7
|
||||
github.com/remeh/sizedwaitgroup v1.0.0
|
||||
github.com/rs/xid v1.6.0
|
||||
github.com/segmentio/ksuid v1.0.4
|
||||
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||
github.com/spf13/cast v1.10.0
|
||||
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.58.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/text v0.41.0
|
||||
)
|
||||
go 1.27.1
|
||||
|
||||
require (
|
||||
carvel.dev/ytt v0.52.0
|
||||
code.gitea.io/sdk/gitea v0.17.0
|
||||
filippo.io/age v1.2.0
|
||||
filippo.io/age v1.2.1
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1
|
||||
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.1.0
|
||||
github.com/Azure/go-ntlmssp v0.1.1
|
||||
@@ -48,11 +13,13 @@ require (
|
||||
github.com/FalconOpsLLC/goexec v0.3.0
|
||||
github.com/Masterminds/semver/v3 v3.5.0
|
||||
github.com/Mzack9999/go-rsync v0.0.0-20250821180103-81ffa574ef4d
|
||||
github.com/Mzack9999/goimpacket v0.0.0-20260422121140-7085336a0415
|
||||
github.com/RedTeamPentesting/adauth v0.5.4-0.20260511073005-3d18e8a5a687
|
||||
github.com/adrg/xdg v0.5.3
|
||||
github.com/alecthomas/chroma v0.10.0
|
||||
github.com/alexsnet/go-vnc v0.1.0
|
||||
github.com/alitto/pond v1.9.2
|
||||
github.com/andygrunwald/go-jira v1.16.1
|
||||
github.com/antchfx/htmlquery v1.3.5
|
||||
github.com/antchfx/xmlquery v1.4.4
|
||||
github.com/antchfx/xpath v1.3.6
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5
|
||||
@@ -60,29 +27,39 @@ require (
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.20
|
||||
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.2
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0
|
||||
github.com/bytedance/sonic v1.15.0
|
||||
github.com/bytedance/sonic v1.15.3
|
||||
github.com/cespare/xxhash v1.1.0
|
||||
github.com/charmbracelet/glamour v0.10.0
|
||||
github.com/clbanning/mxj/v2 v2.7.0
|
||||
github.com/ditashi/jsbeautifier-go v0.0.0-20141206144643-2520a8026a9c
|
||||
github.com/docker/go-units v0.5.0
|
||||
github.com/elazarl/goproxy v1.7.2
|
||||
github.com/fatih/structs v1.1.0
|
||||
github.com/fullstorydev/grpcurl v1.9.3
|
||||
github.com/getkin/kin-openapi v0.144.0
|
||||
github.com/getsops/sops/v3 v3.9.2
|
||||
github.com/go-echarts/go-echarts/v2 v2.6.0
|
||||
github.com/go-git/go-git/v5 v5.19.2
|
||||
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-playground/validator/v10 v10.26.0
|
||||
github.com/go-rod/rod v0.116.2
|
||||
github.com/go-sql-driver/mysql v1.10.0
|
||||
github.com/gobwas/ws v1.4.0
|
||||
github.com/goccy/go-json v0.10.5
|
||||
github.com/google/go-github/v30 v30.1.0
|
||||
github.com/google/go-github/v92 v92.0.0
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/graphql-go/graphql v0.8.1
|
||||
github.com/h2non/filetype v1.1.3
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7
|
||||
github.com/hdm/jarm-go v0.0.8
|
||||
github.com/invopop/jsonschema v0.13.0
|
||||
github.com/itchyny/gojq v0.12.17
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4
|
||||
github.com/jhump/protoreflect v1.17.0
|
||||
github.com/julienschmidt/httprouter v1.3.0
|
||||
github.com/kitabisa/go-ci v1.0.3
|
||||
github.com/leslie-qiwa/flat v0.0.0-20230424180412-f9d1cf014baa
|
||||
github.com/lib/pq v1.12.3
|
||||
@@ -91,45 +68,72 @@ require (
|
||||
github.com/maypok86/otter/v2 v2.2.1
|
||||
github.com/mholt/archives v0.1.5
|
||||
github.com/microsoft/go-mssqldb v1.9.2
|
||||
github.com/miekg/dns v1.1.73
|
||||
github.com/oiweiwei/go-msrpc v1.2.12
|
||||
github.com/olekukonko/tablewriter v1.0.8
|
||||
github.com/ory/dockertest/v3 v3.12.0
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/praetorian-inc/fingerprintx v1.1.15
|
||||
github.com/projectdiscovery/dsl v0.8.22
|
||||
github.com/projectdiscovery/clistats v0.1.7
|
||||
github.com/projectdiscovery/dsl v0.8.24
|
||||
github.com/projectdiscovery/fastdialer v0.5.26
|
||||
github.com/projectdiscovery/fasttemplate v0.0.2
|
||||
github.com/projectdiscovery/gcache v0.0.0-20241015120333-12546c6e3f4c
|
||||
github.com/projectdiscovery/goflags v0.2.0
|
||||
github.com/projectdiscovery/goja v0.0.0-20260618133720-acb73e419534
|
||||
github.com/projectdiscovery/goflags v0.2.1
|
||||
github.com/projectdiscovery/goimpacket v0.1.1-0.20261007205806-6042a8213eeb
|
||||
github.com/projectdiscovery/goja v0.0.0-20261012075649-9b78f2e05b9c
|
||||
github.com/projectdiscovery/goja_nodejs v0.0.0-20260618132410-8519f75f703d
|
||||
github.com/projectdiscovery/gologger v1.1.72
|
||||
github.com/projectdiscovery/gologger v1.1.73
|
||||
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.11.0
|
||||
github.com/projectdiscovery/hmap v0.0.102
|
||||
github.com/projectdiscovery/httpx v1.12.0
|
||||
github.com/projectdiscovery/interactsh v1.4.1
|
||||
github.com/projectdiscovery/mapcidr v1.1.97
|
||||
github.com/projectdiscovery/n3iwf v0.0.0-20230523120440-b8cd232ff1f5
|
||||
github.com/projectdiscovery/networkpolicy v0.1.49
|
||||
github.com/projectdiscovery/ratelimit v0.0.88
|
||||
github.com/projectdiscovery/networkpolicy v0.1.55
|
||||
github.com/projectdiscovery/ratelimit v0.0.94
|
||||
github.com/projectdiscovery/rawhttp v0.2.0
|
||||
github.com/projectdiscovery/rdap v0.9.0
|
||||
github.com/projectdiscovery/retryabledns v1.0.116
|
||||
github.com/projectdiscovery/retryablehttp-go v1.3.31
|
||||
github.com/projectdiscovery/sarif v0.1.0
|
||||
github.com/projectdiscovery/tlsx v1.3.2
|
||||
github.com/projectdiscovery/tlsx v1.4.3-0.20261020052910-acb80c2c7c75
|
||||
github.com/projectdiscovery/uncover v1.2.1
|
||||
github.com/projectdiscovery/useragent v0.0.109
|
||||
github.com/projectdiscovery/utils v0.11.3
|
||||
github.com/projectdiscovery/wappalyzergo v0.2.96
|
||||
github.com/redis/go-redis/v9 v9.11.0
|
||||
github.com/projectdiscovery/utils v0.11.11-0.20261020211236-cde16c1381ec
|
||||
github.com/projectdiscovery/wappalyzergo v0.3.5
|
||||
github.com/projectdiscovery/yamldoc-go v1.0.7
|
||||
github.com/redis/go-redis/v9 v9.19.0
|
||||
github.com/remeh/sizedwaitgroup v1.0.0
|
||||
github.com/rs/xid v1.6.0
|
||||
github.com/rs/zerolog v1.34.0
|
||||
github.com/sandrolain/httpcache v1.4.0
|
||||
github.com/segmentio/ksuid v1.0.4
|
||||
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466
|
||||
github.com/sijms/go-ora/v2 v2.9.0
|
||||
github.com/spf13/cast v1.10.0
|
||||
github.com/stretchr/testify v1.12.1
|
||||
github.com/syndtr/goleveldb v1.0.0
|
||||
github.com/tarunKoyalwar/goleak v0.0.0-20240429141123-0efa90dbdcf9
|
||||
github.com/trivago/tgo v1.0.7
|
||||
github.com/yassinebenaid/godump v0.11.1
|
||||
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.22.0
|
||||
golang.org/x/term v0.45.0
|
||||
google.golang.org/grpc v1.83.1
|
||||
go.uber.org/multierr v1.11.0
|
||||
golang.org/x/exp v0.0.0-20260527015227-08cc5374adb3
|
||||
golang.org/x/net v0.59.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
golang.org/x/sync v0.23.0
|
||||
golang.org/x/sys v0.48.0
|
||||
golang.org/x/term v0.46.0
|
||||
golang.org/x/text v0.42.0
|
||||
golang.org/x/time v0.15.0
|
||||
golang.org/x/tools v0.49.0
|
||||
google.golang.org/grpc v1.83.2
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
moul.io/http2curl v1.0.0
|
||||
)
|
||||
@@ -148,7 +152,6 @@ require (
|
||||
cloud.google.com/go/storage v1.56.0 // indirect
|
||||
dario.cat/mergo v1.0.2 // 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
|
||||
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.3.1 // indirect
|
||||
@@ -161,9 +164,10 @@ require (
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/Mzack9999/gcache v0.0.0-20230410081825-519e28eab057 // indirect
|
||||
github.com/Mzack9999/go-http-digest-auth-client v0.6.1-0.20220414142836-eb8883508809 // indirect
|
||||
github.com/Mzack9999/goimpacket v0.0.0-20260420131935-a9fe473cda7d // indirect
|
||||
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.1.6 // indirect
|
||||
github.com/PuerkitoBio/goquery v1.12.0 // indirect
|
||||
github.com/PuerkitoBio/goquery v1.13.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
|
||||
@@ -172,7 +176,7 @@ require (
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect
|
||||
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/andybalholm/cascadia v1.3.4 // indirect
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.12 // indirect
|
||||
@@ -202,9 +206,9 @@ require (
|
||||
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
|
||||
github.com/caddyserver/certmagic v0.25.0 // indirect
|
||||
github.com/caddyserver/zerossl v0.1.3 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.2 // indirect
|
||||
github.com/caddyserver/certmagic v0.25.3 // indirect
|
||||
github.com/caddyserver/zerossl v0.1.5 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/censys/censys-sdk-go v0.19.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
@@ -215,7 +219,8 @@ require (
|
||||
github.com/charmbracelet/x/exp/slice v0.0.0-20250908092851-c2208eb08494 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/cheggaaa/pb/v3 v3.1.7 // indirect
|
||||
github.com/cloudflare/cfssl v1.6.4 // indirect
|
||||
github.com/chzyer/readline v1.5.1 // indirect
|
||||
github.com/cloudflare/cfssl v1.6.5 // 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
|
||||
@@ -226,7 +231,7 @@ require (
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
|
||||
github.com/davidmz/go-pageant v1.0.2 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dimchansky/utfbom v1.1.1 // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/djherbis/times v1.6.0 // indirect
|
||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||
@@ -237,6 +242,8 @@ require (
|
||||
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/ebitengine/purego v0.10.0 // indirect
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||
github.com/emersion/go-smtp v0.24.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
|
||||
@@ -246,7 +253,7 @@ require (
|
||||
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.29.0 // indirect
|
||||
github.com/gaissmai/bart v0.30.0 // indirect
|
||||
github.com/geoffgarside/ber v1.2.0 // indirect
|
||||
github.com/getsops/gopgagent v0.0.0-20240527072608-0c14999532fe // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
@@ -266,14 +273,19 @@ require (
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/goburrow/cache v0.1.4 // indirect
|
||||
github.com/gobwas/httphead v0.1.0 // indirect
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
|
||||
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/certificate-transparency-go v1.3.3 // indirect
|
||||
github.com/google/go-cmp v0.7.0 // indirect
|
||||
github.com/google/go-querystring v1.2.0 // indirect
|
||||
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect
|
||||
github.com/google/s2a-go v0.1.9 // indirect
|
||||
github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect
|
||||
@@ -295,7 +307,6 @@ require (
|
||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
||||
github.com/hashicorp/vault/api v1.15.0 // indirect
|
||||
github.com/hbakhtiyor/strsim v0.0.0-20190107154042-4d2bbb273edf // 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
|
||||
@@ -307,6 +318,7 @@ 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/json-iterator/go v1.1.12 // 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
|
||||
@@ -317,6 +329,7 @@ require (
|
||||
github.com/kylelemons/godebug v1.1.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/libdns/libdns v1.1.1 // indirect
|
||||
github.com/logrusorgru/aurora v2.0.3+incompatible // indirect
|
||||
github.com/lor00x/goldap v0.0.0-20240304151906-8d785c64d1c8 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
|
||||
@@ -325,7 +338,7 @@ require (
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.22 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/mholt/acmez/v3 v3.1.3 // indirect
|
||||
github.com/mholt/acmez/v3 v3.1.6 // indirect
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
|
||||
github.com/mikelolasagasti/xz v1.0.1 // indirect
|
||||
github.com/minio/minlz v1.0.1 // indirect
|
||||
@@ -362,21 +375,24 @@ require (
|
||||
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.2 // indirect
|
||||
github.com/projectdiscovery/cdncheck v1.2.51 // indirect
|
||||
github.com/projectdiscovery/cdncheck v1.3.2 // indirect
|
||||
github.com/projectdiscovery/freeport v0.0.7 // indirect
|
||||
github.com/projectdiscovery/jarm-go v0.0.0-20260910160638-430e2bae86f6 // indirect
|
||||
github.com/projectdiscovery/ldapserver v1.0.2-0.20240219154113-dcc758ebc0cb // indirect
|
||||
github.com/projectdiscovery/machineid v0.0.0-20250715113114-c77eb3567582 // indirect
|
||||
github.com/refraction-networking/utls v1.8.2 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
github.com/ryanuber/go-glob v1.0.0 // indirect
|
||||
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // 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/sergi/go-diff v1.4.0 // indirect
|
||||
github.com/shirou/gopsutil v3.21.11+incompatible // indirect
|
||||
github.com/shirou/gopsutil/v4 v4.26.3 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/sirupsen/logrus v1.10.2 // indirect
|
||||
github.com/skeema/knownhosts v1.3.1 // indirect
|
||||
github.com/sorairolake/lzip-go v0.3.8 // indirect
|
||||
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/spiffe/go-spiffe/v2 v2.7.0 // indirect
|
||||
@@ -402,6 +418,7 @@ require (
|
||||
github.com/vmihailenco/tagparser v0.1.2 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
github.com/vulncheck-oss/go-exploit v1.51.0 // indirect
|
||||
github.com/weppos/publicsuffix-go v0.50.4-0.20260918082853-3c3d7dc4a0cb // indirect
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
|
||||
@@ -413,11 +430,17 @@ require (
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
github.com/ysmood/fetchup v0.2.3 // indirect
|
||||
github.com/ysmood/goob v0.4.0 // indirect
|
||||
github.com/ysmood/got v0.40.0 // indirect
|
||||
github.com/ysmood/gson v0.7.3 // indirect
|
||||
github.com/ysmood/leakless v0.9.0 // indirect
|
||||
github.com/yuin/goldmark v1.7.17 // indirect
|
||||
github.com/yuin/goldmark-emoji v1.0.6 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
github.com/zcalusic/sysinfo v1.1.3 // indirect
|
||||
github.com/zeebo/blake3 v0.2.4 // indirect
|
||||
github.com/zmap/zcrypto v0.0.0-20261015050653-aa1de3126e87 // indirect
|
||||
go.etcd.io/bbolt v1.4.3 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect
|
||||
@@ -427,54 +450,26 @@ require (
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.44.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/zap v1.27.1 // indirect
|
||||
go.uber.org/zap/exp v0.3.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
go4.org v0.0.0-20230225012048-214862532bf5 // indirect
|
||||
goftp.io/server/v2 v2.0.1 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/crypto v0.57.0 // indirect
|
||||
golang.org/x/mod v0.41.0 // indirect
|
||||
google.golang.org/api v0.264.0 // indirect
|
||||
google.golang.org/genproto v0.0.0-20260128011058-8636f8732409 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6 // indirect
|
||||
gopkg.in/corvus-ch/zbase32.v1 v1.0.0 // indirect
|
||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
mellium.im/sasl v0.3.2 // indirect
|
||||
software.sslmate.com/src/go-pkcs12 v0.7.2 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dimchansky/utfbom v1.1.1 // indirect
|
||||
github.com/goburrow/cache v0.1.4 // indirect
|
||||
github.com/gobwas/httphead v0.1.0 // indirect
|
||||
github.com/gobwas/pool v0.2.1 // indirect
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
|
||||
github.com/google/go-querystring v1.2.0 // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect
|
||||
github.com/trivago/tgo v1.0.7
|
||||
github.com/ysmood/goob v0.4.0 // indirect
|
||||
github.com/ysmood/gson v0.7.3 // indirect
|
||||
github.com/ysmood/leakless v0.9.0 // indirect
|
||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||
github.com/zmap/rc2 v0.0.0-20190804163417-abaa70531248 // indirect
|
||||
github.com/zmap/zcrypto v0.0.0-20240803002437-3a861682ac77 // indirect
|
||||
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.55.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260527015227-08cc5374adb3
|
||||
golang.org/x/mod v0.39.0 // indirect
|
||||
golang.org/x/sys v0.47.0
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
golang.org/x/tools v0.49.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
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/alecthomas/chroma v0.10.0
|
||||
github.com/go-echarts/go-echarts/v2 v2.6.0
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
)
|
||||
|
||||
// https://go.dev/ref/mod#go-mod-file-retract
|
||||
retract v3.2.0 // retract due to broken js protocol issue
|
||||
@@ -64,15 +64,15 @@ cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U
|
||||
cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s=
|
||||
code.gitea.io/sdk/gitea v0.17.0 h1:8JPBss4+Jf7AE1YcfyiGrngTXE8dFSG3si/bypsTH34=
|
||||
code.gitea.io/sdk/gitea v0.17.0/go.mod h1:ndkDk99BnfiUCCYEUhpNzi0lpmApXlwRFqClBlOlEBg=
|
||||
code.pfad.fr/check v1.1.0 h1:GWvjdzhSEgHvEHe2uJujDcpmZoySKuHQNrZMfzfO0bE=
|
||||
code.pfad.fr/check v1.1.0/go.mod h1:NiUH13DtYsb7xp5wll0U4SXx7KhXQVCtRgdC96IPfoM=
|
||||
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/age v1.2.0 h1:vRDp7pUMaAJzXNIWJVAZnEf/Dyi4Vu4wI8S1LBzufhE=
|
||||
filippo.io/age v1.2.0/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004=
|
||||
filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o=
|
||||
filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004=
|
||||
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=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0/go.mod h1:Ot/6aikWnKWi4l9QB7qVSwa8iMphQNqkWALMoNT3rzM=
|
||||
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 h1:B+blDbyVIG3WaikNxPnhPiJ1MThR03b3vKGtER95TP4=
|
||||
@@ -124,17 +124,16 @@ github.com/Mzack9999/go-http-digest-auth-client v0.6.1-0.20220414142836-eb888350
|
||||
github.com/Mzack9999/go-http-digest-auth-client v0.6.1-0.20220414142836-eb8883508809/go.mod h1:upgc3Zs45jBDnBT4tVRgRcgm26ABpaP7MoTSdgysca4=
|
||||
github.com/Mzack9999/go-rsync v0.0.0-20250821180103-81ffa574ef4d h1:DofPB5AcjTnOU538A/YD86/dfqSNTvQsAXgwagxmpu4=
|
||||
github.com/Mzack9999/go-rsync v0.0.0-20250821180103-81ffa574ef4d/go.mod h1:uzdh/m6XQJI7qRvufeBPDa+lj5SVCJO8B9eLxTbtI5U=
|
||||
github.com/Mzack9999/goimpacket v0.0.0-20260422121140-7085336a0415 h1:lpSZPcbowbxvKFaFvE1reLTCStezWXcRVk0zzBtUatg=
|
||||
github.com/Mzack9999/goimpacket v0.0.0-20260422121140-7085336a0415/go.mod h1:Wvb2f1Aq6NVL4Fza/dPNxv6/canpeizpgmiTCBGMD50=
|
||||
github.com/Mzack9999/goimpacket v0.0.0-20260420131935-a9fe473cda7d h1:xX93NZHxxrHcmN4nn1owvJdr1E/l1kp1Rv7rMr9ly+U=
|
||||
github.com/Mzack9999/goimpacket v0.0.0-20260420131935-a9fe473cda7d/go.mod h1:Wvb2f1Aq6NVL4Fza/dPNxv6/canpeizpgmiTCBGMD50=
|
||||
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw=
|
||||
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk=
|
||||
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
|
||||
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
||||
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.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
|
||||
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
|
||||
github.com/PuerkitoBio/goquery v1.13.0 h1:mqHbjD7Jmnul4DTR24LKTjo1uUmHUh072kteGV+xpFM=
|
||||
github.com/PuerkitoBio/goquery v1.13.0/go.mod h1:Hip5mdBL8K2wEGKJdr27sRaNwIdDajmCwB/ExUPwW+g=
|
||||
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=
|
||||
@@ -169,12 +168,14 @@ github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktp
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/alexsnet/go-vnc v0.1.0 h1:vBCwPNy79WEL8V/Z5A0ngEFCvTWBAjmS048lkR2rdmY=
|
||||
github.com/alexsnet/go-vnc v0.1.0/go.mod h1:bbRsg41Sh3zvrnWsw+REKJVGZd8Of2+S0V1G0ZaBhlU=
|
||||
github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw=
|
||||
github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
|
||||
github.com/alitto/pond v1.9.2 h1:9Qb75z/scEZVCoSU+osVmQ0I0JOeLfdTDafrbcJ8CLs=
|
||||
github.com/alitto/pond v1.9.2/go.mod h1:xQn3P/sHTYcU/1BR3i86IGIrilcrGC2LiS+E2+CJWsI=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/andybalholm/cascadia v1.3.4 h1:vM2lgh0Vru9Vwyfm4cQqWP2HHMW0u0+2PAW7Q38Qufg=
|
||||
github.com/andybalholm/cascadia v1.3.4/go.mod h1:BLRmbRjpEtNKieZOCCvYj4RqN+KRA41GBe/5O+G93kM=
|
||||
github.com/andygrunwald/go-jira v1.16.1 h1:WoQEar5XoDRAibOgKzTFELlPNlKAtnfWr296R9zdFLA=
|
||||
github.com/andygrunwald/go-jira v1.16.1/go.mod h1:UQH4IBVxIYWbgagc0LF/k9FRs9xjIiQ8hIcC6HfLwFU=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
@@ -265,17 +266,16 @@ github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/
|
||||
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=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/caddyserver/certmagic v0.25.0 h1:VMleO/XA48gEWes5l+Fh6tRWo9bHkhwAEhx63i+F5ic=
|
||||
github.com/caddyserver/certmagic v0.25.0/go.mod h1:m9yB7Mud24OQbPHOiipAoyKPn9pKHhpSJxXR1jydBxA=
|
||||
github.com/caddyserver/zerossl v0.1.3 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+YTAyA=
|
||||
github.com/caddyserver/zerossl v0.1.3/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4=
|
||||
github.com/bytedance/sonic v1.15.3 h1:P3akjLPBtV/i6bHC6LbcLjY3KuoOvfiqF8wFHeP5IhY=
|
||||
github.com/bytedance/sonic v1.15.3/go.mod h1:8e51yTPdY8M6t+vvGL1c2Y1xL9i+frEeIAQAEl75NUc=
|
||||
github.com/bytedance/sonic/loader v0.5.2 h1:0QtP1gevc1OZ6/H8Lb9BRZiCXd1Ftjd3OKuj1T1lBIo=
|
||||
github.com/bytedance/sonic/loader v0.5.2/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/caddyserver/certmagic v0.25.3 h1:mGf5ba8F7xA4c5jfDZZbK2buY1VEkbnwpMDixaju94A=
|
||||
github.com/caddyserver/certmagic v0.25.3/go.mod h1:YVs43D5+H/Dckt4bTga1KSO/xYfFBfVZainGDywYPAA=
|
||||
github.com/caddyserver/zerossl v0.1.5 h1:dkvOjBAEEtY6LIGAHei7sw2UgqSD6TrWweXpV7lvEvE=
|
||||
github.com/caddyserver/zerossl v0.1.5/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||
@@ -310,17 +310,19 @@ github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89/go.mod h1:GKljq0V
|
||||
github.com/chromedp/chromedp v0.9.2/go.mod h1:LkSXJKONWTCHAfQasKFUZI+mxqS4tZqhmtGzzhLsnLs=
|
||||
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/logex v1.2.1 h1:XHDu3E6q+gdHgsdTPH6ImJMIp436vR6MPtH8gP05QzM=
|
||||
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
|
||||
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04=
|
||||
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
|
||||
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
|
||||
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/cloudflare/cfssl v1.6.4 h1:NMOvfrEjFfC63K3SGXgAnFdsgkmiq4kATme5BfcqrO8=
|
||||
github.com/cloudflare/cfssl v1.6.4/go.mod h1:8b3CQMxfWPAeom3zBnGJ6sd+G1NkL5TXqmDXacb+1J0=
|
||||
github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I=
|
||||
github.com/cloudflare/cfssl v1.6.5 h1:46zpNkm6dlNkMZH/wMW22ejih6gIaJbzL2du6vD7ZeI=
|
||||
github.com/cloudflare/cfssl v1.6.5/go.mod h1:Bk1si7sq8h2+yVEDrFJiz3d7Aw+pfjjJSZVaD+Taky4=
|
||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
@@ -349,8 +351,6 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
|
||||
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U=
|
||||
github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
@@ -382,6 +382,10 @@ github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/
|
||||
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
||||
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
|
||||
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
|
||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
@@ -419,8 +423,8 @@ github.com/fullstorydev/grpcurl v1.9.3 h1:PC1Xi3w+JAvEE2Tg2Gf2RfVgPbf9+tbuQr1Zky
|
||||
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.29.0 h1:wO6HGE8g9YE0Wm0bCpYxwRzfQ4+fbJKOhL64e5ACGCI=
|
||||
github.com/gaissmai/bart v0.29.0/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
|
||||
github.com/gaissmai/bart v0.30.0 h1:K1oTSQ6Nf76bhrgxeTqzZa4hE0M9x4xDfL6n6UG67Sc=
|
||||
github.com/gaissmai/bart v0.30.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.144.0 h1:hIRcTH+KjLfkLpYU6bSSfdFpi0fZi1fp+hSPi4aQu9Y=
|
||||
@@ -567,8 +571,8 @@ github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/certificate-transparency-go v1.3.2 h1:9ahSNZF2o7SYMaKaXhAumVEzXB2QaayzII9C8rv7v+A=
|
||||
github.com/google/certificate-transparency-go v1.3.2/go.mod h1:H5FpMUaGa5Ab2+KCYsxg6sELw3Flkl7pGZzWdBoYLXs=
|
||||
github.com/google/certificate-transparency-go v1.3.3 h1:hq/rSxztSkXN2tx/3jQqF6Xc0O565UQPdHrOWvZwybo=
|
||||
github.com/google/certificate-transparency-go v1.3.3/go.mod h1:iR17ZgSaXRzSa5qvjFl8TnVD5h8ky2JMVio+dzoKMgA=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
@@ -585,9 +589,8 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ=
|
||||
github.com/google/go-github/v30 v30.1.0 h1:VLDx+UolQICEOKu2m4uAoMti1SxuEBAl7RSEG16L+Oo=
|
||||
github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQFEufcolZ95JfU8=
|
||||
github.com/google/go-github/v50 v50.2.0/go.mod h1:VBY8FB6yPIjrtKhozXv4FQupxKLS6H4m6xFZlT43q8Q=
|
||||
github.com/google/go-github/v92 v92.0.0 h1:4vW4RVffwIvoEfIA4RX09mRSB47qQX557/2+HgeWRRg=
|
||||
github.com/google/go-github/v92 v92.0.0/go.mod h1:w3CH62ZcmRfvW1cdXpyTztSOVMtdjxtKpgo0GouLmjY=
|
||||
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
|
||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
|
||||
@@ -639,6 +642,8 @@ github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6
|
||||
github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc=
|
||||
github.com/goware/prefixer v0.0.0-20160118172347-395022866408 h1:Y9iQJfEqnN3/Nce9cOegemcy/9Ai5k3huT6E80F3zaw=
|
||||
github.com/goware/prefixer v0.0.0-20160118172347-395022866408/go.mod h1:PE1ycukgRPJ7bJ9a1fdfQ9j8i/cEcRAoLZzbxYpNB/s=
|
||||
github.com/graphql-go/graphql v0.8.1 h1:p7/Ou/WpmulocJeEx7wjQy611rtXGQaAcXGqanuMMgc=
|
||||
github.com/graphql-go/graphql v0.8.1/go.mod h1:nKiHzRM0qopJEwCITUuIsxk9PlVlwIiiI8pnJEhordQ=
|
||||
github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg=
|
||||
github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
@@ -773,6 +778,10 @@ 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/letsencrypt/challtestsrv v1.4.2 h1:0ON3ldMhZyWlfVNYYpFuWRTmZNnyfiL9Hh5YzC3JVwU=
|
||||
github.com/letsencrypt/challtestsrv v1.4.2/go.mod h1:GhqMqcSoeGpYd5zX5TgwA6er/1MbWzx/o7yuuVya+Wk=
|
||||
github.com/letsencrypt/pebble/v2 v2.10.0 h1:Wq6gYXlsY6ubqI3hhxsTzdyotvfdjFBxuwYqCLCnj/U=
|
||||
github.com/letsencrypt/pebble/v2 v2.10.0/go.mod h1:Sk8cmUIPcIdv2nINo+9PB4L+ZBhzY+F9A1a/h/xmWiQ=
|
||||
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=
|
||||
@@ -808,8 +817,8 @@ github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
|
||||
github.com/maypok86/otter/v2 v2.2.1 h1:hnGssisMFkdisYcvQ8L019zpYQcdtPse+g0ps2i7cfI=
|
||||
github.com/maypok86/otter/v2 v2.2.1/go.mod h1:1NKY9bY+kB5jwCXBJfE59u+zAwOt6C7ni1FTlFFMqVs=
|
||||
github.com/mholt/acmez/v3 v3.1.3 h1:gUl789rjbJSuM5hYzOFnNaGgWPV1xVfnOs59o0dZEcc=
|
||||
github.com/mholt/acmez/v3 v3.1.3/go.mod h1:L1wOU06KKvq7tswuMDwKdcHeKpFFgkppZy/y0DFxagQ=
|
||||
github.com/mholt/acmez/v3 v3.1.6 h1:eGVQNObP0pBN4sxqrXeg7MYqTOWyoiYpQqITVWlrevk=
|
||||
github.com/mholt/acmez/v3 v3.1.6/go.mod h1:5nTPosTGosLxF3+LU4ygbgMRFDhbAVpqMI4+a4aHLBY=
|
||||
github.com/mholt/archives v0.1.5 h1:Fh2hl1j7VEhc6DZs2DLMgiBNChUux154a1G+2esNvzQ=
|
||||
github.com/mholt/archives v0.1.5/go.mod h1:3TPMmBLPsgszL+1As5zECTuKwKvIfj6YcwWPpeTAXF4=
|
||||
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
|
||||
@@ -817,8 +826,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.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
|
||||
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
|
||||
github.com/miekg/dns v1.1.73 h1:uhT8nJxmTrPJYClxVxTCX+CVn6qnzSiybRk72Z6DgrE=
|
||||
github.com/miekg/dns v1.1.73/go.mod h1:RW2Obtfd5NZHvOFe3zYG0W8koWOQtAzyHaLo8vASBuQ=
|
||||
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=
|
||||
@@ -922,28 +931,30 @@ 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.2 h1:mxXdu0uM8P2L2Qi210COlU8QiICPFW/Rxk5QUhlPO2k=
|
||||
github.com/projectdiscovery/blackrock v0.0.2/go.mod h1:ANUtjDfaVrqB453bzToU+YB4cUbvBRpLvEwoWIwlTss=
|
||||
github.com/projectdiscovery/cdncheck v1.2.51 h1:uB+/JdG39HaP3GQ3ncBsWmPh/8cbYmJ3p/UxstHZl0o=
|
||||
github.com/projectdiscovery/cdncheck v1.2.51/go.mod h1:GIAA0SqjDfxzDQjHR/X+bK05BGmUGfsVplP5Zdv/kHA=
|
||||
github.com/projectdiscovery/clistats v0.1.6 h1:e2Hc7cVWKJ4sCbyQ7th5zuBvgqsZcLwRq1qY0vmQXY4=
|
||||
github.com/projectdiscovery/clistats v0.1.6/go.mod h1:RxRGxhMzkT344VTTvZWIrojurUXdQQRN1yfwgg/88I8=
|
||||
github.com/projectdiscovery/dsl v0.8.22 h1:ZfPunLaaYUJTBffE1ShuAAV9hkSH2thvlszK48FlZS0=
|
||||
github.com/projectdiscovery/dsl v0.8.22/go.mod h1:fr8WMU381As89e4Z+SQ8tYQ1NudniWAS6NLPHrhe+Vs=
|
||||
github.com/projectdiscovery/fastdialer v0.5.19 h1:FQOoGGaCyreq53yz3QI+KztvOKn2M9OXJqNg7/4e03w=
|
||||
github.com/projectdiscovery/fastdialer v0.5.19/go.mod h1:6I4/kPu9s/LTUgmPqIc0ZOEh8TfZGY0Nlr5M84cFAj8=
|
||||
github.com/projectdiscovery/cdncheck v1.3.2 h1:I453ZWNBeG0WWIy3wi9412l4PTzorLgebEnqmbcpSfo=
|
||||
github.com/projectdiscovery/cdncheck v1.3.2/go.mod h1:5IkUMMlcGaeg1DWW5oJVd3r9X3EpJ88j5HxBeF4Me9M=
|
||||
github.com/projectdiscovery/clistats v0.1.7 h1:NXf4lUzx1QcbJd6QycNwwYHTv2FsW9MchQ2LsgSL6C0=
|
||||
github.com/projectdiscovery/clistats v0.1.7/go.mod h1:RxRGxhMzkT344VTTvZWIrojurUXdQQRN1yfwgg/88I8=
|
||||
github.com/projectdiscovery/dsl v0.8.24 h1:rN2w6LSIjA16qKGRjyj70oxdwXGaspe6sAjEkP7F2fk=
|
||||
github.com/projectdiscovery/dsl v0.8.24/go.mod h1:V+EludvS5PS19UNMlM3CuBwZ+sFnMaQhCFGxysDUanQ=
|
||||
github.com/projectdiscovery/fastdialer v0.5.26 h1:wCCsopLsO1EF1V1+9Wqkxj1bMm6sRo8NdlzwE2Ib2Ss=
|
||||
github.com/projectdiscovery/fastdialer v0.5.26/go.mod h1:dZzEPnpsxqCiD3IkM5CN3RglW3l1gXJNyTkxqjPZIbs=
|
||||
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/goflags v0.2.0 h1:zj16hnTmLmlnVYm02hfOrCY6oYTu/AQDBE99yZkS+s8=
|
||||
github.com/projectdiscovery/goflags v0.2.0/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/goflags v0.2.1 h1:XnSYvsCjUwHbZaaJUZ3YznCJPoXRk/IhTFP9Gecmx6M=
|
||||
github.com/projectdiscovery/goflags v0.2.1/go.mod h1:7nAP1r2Dqgn/rwmOE3EWbZWUCEJKNIhVSBGpuzJAIns=
|
||||
github.com/projectdiscovery/goimpacket v0.1.1-0.20261007205806-6042a8213eeb h1:+L1yW2PblRHSSHQRTmvxLkGDPZnM52N62oeBd2xHKF0=
|
||||
github.com/projectdiscovery/goimpacket v0.1.1-0.20261007205806-6042a8213eeb/go.mod h1:bfgULaYf+6pjjcLCekpXIYElnLJbmhvTttxMAbQJvhY=
|
||||
github.com/projectdiscovery/goja v0.0.0-20261012075649-9b78f2e05b9c h1:4H/CfVNlue9PIwsCM1IGxOynDI7cir+emXuDvGJTLJs=
|
||||
github.com/projectdiscovery/goja v0.0.0-20261012075649-9b78f2e05b9c/go.mod h1:SO0AP+uKfYeeoR6jyVH/PKRYJE/f5gJrPMAM00iGEMc=
|
||||
github.com/projectdiscovery/goja_nodejs v0.0.0-20260618132410-8519f75f703d h1:fqqH9LHpN2WDz9QuxFrhKNxXSRtzk+Sa6jAhbB7tXcQ=
|
||||
github.com/projectdiscovery/goja_nodejs v0.0.0-20260618132410-8519f75f703d/go.mod h1:Ezmbgdaw4EunGGBU4MQViLoGMJc37LA3ip55YV3KeRI=
|
||||
github.com/projectdiscovery/gologger v1.1.72 h1:PKk+aSx3jYOCBPi+FD+nTnhe+Vxx9u3K7Ega0GVlMCI=
|
||||
github.com/projectdiscovery/gologger v1.1.72/go.mod h1:mJwODZcFDg70ihINpOvZevmBtgvpP8H9/l8Y+OPhZPY=
|
||||
github.com/projectdiscovery/gologger v1.1.73 h1:WX2nzq29cL7HdvOHqIttacoid/5lMwapyM7ukGU1jFY=
|
||||
github.com/projectdiscovery/gologger v1.1.73/go.mod h1:mJwODZcFDg70ihINpOvZevmBtgvpP8H9/l8Y+OPhZPY=
|
||||
github.com/projectdiscovery/gostruct v0.0.2 h1:s8gP8ApugGM4go1pA+sVlPDXaWqNP5BBDDSv7VEdG1M=
|
||||
github.com/projectdiscovery/gostruct v0.0.2/go.mod h1:H86peL4HKwMXcQQtEa6lmC8FuD9XFt6gkNR0B/Mu5PE=
|
||||
github.com/projectdiscovery/govaluate v0.0.0-20260615100919-5ee2581bbf7e h1:vxzgQlz2Cy/YvizYDQx9OhucBcmBotfDhbQ4yCY2vfA=
|
||||
@@ -952,10 +963,12 @@ 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.102 h1:ybPuf7UQvuOLiHLOUbVMvd+PSlItQU5iQpP1Erl8kaU=
|
||||
github.com/projectdiscovery/hmap v0.0.102/go.mod h1:IZ92wXFmxYpdn0euZ7VFmypzOBNDwq8c3yRyIIol6XE=
|
||||
github.com/projectdiscovery/httpx v1.11.0 h1:0jPnhqiCMlkcEGv5qFVkBYUX7BRbe3gStvDkBZdkbYs=
|
||||
github.com/projectdiscovery/httpx v1.11.0/go.mod h1:parFzVgFWVb7qDgyPgztD2I2OaVGJl8CCKGLPkWaObA=
|
||||
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/httpx v1.12.0 h1:NN5iDrw7NjPBDlACdjac4lePTQmZ5aOmgOzptMpP44c=
|
||||
github.com/projectdiscovery/httpx v1.12.0/go.mod h1:OgtyibNGdu9BrJxRdNKm280Davw1Z4s2FAhfPNNtLB4=
|
||||
github.com/projectdiscovery/interactsh v1.4.1 h1:EGHPJ1rnuxvILWfcoaRgb/Ykz67WiDXABRCSJN666GA=
|
||||
github.com/projectdiscovery/interactsh v1.4.1/go.mod h1:5g207F6MvIydlKWo4ZzplXkhaCwSq2veuUTNvEFjqps=
|
||||
github.com/projectdiscovery/jarm-go v0.0.0-20260910160638-430e2bae86f6 h1:GUHQYLllWhoca+C4UAT0+TTB1PrUT05klj39V6/KpK8=
|
||||
github.com/projectdiscovery/jarm-go v0.0.0-20260910160638-430e2bae86f6/go.mod h1:f8P5X5+jEkUqK9z3oNWtSH1g23jMfw3nuEqmtA1jXA4=
|
||||
github.com/projectdiscovery/ldapserver v1.0.2-0.20240219154113-dcc758ebc0cb h1:MGtI4oE12ruWv11ZlPXXd7hl/uAaQZrFvrIDYDeVMd8=
|
||||
github.com/projectdiscovery/ldapserver v1.0.2-0.20240219154113-dcc758ebc0cb/go.mod h1:vmgC0DTFCfoCLp0RAfsfYTZZan0QMVs+cmTbH6blfjk=
|
||||
github.com/projectdiscovery/machineid v0.0.0-20250715113114-c77eb3567582 h1:eR+0HE//Ciyfwy3HC7fjRyKShSJHYoX2Pv7pPshjK/Q=
|
||||
@@ -964,32 +977,32 @@ 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.49 h1:4F2lIy/iZUfLltpAwudXF/93CCICipr+pBshz5Ozts4=
|
||||
github.com/projectdiscovery/networkpolicy v0.1.49/go.mod h1:wm/KWiKQ9I0LLowandFnLiSype7HEvilXVjEwNLk2PE=
|
||||
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.92 h1:38GRsVLNllES/dZcEcA6s6P+rYBp34H9mDphMJMDvvg=
|
||||
github.com/projectdiscovery/rawhttp v0.1.92/go.mod h1:spfXTPjxCj+bfWLynMVB7uOb+LS3PDNAI9HexMEV1Zg=
|
||||
github.com/projectdiscovery/networkpolicy v0.1.55 h1:D30pipxOdWnSq3r7sX0kQvGyb18sUgopuda1j9NAmnQ=
|
||||
github.com/projectdiscovery/networkpolicy v0.1.55/go.mod h1:N5bXm8L3UsDw51CeA1nhTsGEbQMbgL5lXTiIU013xd0=
|
||||
github.com/projectdiscovery/ratelimit v0.0.94 h1:2qciSuUxarn73YGICZeprZtjrwyAH+0iVpdKzKCXoXE=
|
||||
github.com/projectdiscovery/ratelimit v0.0.94/go.mod h1:GAerX6s+eWRuACedHRv2/xmMtlW+VahZmbRstQh0sS0=
|
||||
github.com/projectdiscovery/rawhttp v0.2.0 h1:TGiHC5hydL97/zoi0WjEuNrfO6Gjtg9LeXndUf1Cq3M=
|
||||
github.com/projectdiscovery/rawhttp v0.2.0/go.mod h1:Waz+K5y6r44ElpqAoOfYtY03zrGGZ12nx0yDKq7Ot1E=
|
||||
github.com/projectdiscovery/rdap v0.9.0 h1:wPhHx5pQ2QI+WGhyNb2PjhTl0NtB39Nk7YFZ9cp8ZGA=
|
||||
github.com/projectdiscovery/rdap v0.9.0/go.mod h1:zk4yrJFQ2Hy36Aqk+DvotYQxYAeALaCJ5ORySkff36Q=
|
||||
github.com/projectdiscovery/retryabledns v1.0.116 h1:tK28PJ4tLO7bgM89RpIFjkfTPMSbRegwvg9mN/q6z78=
|
||||
github.com/projectdiscovery/retryabledns v1.0.116/go.mod h1:r3vH0AKYtJ9fculcqRFrdVYe2ABcHwZeR4C6j3rqCbY=
|
||||
github.com/projectdiscovery/retryablehttp-go v1.3.25 h1:caWjhagt49zkSbaOZnsD+qi8UwfgbofezHOtYPHvhb0=
|
||||
github.com/projectdiscovery/retryablehttp-go v1.3.25/go.mod h1:+lyetyXnmFCGzjfebzVCsszts9JHkKncal+nNwYeqCs=
|
||||
github.com/projectdiscovery/retryablehttp-go v1.3.31 h1:0hSEZ5+jj6Oo1IL8qrytD36TK/qCxfe543jEaRqSTjs=
|
||||
github.com/projectdiscovery/retryablehttp-go v1.3.31/go.mod h1:/WEa7YejxTCXEMLCGfGYaC3JsgXgzuqw8rv8cb4buEc=
|
||||
github.com/projectdiscovery/sarif v0.1.0 h1:O541T+a448nSJsmIMnXXSOeDQEzpnCAYvRfe0eG5h74=
|
||||
github.com/projectdiscovery/sarif v0.1.0/go.mod h1:LBC+reM3bkI3qIIhE0rZaINaYX6VG+En6u2hHa5mA7E=
|
||||
github.com/projectdiscovery/stringsutil v0.0.2 h1:uzmw3IVLJSMW1kEg8eCStG/cGbYYZAja8BH3LqqJXMA=
|
||||
github.com/projectdiscovery/stringsutil v0.0.2/go.mod h1:EJ3w6bC5fBYjVou6ryzodQq37D5c6qbAYQpGmAy+DC0=
|
||||
github.com/projectdiscovery/tlsx v1.3.2 h1:1Lh2ith79o8R+rOOSBZiMq8EiZ8j4D3T/8ENO+gAqXA=
|
||||
github.com/projectdiscovery/tlsx v1.3.2/go.mod h1:2wgTGC/sourHvoR+RX8cAok0Sa8YK6QbEP3xo5SDzPI=
|
||||
github.com/projectdiscovery/tlsx v1.4.3-0.20261020052910-acb80c2c7c75 h1:3qJgfWzzwtFXc/vdUIurSzPzyjBH9NQe0wATkZMoQGI=
|
||||
github.com/projectdiscovery/tlsx v1.4.3-0.20261020052910-acb80c2c7c75/go.mod h1:8jqB9aHT6U8NkUsENvaSjD/iBEGB4Ta+CbJBMcvzP9c=
|
||||
github.com/projectdiscovery/uncover v1.2.1 h1:8U46T/96CLT7BPoXBgkTvWqB06lOyeTSLvh5+UjzATE=
|
||||
github.com/projectdiscovery/uncover v1.2.1/go.mod h1:0p8onrWxfpXQEYs90ZDzTSpu1107gWmodX1NWqu/+z4=
|
||||
github.com/projectdiscovery/useragent v0.0.109 h1:b86BPdZvmgbJousa04jAugDRY4Y+rAvEamE0TFCPmeE=
|
||||
github.com/projectdiscovery/useragent v0.0.109/go.mod h1:sRyBqDAcD31RKYrO8fyXGowLd36fNwY1g0MNW9mp7zk=
|
||||
github.com/projectdiscovery/utils v0.11.3 h1:TNBhSNJ8uFw7DWASUHBf1vfCx7mwMi+jtGizrizMM5s=
|
||||
github.com/projectdiscovery/utils v0.11.3/go.mod h1:HMxhxLigsAr+M9Oa8n9Z0ROZcs8wAJHGsgR7UGb+oUE=
|
||||
github.com/projectdiscovery/wappalyzergo v0.2.96 h1:/YHMiUA2kL2bap2vHDuZs1cJK3RgHZjxdVam5GxJ12k=
|
||||
github.com/projectdiscovery/wappalyzergo v0.2.96/go.mod h1:5ZuunxbKPGvnJeEOMIqlfj5xg/XSEAoakZr/s9l2pNI=
|
||||
github.com/projectdiscovery/utils v0.11.11-0.20261020211236-cde16c1381ec h1:VhJiivJR8ljv5Qk3QZQC9og50wspEBB5qkNfdyqNhws=
|
||||
github.com/projectdiscovery/utils v0.11.11-0.20261020211236-cde16c1381ec/go.mod h1:T7KzyElivWJiPP+B4sXqkp1CcFe6O/P0qBn/MjDNa74=
|
||||
github.com/projectdiscovery/wappalyzergo v0.3.5 h1:UZradYF1GIcruSIV5wwo/Coph9/VnM17oajLD9eXR2U=
|
||||
github.com/projectdiscovery/wappalyzergo v0.3.5/go.mod h1:4E6+fh/ckaY3wo5tlM7lNYSkhuTYRHzu2YmGIRraXP0=
|
||||
github.com/projectdiscovery/yamldoc-go v1.0.7 h1:Odmjd1o4EkMff2F6QHi1dIYPIQPZUFy24YqmQCprmTk=
|
||||
github.com/projectdiscovery/yamldoc-go v1.0.7/go.mod h1:nqD1iPMcofpRA1DbXavPByJhWURrshT+l8WN9XjyQag=
|
||||
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
@@ -1016,8 +1029,8 @@ github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1
|
||||
github.com/prometheus/procfs v0.7.3/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
|
||||
github.com/prometheus/procfs v0.8.0/go.mod h1:z7EfXMXOkbkqb9IINtpCn86r/to3BnA0uaxHdg830/4=
|
||||
github.com/prometheus/procfs v0.9.0/go.mod h1:+pB4zwohETzFnmlpe6yd2lSc+0/46IYZRB/chUwxUZY=
|
||||
github.com/redis/go-redis/v9 v9.11.0 h1:E3S08Gl/nJNn5vkxd2i78wZxWAPNZgUNTp8WIJUAiIs=
|
||||
github.com/redis/go-redis/v9 v9.11.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
|
||||
github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k=
|
||||
github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA=
|
||||
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
|
||||
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||
github.com/remeh/sizedwaitgroup v1.0.0 h1:VNGGFwNo/R5+MJBf6yrsr110p0m4/OX4S3DCy7Kyl5E=
|
||||
@@ -1044,12 +1057,10 @@ github.com/sandrolain/httpcache v1.4.0 h1:Jf4Vx62X2ybvNPSpPvI1kT3xvMdDG1AsApQjOQ
|
||||
github.com/sandrolain/httpcache v1.4.0/go.mod h1:kHBuXveitSn39SNPBhdf/ybG272X706HJ2RJqOQ+Em0=
|
||||
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=
|
||||
github.com/segmentio/ksuid v1.0.4/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw=
|
||||
github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
|
||||
github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
|
||||
github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc=
|
||||
@@ -1064,9 +1075,8 @@ github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6Mwd
|
||||
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/sirupsen/logrus v1.10.2 h1:G2SED73/qrAu6YwbdxOD6peLkCBI3z7L+ykJFTXJBBo=
|
||||
github.com/sirupsen/logrus v1.10.2/go.mod h1:SLEg8TqYulVKKfIGHldVp2K2aYz2DKSVBq4g/H5bR7Q=
|
||||
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
|
||||
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
|
||||
@@ -1172,9 +1182,8 @@ github.com/vulncheck-oss/go-exploit v1.51.0/go.mod h1:J28w0dLnA6DnCrnBm9Sbt6smX8
|
||||
github.com/weppos/publicsuffix-go v0.12.0/go.mod h1:z3LCPQ38eedDQSwmsSRW4Y7t2L8Ln16JPQ02lHAdn5k=
|
||||
github.com/weppos/publicsuffix-go v0.13.0/go.mod h1:z3LCPQ38eedDQSwmsSRW4Y7t2L8Ln16JPQ02lHAdn5k=
|
||||
github.com/weppos/publicsuffix-go v0.30.0/go.mod h1:kBi8zwYnR0zrbm8RcuN1o9Fzgpnnn+btVN8uWPMyXAY=
|
||||
github.com/weppos/publicsuffix-go v0.40.2/go.mod h1:XsLZnULC3EJ1Gvk9GVjuCTZ8QUu9ufE4TZpOizDShko=
|
||||
github.com/weppos/publicsuffix-go v0.50.3 h1:eT5dcjHQcVDNc0igpFEsGHKIip30feuB2zuuI9eJxiE=
|
||||
github.com/weppos/publicsuffix-go v0.50.3/go.mod h1:/rOa781xBykZhHK/I3QeHo92qdDKVmKZKF7s8qAEM/4=
|
||||
github.com/weppos/publicsuffix-go v0.50.4-0.20260918082853-3c3d7dc4a0cb h1:QIW0GHVTSubm3VxkKpn1FnFAgins6N8FiGTvUQwpXzw=
|
||||
github.com/weppos/publicsuffix-go v0.50.4-0.20260918082853-3c3d7dc4a0cb/go.mod h1:/rOa781xBykZhHK/I3QeHo92qdDKVmKZKF7s8qAEM/4=
|
||||
github.com/weppos/publicsuffix-go/publicsuffix/generator v0.0.0-20220927085643-dc0d00c92642/go.mod h1:GHfoeIdZLdZmLjMlzBftbTDntahTttUMWjxZwQJhULE=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=
|
||||
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
|
||||
@@ -1229,6 +1238,8 @@ 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/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
|
||||
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
|
||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||
github.com/zcalusic/sysinfo v1.1.3 h1:u/AVENkuoikKuIZ4sUEJ6iibpmQP6YpGD8SSMCrqAF0=
|
||||
@@ -1239,16 +1250,17 @@ github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
|
||||
github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCRUjcE=
|
||||
github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
|
||||
github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
|
||||
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||
github.com/zmap/rc2 v0.0.0-20131011165748-24b9757f5521/go.mod h1:3YZ9o3WnatTIZhuOtot4IcUfzoKVjUHqu6WALIyI0nE=
|
||||
github.com/zmap/rc2 v0.0.0-20190804163417-abaa70531248 h1:Nzukz5fNOBIHOsnP+6I79kPx3QhLv8nBy2mfFhBRq30=
|
||||
github.com/zmap/rc2 v0.0.0-20190804163417-abaa70531248/go.mod h1:3YZ9o3WnatTIZhuOtot4IcUfzoKVjUHqu6WALIyI0nE=
|
||||
github.com/zmap/zcertificate v0.0.0-20180516150559-0e3d58b1bac4/go.mod h1:5iU54tB79AMBcySS0R2XIyZBAVmeHranShAFELYx7is=
|
||||
github.com/zmap/zcertificate v0.0.1/go.mod h1:q0dlN54Jm4NVSSuzisusQY0hqDWvu92C+TWveAxiVWk=
|
||||
github.com/zmap/zcrypto v0.0.0-20201128221613-3719af1573cf/go.mod h1:aPM7r+JOkfL+9qSB4KbYjtoEzJqUK50EXkkJabeNJDQ=
|
||||
github.com/zmap/zcrypto v0.0.0-20201211161100-e54a5822fb7e/go.mod h1:aPM7r+JOkfL+9qSB4KbYjtoEzJqUK50EXkkJabeNJDQ=
|
||||
github.com/zmap/zcrypto v0.0.0-20230310154051-c8b263fd8300/go.mod h1:mOd4yUMgn2fe2nV9KXsa9AyQBFZGzygVPovsZR+Rl5w=
|
||||
github.com/zmap/zcrypto v0.0.0-20240803002437-3a861682ac77 h1:DCz0McWRVJNICkHdu2XpETqeLvPtZXs315OZyUs1BDk=
|
||||
github.com/zmap/zcrypto v0.0.0-20240803002437-3a861682ac77/go.mod h1:aSvf+uTU222mUYq/KQj3oiEU7ajhCZe8RRSLHIoM4EM=
|
||||
github.com/zmap/zcrypto v0.0.0-20261015050653-aa1de3126e87 h1:B9sBGvtHjiGOOemAh0cCGTzub76DXlxmOh3Qtx3TkRs=
|
||||
github.com/zmap/zcrypto v0.0.0-20261015050653-aa1de3126e87/go.mod h1:9O2aSz1l9fUHTFxJMYCoFsZBy6yGlz3nrOp4dHue5qw=
|
||||
github.com/zmap/zflags v1.4.0-beta.1.0.20200204220219-9d95409821b6/go.mod h1:HXDUD+uue8yeLHr0eXx1lvY6CvMiHbTKw5nGmA9OUoo=
|
||||
github.com/zmap/zgrab2 v0.1.8 h1:PFnXrIBcGjYFec1JNbxMKQuSXXzS+SbqE89luuF4ORY=
|
||||
github.com/zmap/zgrab2 v0.1.8/go.mod h1:5d8HSmUwvllx4q1qG50v/KXphkg45ZzWdaQtgTFnegE=
|
||||
@@ -1286,12 +1298,14 @@ go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRk
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||
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/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
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=
|
||||
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
|
||||
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U=
|
||||
go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
@@ -1324,10 +1338,9 @@ golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.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.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
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=
|
||||
@@ -1365,8 +1378,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.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74=
|
||||
golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY=
|
||||
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
|
||||
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
|
||||
golang.org/x/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=
|
||||
@@ -1414,10 +1427,9 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.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.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
|
||||
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||
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=
|
||||
@@ -1426,7 +1438,6 @@ golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4Iltr
|
||||
golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A=
|
||||
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.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=
|
||||
@@ -1445,8 +1456,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.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
|
||||
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -1493,7 +1504,6 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -1511,10 +1521,9 @@ golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
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=
|
||||
@@ -1526,10 +1535,9 @@ golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.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.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
|
||||
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
|
||||
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
|
||||
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
|
||||
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=
|
||||
@@ -1545,10 +1553,9 @@ golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.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.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
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=
|
||||
@@ -1680,8 +1687,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.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y=
|
||||
google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
|
||||
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
|
||||
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
|
||||
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=
|
||||
@@ -1697,7 +1704,6 @@ google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQ
|
||||
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc=
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
module nuclei-tools
|
||||
|
||||
go 1.26.1
|
||||
go 1.27.1
|
||||
|
||||
tool (
|
||||
github.com/dvyukov/go-fuzz/go-fuzz
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
llmclient "github.com/projectdiscovery/nuclei/v3/pkg/operators/common/llm"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
utilsllm "github.com/projectdiscovery/utils/llm"
|
||||
)
|
||||
|
||||
// llmMatcherClient adapts the shared provider layer to the minimal interface
|
||||
// the matchers package needs, keeping matchers free of any provider SDK import.
|
||||
type llmMatcherClient struct {
|
||||
client *utilsllm.Client
|
||||
model string
|
||||
}
|
||||
|
||||
// Model names the model behind a verdict so it can be recorded in the finding.
|
||||
func (a *llmMatcherClient) Model() string {
|
||||
return a.model
|
||||
}
|
||||
|
||||
func (a *llmMatcherClient) Complete(ctx context.Context, prompt string, asJSON bool) (string, error) {
|
||||
return a.client.Complete(ctx, utilsllm.Request{
|
||||
Prompt: prompt,
|
||||
Format: utilsllm.Format{JSON: asJSON},
|
||||
})
|
||||
}
|
||||
|
||||
// configureLLM builds the scan's llm client from options. It returns nil unless
|
||||
// -llm is set, so nothing reaches a model until the user opts in. The API key is
|
||||
// read from the environment by the provider layer, never from options.
|
||||
//
|
||||
// The client is returned rather than installed globally so two scans in one
|
||||
// process cannot overwrite each other's provider, cache, budget, or timeout.
|
||||
func configureLLM(options *types.Options) (llmclient.Client, error) {
|
||||
if !options.EnableLLM {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
client, err := utilsllm.New(utilsllm.Config{
|
||||
Provider: options.LLMProvider,
|
||||
BaseURL: options.LLMBaseURL,
|
||||
Model: options.LLMModel,
|
||||
Timeout: time.Duration(options.LLMTimeout) * time.Second,
|
||||
Cache: options.LLMCache,
|
||||
MaxCalls: options.LLMMaxCalls,
|
||||
MaxConcurrency: options.LLMConcurrency,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &llmMatcherClient{client: client, model: options.LLMModel}, nil
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/projectdiscovery/gologger"
|
||||
"github.com/projectdiscovery/gologger/formatter"
|
||||
"github.com/projectdiscovery/gologger/levels"
|
||||
"github.com/projectdiscovery/nuclei/v3/internal/server/proxy"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolinit"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/utils/vardump"
|
||||
@@ -55,10 +56,18 @@ func ConfigureOptions() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// readsTargetsFromStdin reports whether targets are read from a stdin pipe.
|
||||
// Targets given with -u, -l, or uncover take its place: under cron, CI or an
|
||||
// agent stdin is often a pipe nothing writes to or closes, and reading it
|
||||
// would hold the scan until it does.
|
||||
func readsTargetsFromStdin(options *types.Options, stdinIsPipe bool) bool {
|
||||
hasOtherTargets := len(options.Targets) > 0 || options.TargetsFilePath != "" || options.Uncover || len(options.UncoverQuery) > 0
|
||||
return stdinIsPipe && !options.DisableStdin && !hasOtherTargets
|
||||
}
|
||||
|
||||
// ParseOptions parses the command line flags provided by a user
|
||||
func ParseOptions(options *types.Options) {
|
||||
// Check if stdin pipe was given
|
||||
options.Stdin = !options.DisableStdin && fileutil.HasStdin()
|
||||
options.Stdin = readsTargetsFromStdin(options, fileutil.HasStdin())
|
||||
|
||||
// Read the inputs from env variables that not passed by flag.
|
||||
readEnvInputVars(options)
|
||||
@@ -170,6 +179,9 @@ func ValidateOptions(options *types.Options) error {
|
||||
if err := loadProxyServers(options); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateReplayProxy(options.ReplayProxy); err != nil {
|
||||
return err
|
||||
}
|
||||
if options.Validate {
|
||||
validateTemplatePaths(options.Logger, config.DefaultConfig.TemplatesDirectory, options.Templates, options.Workflows)
|
||||
}
|
||||
@@ -296,6 +308,11 @@ func validateDASTOptions(options *types.Options) error {
|
||||
if len(options.DASTServerToken) > 0 && len(options.DASTServerToken) < 16 {
|
||||
return fmt.Errorf("DAST server token must be at least 16 characters long")
|
||||
}
|
||||
if options.DASTProxy {
|
||||
if _, _, err := proxy.ParseAuth(options.DASTProxyAuth); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -59,3 +59,24 @@ func TestParseHeadlessOptionalArguments(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadsTargetsFromStdin(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
options *types.Options
|
||||
stdinIsPipe bool
|
||||
want bool
|
||||
}{
|
||||
{"piped stdin alone", &types.Options{}, true, true},
|
||||
{"no pipe", &types.Options{}, false, false},
|
||||
{"disabled", &types.Options{DisableStdin: true}, true, false},
|
||||
{"targets given with -u", &types.Options{Targets: goflags.StringSlice{"example.com"}}, true, false},
|
||||
{"targets given with -l", &types.Options{TargetsFilePath: "targets.txt"}, true, false},
|
||||
{"uncover query", &types.Options{UncoverQuery: goflags.StringSlice{"example"}}, true, false},
|
||||
{"uncover enabled", &types.Options{Uncover: true}, true, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require.Equal(t, tc.want, readsTargetsFromStdin(tc.options, tc.stdinIsPipe))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -65,3 +65,21 @@ func loadProxyServers(options *types.Options) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateReplayProxy rejects a -replay-proxy value the replay client cannot dial
|
||||
func validateReplayProxy(raw string) error {
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
proxyURL, err := url.Parse(raw)
|
||||
if err != nil {
|
||||
return errkit.Wrapf(err, "invalid replay proxy %q", raw)
|
||||
}
|
||||
switch proxyURL.Scheme {
|
||||
case proxyutils.HTTP, proxyutils.HTTPS, proxyutils.SOCKS5:
|
||||
if proxyURL.Host != "" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("invalid replay proxy %q: want an http, https or socks5 URL", raw)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestValidateReplayProxy(t *testing.T) {
|
||||
for _, valid := range []string{"", "http://127.0.0.1:8080", "https://proxy.example:8443", "socks5://127.0.0.1:1080"} {
|
||||
require.NoError(t, validateReplayProxy(valid), valid)
|
||||
}
|
||||
for _, invalid := range []string{"127.0.0.1:8080", "ftp://127.0.0.1:21", "http://", "://bad"} {
|
||||
require.Error(t, validateReplayProxy(invalid), invalid)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"github.com/projectdiscovery/gologger"
|
||||
"github.com/projectdiscovery/nuclei/v3/internal/pdcp"
|
||||
"github.com/projectdiscovery/nuclei/v3/internal/server"
|
||||
"github.com/projectdiscovery/nuclei/v3/internal/server/proxy"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/authprovider"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz/frequency"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/input/provider"
|
||||
@@ -45,6 +47,7 @@ import (
|
||||
fuzzStats "github.com/projectdiscovery/nuclei/v3/pkg/fuzz/stats"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/input"
|
||||
parsers "github.com/projectdiscovery/nuclei/v3/pkg/loader/workflow"
|
||||
llmclient "github.com/projectdiscovery/nuclei/v3/pkg/operators/common/llm"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/output"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/progress"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/projectfile"
|
||||
@@ -53,6 +56,7 @@ import (
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/globalmatchers"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/honeypotdetector"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/hostbackoff"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/hosterrorscache"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/interactsh"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolinit"
|
||||
@@ -108,6 +112,7 @@ type Runner struct {
|
||||
httpApiEndpoint *httpapi.Server
|
||||
fuzzStats *fuzzStats.Tracker
|
||||
dastServer *server.DASTServer
|
||||
llmClient llmclient.Client
|
||||
}
|
||||
|
||||
// New creates a new client for running the enumeration process.
|
||||
@@ -121,6 +126,12 @@ func New(options *types.Options) (*Runner, error) {
|
||||
return nil, fmt.Errorf("initialize nuclei configuration: %w", err)
|
||||
}
|
||||
|
||||
llmClient, err := configureLLM(options)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("configure llm: %w", err)
|
||||
}
|
||||
runner.llmClient = llmClient
|
||||
|
||||
if options.HealthCheck {
|
||||
runner.Logger.Print().Msgf("%s\n", DoHealthCheck(options))
|
||||
os.Exit(0)
|
||||
@@ -359,14 +370,14 @@ func New(options *types.Options) (*Runner, error) {
|
||||
|
||||
runner.resumeCfg = resumeCfg
|
||||
|
||||
if options.DASTReport || options.DASTServer {
|
||||
if options.DASTReport || options.DASTServer || options.DASTProxy {
|
||||
var err error
|
||||
runner.fuzzStats, err = fuzzStats.NewTracker()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not create fuzz stats db")
|
||||
}
|
||||
|
||||
if !options.DASTServer {
|
||||
if !options.DASTServer && !options.DASTProxy {
|
||||
dastServer, err := server.NewStatsServer(runner.fuzzStats)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not create dast server")
|
||||
@@ -577,7 +588,7 @@ func (r *Runner) RunEnumeration() error {
|
||||
|
||||
// If the user has asked for DAST server mode, run the live
|
||||
// DAST fuzzing server.
|
||||
if r.options.DASTServer {
|
||||
if r.options.DASTServer || r.options.DASTProxy {
|
||||
execurOpts := &server.NucleiExecutorOptions{
|
||||
Options: r.options,
|
||||
Output: r.output,
|
||||
@@ -594,7 +605,7 @@ func (r *Runner) RunEnumeration() error {
|
||||
FuzzStatsDB: r.fuzzStats,
|
||||
Logger: r.Logger,
|
||||
}
|
||||
dastServer, err := server.New(&server.Options{
|
||||
serverOptions := &server.Options{
|
||||
Address: r.options.DASTServerAddress,
|
||||
Templates: r.options.Templates,
|
||||
OutputWriter: r.output,
|
||||
@@ -603,7 +614,21 @@ func (r *Runner) RunEnumeration() error {
|
||||
InScope: r.options.Scope,
|
||||
OutScope: r.options.OutOfScope,
|
||||
NucleiExecutorOptions: execurOpts,
|
||||
})
|
||||
EnableFuzzAPI: r.options.DASTServer,
|
||||
ForwardProxy: r.options.AliveHttpProxy,
|
||||
}
|
||||
if r.options.DASTProxy {
|
||||
username, password, err := proxy.ParseAuth(r.options.DASTProxyAuth)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
serverOptions.ProxyAddress = r.options.DASTProxyAddress
|
||||
serverOptions.ProxyCADir = config.DefaultConfig.GetConfigDir()
|
||||
serverOptions.ProxyUsername = username
|
||||
serverOptions.ProxyPassword = password
|
||||
}
|
||||
|
||||
dastServer, err := server.New(serverOptions)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -642,6 +667,7 @@ func (r *Runner) RunEnumeration() error {
|
||||
executorOpts := &protocols.ExecutorOptions{
|
||||
Output: r.output,
|
||||
Options: r.options,
|
||||
LLMClient: r.llmClient,
|
||||
Progress: r.progress,
|
||||
Catalog: r.catalog,
|
||||
IssuesClient: r.issuesClient,
|
||||
@@ -702,6 +728,10 @@ func (r *Runner) RunEnumeration() error {
|
||||
executorOpts.HostErrorsCache = cache
|
||||
}
|
||||
|
||||
if r.options.HostBackoff {
|
||||
executorOpts.HostBackoff = hostbackoff.New(hostbackoff.Config{Max: r.options.HostBackoffMax})
|
||||
}
|
||||
|
||||
executorEngine := core.New(r.options)
|
||||
executorEngine.SetExecuterOptions(executorOpts)
|
||||
|
||||
@@ -713,6 +743,13 @@ func (r *Runner) RunEnumeration() error {
|
||||
|
||||
// If using input-file flags, only load http fuzzing based templates.
|
||||
loaderConfig := loader.NewConfig(r.options, r.catalog, executorOpts)
|
||||
profiles := r.targetProfiles()
|
||||
if profiles != nil {
|
||||
if r.options.AutomaticScan {
|
||||
return errors.New("per-target profiles cannot be combined with automatic scan")
|
||||
}
|
||||
loaderConfig.TargetFilter = profiles.LoadFilter()
|
||||
}
|
||||
if (!strings.EqualFold(r.options.InputFileMode, "list") || r.options.DAST) && !r.options.OfflineHTTP {
|
||||
// if input type is not list (implicitly enable fuzzing), unless passive/offlinehttp
|
||||
r.options.DAST = true
|
||||
@@ -757,6 +794,10 @@ func (r *Runner) RunEnumeration() error {
|
||||
if err := store.Load(); err != nil {
|
||||
return err
|
||||
}
|
||||
if profiles != nil {
|
||||
profiles.Prepare(slices.Concat(store.Templates(), store.Workflows()))
|
||||
executorOpts.TargetScope = profiles
|
||||
}
|
||||
// TODO: remove below functions after v3 or update warning messages
|
||||
templates.PrintDeprecatedProtocolNameMsgIfApplicable(r.options.Silent, r.options.Verbose)
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package runner
|
||||
|
||||
import (
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/input/targetprofile"
|
||||
)
|
||||
|
||||
// targetProfiles returns the per-target profiles read from input, or nil when
|
||||
// no target has one.
|
||||
func (r *Runner) targetProfiles() *targetprofile.Registry {
|
||||
provider, ok := r.inputProvider.(interface {
|
||||
TargetProfiles() *targetprofile.Registry
|
||||
})
|
||||
if !ok || !provider.TargetProfiles().Scoped() {
|
||||
return nil
|
||||
}
|
||||
return provider.TargetProfiles()
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import (
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/progress"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/globalmatchers"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/hostbackoff"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/hosterrorscache"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/interactsh"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/utils/excludematchers"
|
||||
@@ -106,6 +107,10 @@ func newNucleiExecutor(opts *NucleiExecutorOptions) (*nucleiExecutor, error) {
|
||||
executorOpts.HostErrorsCache = cache
|
||||
}
|
||||
|
||||
if opts.Options.HostBackoff {
|
||||
executorOpts.HostBackoff = hostbackoff.New(hostbackoff.Config{Max: opts.Options.HostBackoffMax})
|
||||
}
|
||||
|
||||
executorEngine := core.New(opts.Options)
|
||||
executorEngine.SetExecuterOptions(executorOpts)
|
||||
|
||||
@@ -117,7 +122,7 @@ func newNucleiExecutor(opts *NucleiExecutorOptions) (*nucleiExecutor, error) {
|
||||
|
||||
// If using input-file flags, only load http fuzzing based templates.
|
||||
loaderConfig := loader.NewConfig(opts.Options, opts.Catalog, executorOpts)
|
||||
if (!strings.EqualFold(opts.Options.InputFileMode, "list") || opts.Options.DAST || opts.Options.DASTServer) && !opts.Options.OfflineHTTP {
|
||||
if (!strings.EqualFold(opts.Options.InputFileMode, "list") || opts.Options.DAST || opts.Options.DASTServer || opts.Options.DASTProxy) && !opts.Options.OfflineHTTP {
|
||||
// if input type is not list (implicitly enable fuzzing), unless passive/offlinehttp
|
||||
opts.Options.DAST = true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
fileutil "github.com/projectdiscovery/utils/file"
|
||||
)
|
||||
|
||||
const (
|
||||
caCertFileName = "dast-proxy-ca.pem"
|
||||
caKeyFileName = "dast-proxy-ca.key"
|
||||
|
||||
caValidity = 2 * 365 * 24 * time.Hour
|
||||
|
||||
// The key can mint a certificate that the user's browser trusts for any
|
||||
// host, so it must never be readable by other accounts on the machine.
|
||||
caKeyFileMode os.FileMode = 0o600
|
||||
caCertFileMode os.FileMode = 0o644
|
||||
)
|
||||
|
||||
// CA is the certificate authority used to sign intercepted TLS connections.
|
||||
type CA struct {
|
||||
Certificate *tls.Certificate
|
||||
CertPath string
|
||||
CertPEM []byte
|
||||
}
|
||||
|
||||
// LoadOrCreateCA loads the interception CA from dir, generating and persisting
|
||||
// a new one on first use.
|
||||
//
|
||||
// goproxy ships a built-in CA whose private key is published with the library;
|
||||
// it is deliberately never used here. A per-installation key is the only thing
|
||||
// stopping a third party from minting certificates the user already trusts.
|
||||
func LoadOrCreateCA(dir string) (*CA, error) {
|
||||
certPath := filepath.Join(dir, caCertFileName)
|
||||
keyPath := filepath.Join(dir, caKeyFileName)
|
||||
|
||||
certExists, keyExists := fileutil.FileExists(certPath), fileutil.FileExists(keyPath)
|
||||
if certExists && keyExists {
|
||||
return loadCA(certPath, keyPath)
|
||||
}
|
||||
// Half a keypair is unusable, and regenerating over it would invalidate a
|
||||
// certificate the user may already have installed and trusted. Say which
|
||||
// file is orphaned instead of guessing.
|
||||
if certExists != keyExists {
|
||||
orphan := certPath
|
||||
if keyExists {
|
||||
orphan = keyPath
|
||||
}
|
||||
return nil, errors.Errorf("dast proxy CA is incomplete, remove %s to generate a new keypair", orphan)
|
||||
}
|
||||
return createCA(dir, certPath, keyPath)
|
||||
}
|
||||
|
||||
func loadCA(certPath, keyPath string) (*CA, error) {
|
||||
if err := verifyKeyPermissions(keyPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
certPEM, err := os.ReadFile(certPath)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not read dast proxy CA certificate")
|
||||
}
|
||||
keyPEM, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not read dast proxy CA key")
|
||||
}
|
||||
|
||||
certificate, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not parse dast proxy CA keypair")
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(certificate.Certificate[0])
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not parse dast proxy CA certificate")
|
||||
}
|
||||
if !leaf.IsCA {
|
||||
return nil, errors.Errorf("%s is not a certificate authority", certPath)
|
||||
}
|
||||
// Regenerating silently would swap the CA out from under a certificate the
|
||||
// user has already installed and trusted, so this is left to the user.
|
||||
if time.Now().After(leaf.NotAfter) {
|
||||
return nil, errors.Errorf("dast proxy CA expired on %s, remove %s and %s to generate a new one",
|
||||
leaf.NotAfter.Format(time.RFC3339), certPath, keyPath)
|
||||
}
|
||||
certificate.Leaf = leaf
|
||||
|
||||
return &CA{Certificate: &certificate, CertPath: certPath, CertPEM: certPEM}, nil
|
||||
}
|
||||
|
||||
func createCA(dir, certPath, keyPath string) (*CA, error) {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, errors.Wrap(err, "could not create dast proxy CA directory")
|
||||
}
|
||||
|
||||
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not generate dast proxy CA key")
|
||||
}
|
||||
serialNumber, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not generate dast proxy CA serial number")
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: serialNumber,
|
||||
Subject: pkix.Name{
|
||||
CommonName: "Nuclei DAST Proxy CA",
|
||||
Organization: []string{"ProjectDiscovery"},
|
||||
},
|
||||
NotBefore: now.Add(-1 * time.Hour), // tolerate clock skew on the client
|
||||
NotAfter: now.Add(caValidity),
|
||||
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign | x509.KeyUsageDigitalSignature,
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
// Sign leaf certificates only: a CA reachable through this one must not
|
||||
// be able to issue further authorities.
|
||||
MaxPathLen: 0,
|
||||
MaxPathLenZero: true,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not create dast proxy CA certificate")
|
||||
}
|
||||
|
||||
keyDER, err := x509.MarshalPKCS8PrivateKey(privateKey)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not marshal dast proxy CA key")
|
||||
}
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER})
|
||||
|
||||
// O_EXCL so a pre-existing path, including a symlink planted by another
|
||||
// user, can never be written through.
|
||||
if err := writeNewFile(keyPath, keyPEM, caKeyFileMode); err != nil {
|
||||
return nil, errors.Wrap(err, "could not write dast proxy CA key")
|
||||
}
|
||||
if err := writeNewFile(certPath, certPEM, caCertFileMode); err != nil {
|
||||
// Leaving the key behind would wedge every later start on the
|
||||
// incomplete-keypair check above.
|
||||
_ = os.Remove(keyPath)
|
||||
return nil, errors.Wrap(err, "could not write dast proxy CA certificate")
|
||||
}
|
||||
|
||||
leaf, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not parse generated dast proxy CA certificate")
|
||||
}
|
||||
return &CA{
|
||||
Certificate: &tls.Certificate{Certificate: [][]byte{der}, PrivateKey: privateKey, Leaf: leaf},
|
||||
CertPath: certPath,
|
||||
CertPEM: certPEM,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func writeNewFile(path string, contents []byte, mode os.FileMode) error {
|
||||
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := file.Write(contents); err != nil {
|
||||
_ = file.Close()
|
||||
return err
|
||||
}
|
||||
return file.Close()
|
||||
}
|
||||
|
||||
func verifyKeyPermissions(keyPath string) error {
|
||||
if runtime.GOOS == "windows" {
|
||||
return nil
|
||||
}
|
||||
info, err := os.Stat(keyPath)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not stat dast proxy CA key")
|
||||
}
|
||||
if mode := info.Mode().Perm(); mode&0o077 != 0 {
|
||||
return errors.Errorf("dast proxy CA key %s is readable by other users (%#o), run: chmod 600 %s",
|
||||
keyPath, mode, keyPath)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestLoadOrCreateCAGeneratesUsableAuthority(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
ca, err := LoadOrCreateCA(dir)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ca.Certificate.Leaf)
|
||||
require.True(t, ca.Certificate.Leaf.IsCA)
|
||||
require.True(t, ca.Certificate.Leaf.MaxPathLenZero, "CA must not be able to issue sub authorities")
|
||||
require.Contains(t, string(ca.CertPEM), "BEGIN CERTIFICATE")
|
||||
require.NotContains(t, string(ca.CertPEM), "PRIVATE KEY")
|
||||
require.Equal(t, filepath.Join(dir, caCertFileName), ca.CertPath)
|
||||
}
|
||||
|
||||
func TestLoadOrCreateCAKeyIsOwnerReadableOnly(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("unix file modes only")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
|
||||
_, err := LoadOrCreateCA(dir)
|
||||
require.NoError(t, err)
|
||||
|
||||
info, err := os.Stat(filepath.Join(dir, caKeyFileName))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, caKeyFileMode, info.Mode().Perm())
|
||||
}
|
||||
|
||||
func TestLoadOrCreateCAReusesExistingAuthority(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
first, err := LoadOrCreateCA(dir)
|
||||
require.NoError(t, err)
|
||||
second, err := LoadOrCreateCA(dir)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, first.Certificate.Leaf.SerialNumber, second.Certificate.Leaf.SerialNumber)
|
||||
}
|
||||
|
||||
func TestLoadOrCreateCARejectsGroupReadableKey(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("unix file modes only")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
|
||||
_, err := LoadOrCreateCA(dir)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.Chmod(filepath.Join(dir, caKeyFileName), 0o644))
|
||||
|
||||
_, err = LoadOrCreateCA(dir)
|
||||
require.ErrorContains(t, err, "readable by other users")
|
||||
}
|
||||
|
||||
func TestLoadOrCreateCARejectsIncompleteKeypair(t *testing.T) {
|
||||
for _, orphan := range []string{caCertFileName, caKeyFileName} {
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, orphan), []byte("x"), 0o600))
|
||||
|
||||
_, err := LoadOrCreateCA(dir)
|
||||
require.ErrorContains(t, err, "incomplete", orphan)
|
||||
require.ErrorContains(t, err, orphan)
|
||||
}
|
||||
}
|
||||
|
||||
// A failed certificate write must not leave the key behind, or the
|
||||
// incomplete-keypair check would wedge every later start.
|
||||
func TestLoadOrCreateCACleansUpKeyWhenCertificateWriteFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// A directory at the certificate path makes the write fail.
|
||||
require.NoError(t, os.Mkdir(filepath.Join(dir, caCertFileName), 0o755))
|
||||
|
||||
_, err := LoadOrCreateCA(dir)
|
||||
require.Error(t, err)
|
||||
require.NoFileExists(t, filepath.Join(dir, caKeyFileName), "orphaned key must be removed")
|
||||
|
||||
require.NoError(t, os.Remove(filepath.Join(dir, caCertFileName)))
|
||||
ca, err := LoadOrCreateCA(dir)
|
||||
require.NoError(t, err, "startup must recover once the blocker is gone")
|
||||
require.True(t, ca.Certificate.Leaf.IsCA)
|
||||
}
|
||||
@@ -0,0 +1,452 @@
|
||||
// Package proxy implements an intercepting HTTP proxy that feeds live traffic
|
||||
// into the nuclei DAST scanner. Requests are forwarded untouched; a copy of
|
||||
// each intercepted request is handed to the scanner out of band.
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/subtle"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/elazarl/goproxy"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/projectdiscovery/gologger"
|
||||
)
|
||||
|
||||
const (
|
||||
// Requests with a larger body are not mirrored at all: see captureRequest.
|
||||
maxCapturedBodySize = 4 << 20
|
||||
|
||||
readHeaderTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// hopByHopHeaders are stripped from mirrored requests: they belong to the
|
||||
// client-to-proxy leg and must not be replayed at a target.
|
||||
var hopByHopHeaders = []string{
|
||||
"Connection",
|
||||
"Keep-Alive",
|
||||
"Proxy-Authorization",
|
||||
"Proxy-Authenticate",
|
||||
"Proxy-Connection",
|
||||
"TE",
|
||||
"Trailer",
|
||||
"Transfer-Encoding",
|
||||
"Upgrade",
|
||||
}
|
||||
|
||||
// Options configures the intercepting proxy.
|
||||
type Options struct {
|
||||
// Address is the host:port to listen on.
|
||||
Address string
|
||||
// CADir is the directory holding the interception CA.
|
||||
CADir string
|
||||
// Username and Password gate the proxy with basic authentication. They are
|
||||
// mandatory when Address is not a loopback address.
|
||||
Username string
|
||||
Password string
|
||||
// Verbose enables goproxy's own request logging.
|
||||
Verbose bool
|
||||
// Intercept reports whether the TLS connection to host should be decrypted.
|
||||
// Hosts it rejects are tunnelled through without inspection. Optional;
|
||||
// every host is intercepted when nil.
|
||||
Intercept func(host string) bool
|
||||
// Submit hands a captured request to the scanner. It must not block, and
|
||||
// returns false when the request could not be queued.
|
||||
Submit func(rawHTTP, targetURL string) bool
|
||||
// ForwardProxy is an optional HTTP(S) proxy URL for the forwarding leg.
|
||||
// Ambient HTTP_PROXY / HTTPS_PROXY are ignored so a leftover environment
|
||||
// variable cannot silently chain this listener.
|
||||
ForwardProxy string
|
||||
}
|
||||
|
||||
// Proxy is an intercepting proxy that mirrors traffic into the scanner.
|
||||
type Proxy struct {
|
||||
options *Options
|
||||
ca *CA
|
||||
server *http.Server
|
||||
|
||||
intercepted atomic.Int64
|
||||
tunnelsPassedThrough atomic.Int64
|
||||
dropped atomic.Int64
|
||||
}
|
||||
|
||||
// requestState is what a proxied request carries between the goproxy hooks.
|
||||
// A CONNECT tunnel reuses one instance for every request inside it.
|
||||
type requestState struct {
|
||||
tunnelAuthorized bool
|
||||
rawHTTP string
|
||||
}
|
||||
|
||||
// Stats reports what the proxy has done with the traffic it has seen.
|
||||
type Stats struct {
|
||||
Intercepted int64
|
||||
// TunnelsPassedThrough counts CONNECT tunnels left encrypted, not requests:
|
||||
// their contents are never seen.
|
||||
TunnelsPassedThrough int64
|
||||
Dropped int64
|
||||
}
|
||||
|
||||
// New returns a proxy ready to be started.
|
||||
func New(options *Options) (*Proxy, error) {
|
||||
if options.Submit == nil {
|
||||
return nil, errors.New("proxy requires a submit callback")
|
||||
}
|
||||
if err := validateBinding(options); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := parseForwardProxy(options.ForwardProxy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ca, err := LoadOrCreateCA(options.CADir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
proxy := &Proxy{options: options, ca: ca}
|
||||
proxy.server = &http.Server{
|
||||
Handler: proxy.handler(),
|
||||
// Read and write timeouts would break long lived CONNECT tunnels, so
|
||||
// only the header read is bounded.
|
||||
ReadHeaderTimeout: readHeaderTimeout,
|
||||
}
|
||||
return proxy, nil
|
||||
}
|
||||
|
||||
// CertPath is the on-disk path of the CA certificate users need to trust.
|
||||
func (p *Proxy) CertPath() string { return p.ca.CertPath }
|
||||
|
||||
// CertPEM is the PEM encoded CA certificate. It never contains the key.
|
||||
func (p *Proxy) CertPEM() []byte { return p.ca.CertPEM }
|
||||
|
||||
// Stats returns the current proxy counters.
|
||||
func (p *Proxy) Stats() Stats {
|
||||
return Stats{
|
||||
Intercepted: p.intercepted.Load(),
|
||||
TunnelsPassedThrough: p.tunnelsPassedThrough.Load(),
|
||||
Dropped: p.dropped.Load(),
|
||||
}
|
||||
}
|
||||
|
||||
// Start listens and serves until Close is called.
|
||||
func (p *Proxy) Start() error {
|
||||
listener, err := net.Listen("tcp", p.options.Address)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not listen on dast proxy address")
|
||||
}
|
||||
if err := p.server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close shuts the proxy listener down.
|
||||
func (p *Proxy) Close() error {
|
||||
if p.server == nil {
|
||||
return nil
|
||||
}
|
||||
if err := p.server.Close(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Proxy) handler() http.Handler {
|
||||
handler := goproxy.NewProxyHttpServer()
|
||||
handler.Verbose = p.options.Verbose
|
||||
handler.Logger = proxyLogger{}
|
||||
handler.CertStore = newCertCache()
|
||||
// Stated rather than inherited from goproxy's default, because it is a
|
||||
// deliberate choice: the forwarding leg does not verify target certificates,
|
||||
// matching the rest of nuclei (see httpclientpool), since DAST targets
|
||||
// routinely serve self-signed ones. Interception inherently replaces the
|
||||
// browser's own validation, so this is reported at startup.
|
||||
forward, _ := parseForwardProxy(p.options.ForwardProxy)
|
||||
handler.Tr = &http.Transport{
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, // #nosec G402
|
||||
Proxy: forward,
|
||||
}
|
||||
|
||||
// Every action carries our own CA. goproxy's package level OkConnect and
|
||||
// MitmConnect close over its built-in CA, whose private key ships with the
|
||||
// library, so none of them are reused here.
|
||||
signWithOurCA := goproxy.TLSConfigFromCA(p.ca.Certificate)
|
||||
mitm := &goproxy.ConnectAction{Action: goproxy.ConnectMitm, TLSConfig: signWithOurCA}
|
||||
tunnel := &goproxy.ConnectAction{Action: goproxy.ConnectAccept, TLSConfig: signWithOurCA}
|
||||
// Answering CONNECT with a challenge rather than dropping the connection is
|
||||
// what lets a browser prompt for the proxy credentials.
|
||||
challenge := &goproxy.ConnectAction{
|
||||
Action: goproxy.ConnectHijack,
|
||||
Hijack: func(req *http.Request, client net.Conn, _ *goproxy.ProxyCtx) {
|
||||
defer func() { _ = client.Close() }()
|
||||
if err := authRequiredResponse(req).Write(client); err != nil {
|
||||
gologger.Debug().Msgf("Could not write proxy auth challenge: %s", err)
|
||||
}
|
||||
},
|
||||
TLSConfig: signWithOurCA,
|
||||
}
|
||||
|
||||
handler.OnRequest().HandleConnectFunc(func(host string, ctx *goproxy.ProxyCtx) (*goproxy.ConnectAction, string) {
|
||||
if !p.authorized(ctx.Req) {
|
||||
return challenge, host
|
||||
}
|
||||
// A client only sends Proxy-Authorization on CONNECT, so the successful
|
||||
// check is recorded on the context that every request inside the tunnel
|
||||
// goes on to share, rather than being repeated per request.
|
||||
ctx.UserData = &requestState{tunnelAuthorized: true}
|
||||
|
||||
if p.options.Intercept != nil && !p.options.Intercept(host) {
|
||||
p.tunnelsPassedThrough.Add(1)
|
||||
return tunnel, host
|
||||
}
|
||||
return mitm, host
|
||||
})
|
||||
|
||||
handler.OnRequest().DoFunc(func(req *http.Request, ctx *goproxy.ProxyCtx) (*http.Request, *http.Response) {
|
||||
state, _ := ctx.UserData.(*requestState)
|
||||
if state == nil || !state.tunnelAuthorized {
|
||||
if !p.authorized(req) {
|
||||
return req, authRequiredResponse(req)
|
||||
}
|
||||
state = &requestState{}
|
||||
ctx.UserData = state
|
||||
}
|
||||
// The body has to be captured before the transport consumes it, but the
|
||||
// response is only available later, so the raw request rides along on
|
||||
// the context until then.
|
||||
if raw, ok := captureRequest(req); ok {
|
||||
state.rawHTTP = raw
|
||||
}
|
||||
return req, nil
|
||||
})
|
||||
|
||||
handler.OnResponse().DoFunc(func(resp *http.Response, ctx *goproxy.ProxyCtx) *http.Response {
|
||||
p.submit(ctx)
|
||||
return resp
|
||||
})
|
||||
|
||||
return handler
|
||||
}
|
||||
|
||||
// submit mirrors an intercepted request into the scanner. It runs while the
|
||||
// client is still waiting for its response, so it must stay non-blocking.
|
||||
func (p *Proxy) submit(ctx *goproxy.ProxyCtx) {
|
||||
if ctx == nil || ctx.Req == nil {
|
||||
return
|
||||
}
|
||||
state, ok := ctx.UserData.(*requestState)
|
||||
if !ok || state.rawHTTP == "" {
|
||||
return
|
||||
}
|
||||
raw := state.rawHTTP
|
||||
// Cleared so a tunnel that reuses this state cannot mirror it twice.
|
||||
state.rawHTTP = ""
|
||||
|
||||
p.intercepted.Add(1)
|
||||
if !p.options.Submit(raw, ctx.Req.URL.String()) {
|
||||
p.dropped.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Proxy) authorized(req *http.Request) bool {
|
||||
if p.options.Username == "" && p.options.Password == "" {
|
||||
return true
|
||||
}
|
||||
if req == nil {
|
||||
return false
|
||||
}
|
||||
username, password, ok := parseProxyAuth(req.Header.Get("Proxy-Authorization"))
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
// Both comparisons always run so the result does not leak which half failed.
|
||||
userMatch := subtle.ConstantTimeCompare([]byte(username), []byte(p.options.Username))
|
||||
passMatch := subtle.ConstantTimeCompare([]byte(password), []byte(p.options.Password))
|
||||
return userMatch&passMatch == 1
|
||||
}
|
||||
|
||||
func parseProxyAuth(header string) (username, password string, ok bool) {
|
||||
const prefix = "Basic "
|
||||
if len(header) < len(prefix) || !strings.EqualFold(header[:len(prefix)], prefix) {
|
||||
return "", "", false
|
||||
}
|
||||
decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(header[len(prefix):]))
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
username, password, found := strings.Cut(string(decoded), ":")
|
||||
if !found {
|
||||
return "", "", false
|
||||
}
|
||||
return username, password, true
|
||||
}
|
||||
|
||||
func authRequiredResponse(req *http.Request) *http.Response {
|
||||
resp := goproxy.NewResponse(req, "text/plain", http.StatusProxyAuthRequired, "proxy authentication required\n")
|
||||
resp.Header.Set("Proxy-Authenticate", `Basic realm="nuclei dast proxy"`)
|
||||
// goproxy leaves the protocol version unset, which writes a "HTTP/0.0"
|
||||
// status line that strict clients reject when this is hijacked onto a raw
|
||||
// connection to answer CONNECT.
|
||||
resp.Proto, resp.ProtoMajor, resp.ProtoMinor = "HTTP/1.1", 1, 1
|
||||
resp.Close = true
|
||||
return resp
|
||||
}
|
||||
|
||||
// captureRequest renders the request as raw HTTP for the scanner, leaving the
|
||||
// request that gets forwarded untouched.
|
||||
func captureRequest(req *http.Request) (string, bool) {
|
||||
if req == nil || req.URL == nil {
|
||||
return "", false
|
||||
}
|
||||
if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
|
||||
return "", false
|
||||
}
|
||||
// A body with no declared length, or one above the cap, is not mirrored at
|
||||
// all: fuzzing a large upload is not useful, buffering it would let proxied
|
||||
// traffic drive the scanner's memory use, and capturing only the headers
|
||||
// would hand the scanner a request that contradicts its own Content-Length.
|
||||
if hasBody(req) && (req.ContentLength < 0 || req.ContentLength > maxCapturedBodySize) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
body, err := bufferBody(req)
|
||||
if err != nil {
|
||||
gologger.Debug().Msgf("Could not buffer proxied request body: %s", err)
|
||||
return "", false
|
||||
}
|
||||
|
||||
capture := req.Clone(req.Context())
|
||||
capture.Body = io.NopCloser(bytes.NewReader(body))
|
||||
capture.ContentLength = int64(len(body))
|
||||
// The proxy's own absolute-form request URI is not what the target sees.
|
||||
capture.RequestURI = ""
|
||||
// goproxy only strips hop-by-hop headers after this hook has run, so the
|
||||
// copy is sanitised here. The proxy credential in particular must never be
|
||||
// replayed to a target or written into scan output.
|
||||
stripHopByHopHeaders(capture.Header)
|
||||
|
||||
dumped, err := httputil.DumpRequest(capture, true)
|
||||
if err != nil {
|
||||
gologger.Debug().Msgf("Could not capture proxied request: %s", err)
|
||||
return "", false
|
||||
}
|
||||
return string(dumped), true
|
||||
}
|
||||
|
||||
func stripHopByHopHeaders(header http.Header) {
|
||||
for _, extra := range header.Values("Connection") {
|
||||
for _, name := range strings.Split(extra, ",") {
|
||||
if name = strings.TrimSpace(name); name != "" {
|
||||
header.Del(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, name := range hopByHopHeaders {
|
||||
header.Del(name)
|
||||
}
|
||||
}
|
||||
|
||||
func parseForwardProxy(raw string) (func(*http.Request) (*url.URL, error), error) {
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Host == "" {
|
||||
return nil, errors.Errorf("invalid dast proxy forward proxy %q", raw)
|
||||
}
|
||||
return http.ProxyURL(parsed), nil
|
||||
}
|
||||
|
||||
func hasBody(req *http.Request) bool {
|
||||
return req.Body != nil && req.Body != http.NoBody
|
||||
}
|
||||
|
||||
// bufferBody reads the body and restores it on req so the request can still be
|
||||
// forwarded after being mirrored.
|
||||
func bufferBody(req *http.Request) ([]byte, error) {
|
||||
if !hasBody(req) {
|
||||
return nil, nil
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(req.Body, maxCapturedBodySize))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Body = io.NopCloser(bytes.NewReader(body))
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func validateBinding(options *Options) error {
|
||||
host, _, err := net.SplitHostPort(options.Address)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "invalid dast proxy address %q", options.Address)
|
||||
}
|
||||
if (options.Username == "") != (options.Password == "") {
|
||||
return errors.New("dast proxy auth needs both a username and a password")
|
||||
}
|
||||
if options.Username != "" || isLoopback(host) {
|
||||
return nil
|
||||
}
|
||||
// An unauthenticated proxy on a routable address is an open relay for
|
||||
// anyone who can reach it.
|
||||
return errors.Errorf("refusing to bind dast proxy to %q without authentication, pass -dast-proxy-auth user:pass or bind to localhost", options.Address)
|
||||
}
|
||||
|
||||
func isLoopback(host string) bool {
|
||||
if host == "" {
|
||||
// A bare port binds every interface.
|
||||
return false
|
||||
}
|
||||
if host == "localhost" {
|
||||
return true
|
||||
}
|
||||
if ip := net.ParseIP(host); ip != nil {
|
||||
return ip.IsLoopback()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// certCache memoises the leaf certificate minted for each intercepted host.
|
||||
type certCache struct {
|
||||
certs sync.Map
|
||||
}
|
||||
|
||||
func newCertCache() *certCache { return &certCache{} }
|
||||
|
||||
func (c *certCache) Fetch(hostname string, gen func() (*tls.Certificate, error)) (*tls.Certificate, error) {
|
||||
if cached, ok := c.certs.Load(hostname); ok {
|
||||
return cached.(*tls.Certificate), nil
|
||||
}
|
||||
cert, err := gen()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
actual, _ := c.certs.LoadOrStore(hostname, cert)
|
||||
return actual.(*tls.Certificate), nil
|
||||
}
|
||||
|
||||
type proxyLogger struct{}
|
||||
|
||||
func (proxyLogger) Printf(format string, v ...any) {
|
||||
gologger.Debug().Msgf(format, v...)
|
||||
}
|
||||
|
||||
// ParseAuth splits a user:pass credential pair as accepted on the command line.
|
||||
func ParseAuth(value string) (username, password string, err error) {
|
||||
if value == "" {
|
||||
return "", "", nil
|
||||
}
|
||||
username, password, found := strings.Cut(value, ":")
|
||||
if !found || username == "" || password == "" {
|
||||
return "", "", errors.New("dast proxy auth must be in user:pass format")
|
||||
}
|
||||
return username, password, nil
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/input/types"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type captured struct {
|
||||
rawHTTP string
|
||||
targetURL string
|
||||
}
|
||||
|
||||
// newTestProxy returns a proxy served over a local listener along with the
|
||||
// requests it mirrors into the scanner.
|
||||
func newTestProxy(t *testing.T, options *Options) (*Proxy, *url.URL, <-chan captured) {
|
||||
t.Helper()
|
||||
|
||||
mirrored := make(chan captured, 8)
|
||||
options.CADir = t.TempDir()
|
||||
options.Submit = func(rawHTTP, targetURL string) bool {
|
||||
mirrored <- captured{rawHTTP: rawHTTP, targetURL: targetURL}
|
||||
return true
|
||||
}
|
||||
if options.Address == "" {
|
||||
options.Address = "127.0.0.1:0"
|
||||
}
|
||||
|
||||
instance, err := New(options)
|
||||
require.NoError(t, err)
|
||||
|
||||
frontend := httptest.NewServer(instance.server.Handler)
|
||||
t.Cleanup(frontend.Close)
|
||||
|
||||
proxyURL, err := url.Parse(frontend.URL)
|
||||
require.NoError(t, err)
|
||||
return instance, proxyURL, mirrored
|
||||
}
|
||||
|
||||
// skipInstance drops the proxy handle for tests that only drive it over HTTP.
|
||||
func skipInstance(_ *Proxy, proxyURL *url.URL, mirrored <-chan captured) (*url.URL, <-chan captured) {
|
||||
return proxyURL, mirrored
|
||||
}
|
||||
|
||||
func clientThrough(proxyURL *url.URL) *http.Client {
|
||||
return &http.Client{Transport: &http.Transport{Proxy: http.ProxyURL(proxyURL)}}
|
||||
}
|
||||
|
||||
func TestProxyForwardsAndMirrorsRequest(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.WriteString(w, "upstream-body")
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
proxyURL, mirrored := skipInstance(newTestProxy(t, &Options{}))
|
||||
|
||||
resp, err := clientThrough(proxyURL).Post(upstream.URL+"/login?next=/admin", "application/json", strings.NewReader(`{"user":"admin"}`))
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.Equal(t, "upstream-body", string(body), "proxy must forward the upstream response untouched")
|
||||
|
||||
got := <-mirrored
|
||||
require.Equal(t, upstream.URL+"/login?next=/admin", got.targetURL)
|
||||
|
||||
// The scanner consumes the mirrored traffic through this parser, so the
|
||||
// capture is only useful if it round-trips.
|
||||
parsed, err := types.ParseRawRequestWithURL(got.rawHTTP, got.targetURL)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.MethodPost, parsed.Request.Method)
|
||||
require.Equal(t, `{"user":"admin"}`, parsed.Request.Body)
|
||||
require.Equal(t, "/login", parsed.URL.Path)
|
||||
}
|
||||
|
||||
func TestProxyRequiresAuthWhenConfigured(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, "upstream-body")
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
proxyURL, mirrored := skipInstance(newTestProxy(t, &Options{Username: "nuclei", Password: "s3cret"}))
|
||||
|
||||
resp, err := clientThrough(proxyURL).Get(upstream.URL + "/")
|
||||
require.NoError(t, err)
|
||||
_ = resp.Body.Close()
|
||||
require.Equal(t, http.StatusProxyAuthRequired, resp.StatusCode)
|
||||
require.Contains(t, resp.Header.Get("Proxy-Authenticate"), "Basic")
|
||||
require.Empty(t, mirrored, "an unauthenticated request must not reach the scanner")
|
||||
|
||||
authenticated := proxyURL
|
||||
authenticated.User = url.UserPassword("nuclei", "s3cret")
|
||||
resp, err = clientThrough(authenticated).Get(upstream.URL + "/")
|
||||
require.NoError(t, err)
|
||||
_ = resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.Equal(t, upstream.URL+"/", (<-mirrored).targetURL)
|
||||
}
|
||||
|
||||
func TestProxyRejectsWrongCredentials(t *testing.T) {
|
||||
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {}))
|
||||
defer upstream.Close()
|
||||
|
||||
proxyURL, _ := skipInstance(newTestProxy(t, &Options{Username: "nuclei", Password: "s3cret"}))
|
||||
proxyURL.User = url.UserPassword("nuclei", "wrong")
|
||||
|
||||
resp, err := clientThrough(proxyURL).Get(upstream.URL + "/")
|
||||
require.NoError(t, err)
|
||||
_ = resp.Body.Close()
|
||||
require.Equal(t, http.StatusProxyAuthRequired, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestNewRefusesUnauthenticatedNonLoopbackBind(t *testing.T) {
|
||||
for _, address := range []string{"0.0.0.0:9056", "192.0.2.10:9056", ":9056"} {
|
||||
_, err := New(&Options{
|
||||
Address: address,
|
||||
CADir: t.TempDir(),
|
||||
Submit: func(string, string) bool { return true },
|
||||
})
|
||||
require.ErrorContains(t, err, "refusing to bind", address)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAllowsLoopbackAndAuthenticatedBinds(t *testing.T) {
|
||||
for _, options := range []*Options{
|
||||
{Address: "127.0.0.1:9056"},
|
||||
{Address: "localhost:9056"},
|
||||
{Address: "[::1]:9056"},
|
||||
{Address: "0.0.0.0:9056", Username: "nuclei", Password: "s3cret"},
|
||||
} {
|
||||
options.CADir = t.TempDir()
|
||||
options.Submit = func(string, string) bool { return true }
|
||||
_, err := New(options)
|
||||
require.NoError(t, err, options.Address)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRequiresBothAuthHalves(t *testing.T) {
|
||||
_, err := New(&Options{
|
||||
Address: "127.0.0.1:9056",
|
||||
CADir: t.TempDir(),
|
||||
Username: "nuclei",
|
||||
Submit: func(string, string) bool { return true },
|
||||
})
|
||||
require.ErrorContains(t, err, "both a username and a password")
|
||||
}
|
||||
|
||||
func TestParseAuth(t *testing.T) {
|
||||
username, password, err := ParseAuth("nuclei:s3cret")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "nuclei", username)
|
||||
require.Equal(t, "s3cret", password)
|
||||
|
||||
username, password, err = ParseAuth("")
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, username)
|
||||
require.Empty(t, password)
|
||||
|
||||
for _, invalid := range []string{"nuclei", ":s3cret", "nuclei:"} {
|
||||
_, _, err := ParseAuth(invalid)
|
||||
require.ErrorContains(t, err, "user:pass", invalid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptureRequestSkipsUnboundedBody(t *testing.T) {
|
||||
request, err := http.NewRequest(http.MethodPost, "http://example.com/upload", strings.NewReader("payload"))
|
||||
require.NoError(t, err)
|
||||
request.ContentLength = maxCapturedBodySize + 1
|
||||
|
||||
_, ok := captureRequest(request)
|
||||
require.False(t, ok, "an oversized body must not be buffered")
|
||||
|
||||
request.ContentLength = int64(len("payload"))
|
||||
raw, ok := captureRequest(request)
|
||||
require.True(t, ok)
|
||||
require.Contains(t, raw, "payload")
|
||||
}
|
||||
|
||||
func TestCaptureRequestLeavesForwardedRequestIntact(t *testing.T) {
|
||||
request, err := http.NewRequest(http.MethodPost, "http://example.com/login", strings.NewReader("user=admin"))
|
||||
require.NoError(t, err)
|
||||
|
||||
raw, ok := captureRequest(request)
|
||||
require.True(t, ok)
|
||||
require.Contains(t, raw, "user=admin")
|
||||
|
||||
// The forwarded request still has to be readable after being mirrored.
|
||||
forwarded, err := io.ReadAll(request.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "user=admin", string(forwarded))
|
||||
}
|
||||
|
||||
func TestCaptureRequestDropsProxyCredentials(t *testing.T) {
|
||||
request, err := http.NewRequest(http.MethodGet, "http://example.com/", nil)
|
||||
require.NoError(t, err)
|
||||
request.RequestURI = "http://example.com/" // absolute form, as a proxy receives it
|
||||
request.Header.Set("Proxy-Authorization", "Basic bnVjbGVpOnMzY3JldA==")
|
||||
request.Header.Set("Proxy-Connection", "keep-alive")
|
||||
request.Header.Set("Authorization", "Bearer target-token")
|
||||
|
||||
raw, ok := captureRequest(request)
|
||||
require.True(t, ok)
|
||||
require.NotContains(t, raw, "Proxy-Authorization", "the proxy credential must never reach the scanner")
|
||||
require.NotContains(t, raw, "bnVjbGVpOnMzY3JldA==")
|
||||
require.NotContains(t, raw, "Proxy-Connection")
|
||||
require.Contains(t, raw, "Bearer target-token", "the target's own credentials must be preserved")
|
||||
require.True(t, strings.HasPrefix(raw, "GET / HTTP/1.1"), "request line must be origin form, got %q", raw)
|
||||
}
|
||||
|
||||
func TestCaptureRequestDropsHopByHopHeaders(t *testing.T) {
|
||||
request, err := http.NewRequest(http.MethodGet, "http://example.com/", nil)
|
||||
require.NoError(t, err)
|
||||
request.Header.Set("Connection", "close, X-Proxy-Debug")
|
||||
request.Header.Set("X-Proxy-Debug", "1")
|
||||
request.Header.Set("Keep-Alive", "timeout=5")
|
||||
request.Header.Set("TE", "trailers")
|
||||
request.Header.Set("X-Custom", "keep")
|
||||
|
||||
raw, ok := captureRequest(request)
|
||||
require.True(t, ok)
|
||||
require.NotContains(t, raw, "X-Proxy-Debug")
|
||||
require.NotContains(t, raw, "Keep-Alive")
|
||||
require.NotContains(t, raw, "TE:")
|
||||
require.NotContains(t, raw, "Connection:")
|
||||
require.Contains(t, raw, "X-Custom: keep")
|
||||
}
|
||||
|
||||
func TestNewRejectsInvalidForwardProxy(t *testing.T) {
|
||||
_, err := New(&Options{
|
||||
Address: "127.0.0.1:9056",
|
||||
CADir: t.TempDir(),
|
||||
ForwardProxy: "://bad",
|
||||
Submit: func(string, string) bool { return true },
|
||||
})
|
||||
require.ErrorContains(t, err, "forward proxy")
|
||||
}
|
||||
|
||||
func TestCaptureRequestRejectsNonHTTPScheme(t *testing.T) {
|
||||
request, err := http.NewRequest(http.MethodGet, "http://example.com/", nil)
|
||||
require.NoError(t, err)
|
||||
request.URL.Scheme = "ftp"
|
||||
|
||||
_, ok := captureRequest(request)
|
||||
require.False(t, ok)
|
||||
}
|
||||
|
||||
// A client only sends Proxy-Authorization on CONNECT, so requests inside an
|
||||
// established tunnel must not be challenged again.
|
||||
func TestProxyAuthorizesTunnelOnceOnConnect(t *testing.T) {
|
||||
upstream := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, "upstream-body")
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
instance, proxyURL, mirrored := newTestProxy(t, &Options{Username: "nuclei", Password: "s3cret"})
|
||||
proxyURL.User = url.UserPassword("nuclei", "s3cret")
|
||||
|
||||
roots := x509.NewCertPool()
|
||||
require.True(t, roots.AppendCertsFromPEM(instance.CertPEM()))
|
||||
client := &http.Client{Transport: &http.Transport{
|
||||
Proxy: http.ProxyURL(proxyURL),
|
||||
TLSClientConfig: &tls.Config{RootCAs: roots, MinVersion: tls.VersionTLS12},
|
||||
}}
|
||||
|
||||
resp, err := client.Get(upstream.URL + "/admin?id=1")
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode, "a request inside an authorized tunnel must not be challenged")
|
||||
require.Equal(t, "upstream-body", string(body))
|
||||
|
||||
got := <-mirrored
|
||||
require.Equal(t, upstream.URL+"/admin?id=1", got.targetURL)
|
||||
require.True(t, strings.HasPrefix(got.rawHTTP, "GET /admin?id=1 HTTP/1.1"), "got %q", got.rawHTTP)
|
||||
}
|
||||
|
||||
func TestProxyChallengesUnauthenticatedConnect(t *testing.T) {
|
||||
_, proxyURL, _ := newTestProxy(t, &Options{Username: "nuclei", Password: "s3cret"})
|
||||
|
||||
conn, err := net.Dial("tcp", proxyURL.Host)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
_, err = io.WriteString(conn, "CONNECT example.com:443 HTTP/1.1\r\nHost: example.com:443\r\n\r\n")
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, err := http.ReadResponse(bufio.NewReader(conn), nil)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
// A malformed status line leaves a browser unable to prompt for credentials.
|
||||
require.Equal(t, http.StatusProxyAuthRequired, resp.StatusCode)
|
||||
require.Equal(t, 1, resp.ProtoMajor)
|
||||
require.Equal(t, 1, resp.ProtoMinor)
|
||||
require.Contains(t, resp.Header.Get("Proxy-Authenticate"), "Basic")
|
||||
}
|
||||
|
||||
func TestProxyPassesThroughExcludedHostsWithoutMITM(t *testing.T) {
|
||||
upstream := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = io.WriteString(w, "upstream-body")
|
||||
}))
|
||||
defer upstream.Close()
|
||||
|
||||
upstreamURL, err := url.Parse(upstream.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
instance, proxyURL, mirrored := newTestProxy(t, &Options{
|
||||
Intercept: func(host string) bool {
|
||||
return host != upstreamURL.Host
|
||||
},
|
||||
})
|
||||
|
||||
// Trust only the upstream certificate. An intercepted connection is
|
||||
// signed by the nuclei CA and would fail this handshake.
|
||||
upstreamRoots := x509.NewCertPool()
|
||||
upstreamRoots.AddCert(upstream.Certificate())
|
||||
client := &http.Client{Transport: &http.Transport{
|
||||
Proxy: http.ProxyURL(proxyURL),
|
||||
TLSClientConfig: &tls.Config{RootCAs: upstreamRoots, MinVersion: tls.VersionTLS12},
|
||||
}}
|
||||
resp, err := client.Get(upstream.URL + "/secret")
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.Equal(t, "upstream-body", string(body))
|
||||
require.True(t, resp.TLS.PeerCertificates[0].Equal(upstream.Certificate()),
|
||||
"pass-through must present the target certificate, not a nuclei-minted one")
|
||||
|
||||
require.Equal(t, int64(1), instance.Stats().TunnelsPassedThrough)
|
||||
require.Zero(t, instance.Stats().Intercepted)
|
||||
require.Empty(t, mirrored)
|
||||
}
|
||||
@@ -75,3 +75,15 @@ func (m *Manager) validateURL(URL string) (bool, error) {
|
||||
}
|
||||
return inScopeMatched, nil
|
||||
}
|
||||
|
||||
// IsExplicitlyOutOfScope reports whether the URL matches an out-of-scope rule.
|
||||
// It exists for decisions that can only see a host, such as whether to decrypt
|
||||
// a CONNECT tunnel, where in-scope rules carrying a path can never match.
|
||||
func (m *Manager) IsExplicitlyOutOfScope(URL *url.URL) bool {
|
||||
for _, item := range m.outOfScope {
|
||||
if item.MatchString(URL.String()) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package scope
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
urlutil "github.com/projectdiscovery/utils/url"
|
||||
@@ -24,3 +25,20 @@ func TestManagerValidate(t *testing.T) {
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func TestManagerIsExplicitlyOutOfScope(t *testing.T) {
|
||||
manager, err := NewManager(
|
||||
[]string{`^https://app\.example\.com/api/`},
|
||||
[]string{`^https://telemetry\.example\.com/`},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
excluded, err := url.Parse("https://telemetry.example.com/")
|
||||
require.NoError(t, err)
|
||||
require.True(t, manager.IsExplicitlyOutOfScope(excluded))
|
||||
|
||||
// Not matching an in-scope rule is not the same as being excluded.
|
||||
unlisted, err := url.Parse("https://app.example.com/")
|
||||
require.NoError(t, err)
|
||||
require.False(t, manager.IsExplicitlyOutOfScope(unlisted))
|
||||
}
|
||||
+148
-10
@@ -3,8 +3,10 @@ package server
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -13,6 +15,7 @@ import (
|
||||
|
||||
"github.com/alitto/pond"
|
||||
"github.com/projectdiscovery/gologger"
|
||||
"github.com/projectdiscovery/nuclei/v3/internal/server/proxy"
|
||||
"github.com/projectdiscovery/nuclei/v3/internal/server/scope"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz/stats"
|
||||
@@ -29,6 +32,7 @@ type DASTServer struct {
|
||||
tasksPool *pond.WorkerPool
|
||||
deduplicator *requestDeduplicator
|
||||
scopeManager *scope.Manager
|
||||
proxy *proxy.Proxy
|
||||
startTime time.Time
|
||||
|
||||
// metrics
|
||||
@@ -49,6 +53,21 @@ type Options struct {
|
||||
// Verbose is a flag that controls verbose output
|
||||
Verbose bool
|
||||
|
||||
// ProxyAddress enables the intercepting proxy front-end on this address
|
||||
ProxyAddress string
|
||||
// ProxyCADir is the directory holding the proxy interception CA
|
||||
ProxyCADir string
|
||||
// ProxyUsername and ProxyPassword gate the intercepting proxy with basic
|
||||
// authentication. Mandatory when ProxyAddress is not a loopback address.
|
||||
ProxyUsername string
|
||||
ProxyPassword string
|
||||
// EnableFuzzAPI exposes POST /fuzz. Proxy-only mode leaves it off so the
|
||||
// stats/CA listener cannot be used to inject arbitrary raw HTTP.
|
||||
EnableFuzzAPI bool
|
||||
// ForwardProxy is an optional HTTP(S) proxy URL for the intercepting
|
||||
// proxy's forwarding leg. Ambient HTTP_PROXY is not used.
|
||||
ForwardProxy string
|
||||
|
||||
// Scope fields for fuzzer
|
||||
InScope []string
|
||||
OutScope []string
|
||||
@@ -97,17 +116,70 @@ func New(options *Options) (*DASTServer, error) {
|
||||
}
|
||||
server.scopeManager = scopeManager
|
||||
|
||||
if options.ProxyAddress != "" {
|
||||
if err := server.setupProxy(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
gologger.Debug().Msgf("Using %d parallel tasks with %d buffer", maxWorkers, bufferSize)
|
||||
if options.Token != "" {
|
||||
builder.WriteString(" (with token)")
|
||||
}
|
||||
gologger.Info().Msgf("DAST Server API: %s", server.buildURL("/fuzz"))
|
||||
if options.EnableFuzzAPI {
|
||||
gologger.Info().Msgf("DAST Server API: %s", server.buildURL("/fuzz"))
|
||||
}
|
||||
gologger.Info().Msgf("DAST Server Stats URL: %s", server.buildURL("/stats"))
|
||||
|
||||
return server, nil
|
||||
}
|
||||
|
||||
// setupProxy wires the intercepting proxy front-end into the same queue the
|
||||
// HTTP API feeds.
|
||||
func (s *DASTServer) setupProxy() error {
|
||||
interceptingProxy, err := proxy.New(&proxy.Options{
|
||||
Address: s.options.ProxyAddress,
|
||||
CADir: s.options.ProxyCADir,
|
||||
Username: s.options.ProxyUsername,
|
||||
Password: s.options.ProxyPassword,
|
||||
Verbose: s.options.Verbose,
|
||||
Intercept: s.shouldIntercept,
|
||||
Submit: s.Submit,
|
||||
ForwardProxy: s.options.ForwardProxy,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.proxy = interceptingProxy
|
||||
|
||||
gologger.Info().Msgf("DAST Proxy: http://%s", s.options.ProxyAddress)
|
||||
gologger.Info().Msgf("DAST Proxy CA certificate: %s (install it to intercept https)", interceptingProxy.CertPath())
|
||||
gologger.Info().Msgf("DAST Proxy CA download: %s", s.buildURL("/ca"))
|
||||
// Info, not Warning: gologger orders LevelWarning above LevelInfo, so
|
||||
// warnings are filtered out at default verbosity and these two notices
|
||||
// describe what interception does to the user's traffic.
|
||||
gologger.Info().Msgf("DAST Proxy: intercepted https is re-originated by nuclei, target certificates are not verified")
|
||||
if len(s.options.InScope) == 0 && len(s.options.OutScope) == 0 {
|
||||
gologger.Info().Msgf("DAST Proxy: no scope set, every proxied https host will be decrypted (narrow it with -fuzz-scope / -fuzz-out-scope)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// shouldIntercept decides whether a CONNECT tunnel is decrypted. Only an
|
||||
// explicit out-of-scope match leaves a host encrypted, because in-scope
|
||||
// patterns may carry a path that a CONNECT host can never match against.
|
||||
func (s *DASTServer) shouldIntercept(hostPort string) bool {
|
||||
host := hostPort
|
||||
// Only the default port is dropped, so the synthesised URL has the same
|
||||
// shape as the request URLs these rules are applied to once a request has
|
||||
// actually been captured.
|
||||
if parsedHost, port, err := net.SplitHostPort(hostPort); err == nil && port == "443" {
|
||||
host = parsedHost
|
||||
}
|
||||
return !s.scopeManager.IsExplicitlyOutOfScope(&url.URL{Scheme: "https", Host: host, Path: "/"})
|
||||
}
|
||||
|
||||
func NewStatsServer(fuzzStatsDB *stats.Tracker) (*DASTServer, error) {
|
||||
server := &DASTServer{
|
||||
options: &Options{},
|
||||
@@ -124,6 +196,11 @@ func NewStatsServer(fuzzStatsDB *stats.Tracker) (*DASTServer, error) {
|
||||
}
|
||||
|
||||
func (s *DASTServer) Close() {
|
||||
if s.proxy != nil {
|
||||
if err := s.proxy.Close(); err != nil {
|
||||
gologger.Warning().Msgf("Could not close dast proxy: %s", err)
|
||||
}
|
||||
}
|
||||
if s.nucleiExecutor != nil {
|
||||
s.nucleiExecutor.Close()
|
||||
}
|
||||
@@ -160,16 +237,18 @@ func (s *DASTServer) optionsOrDefault() *Options {
|
||||
}
|
||||
|
||||
func (s *DASTServer) setupHandlers(onlyStats bool) {
|
||||
opts := s.optionsOrDefault()
|
||||
mux := http.NewServeMux()
|
||||
// POST /fuzz - Queue a request for fuzzing
|
||||
if !onlyStats {
|
||||
if !onlyStats && opts.EnableFuzzAPI {
|
||||
mux.HandleFunc("POST /fuzz", s.handleRequest)
|
||||
}
|
||||
mux.HandleFunc("GET /stats", s.handleStats)
|
||||
mux.HandleFunc("GET /stats.json", s.handleStatsJSON)
|
||||
if !onlyStats && opts.ProxyAddress != "" {
|
||||
mux.HandleFunc("GET /ca", s.handleProxyCA)
|
||||
}
|
||||
|
||||
handler := http.Handler(mux)
|
||||
opts := s.optionsOrDefault()
|
||||
if opts.Token != "" {
|
||||
handler = s.tokenAuthMiddleware(handler)
|
||||
}
|
||||
@@ -187,12 +266,37 @@ func (s *DASTServer) Start() error {
|
||||
s.setupHandlers(false)
|
||||
}
|
||||
s.httpServer.Addr = s.optionsOrDefault().Address
|
||||
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
|
||||
if s.proxy == nil {
|
||||
return s.serveAPI()
|
||||
}
|
||||
|
||||
// Both front-ends feed the same queue; whichever fails first ends the run.
|
||||
errs := make(chan error, 2)
|
||||
go func() { errs <- s.serveAPI() }()
|
||||
go func() { errs <- s.proxy.Start() }()
|
||||
return <-errs
|
||||
}
|
||||
|
||||
func (s *DASTServer) serveAPI() error {
|
||||
if err := s.httpServer.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// handleProxyCA serves the interception CA certificate so it can be installed.
|
||||
// Only the certificate is ever served, never the private key.
|
||||
func (s *DASTServer) handleProxyCA(w http.ResponseWriter, _ *http.Request) {
|
||||
if s.proxy == nil {
|
||||
writeServerJSON(w, http.StatusNotFound, map[string]string{"error": "proxy is not enabled"})
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/x-pem-file")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="nuclei-dast-proxy-ca.pem"`)
|
||||
_, _ = w.Write(s.proxy.CertPEM())
|
||||
}
|
||||
|
||||
// PostRequestsHandlerRequest is the request body for the /fuzz POST handler.
|
||||
type PostRequestsHandlerRequest struct {
|
||||
RawHTTP string `json:"raw_http"`
|
||||
@@ -214,13 +318,27 @@ func (s *DASTServer) handleRequest(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
s.endpointsInQueue.Add(1)
|
||||
s.tasksPool.Submit(func() {
|
||||
s.consumeTaskRequest(req)
|
||||
})
|
||||
if !s.Submit(req.RawHTTP, req.URL) {
|
||||
writeServerJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "scan queue is full"})
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
// Submit queues a captured request for fuzzing and reports whether it was
|
||||
// accepted. It never blocks: a saturated scanner must not stall the live
|
||||
// traffic flowing through the proxy, so excess requests are dropped instead.
|
||||
func (s *DASTServer) Submit(rawHTTP, targetURL string) bool {
|
||||
req := PostRequestsHandlerRequest{RawHTTP: rawHTTP, URL: targetURL}
|
||||
|
||||
s.endpointsInQueue.Add(1)
|
||||
if !s.tasksPool.TrySubmit(func() { s.consumeTaskRequest(req) }) {
|
||||
s.endpointsInQueue.Add(-1)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type StatsResponse struct {
|
||||
DASTServerInfo DASTServerInfo `json:"dast_server_info"`
|
||||
DASTScanStatistics DASTScanStatistics `json:"dast_scan_statistics"`
|
||||
@@ -235,6 +353,8 @@ type DASTServerInfo struct {
|
||||
NucleiTemplateVersion string `json:"nuclei_template_version"`
|
||||
NucleiDastServerAPI string `json:"nuclei_dast_server_api"`
|
||||
ServerAuthEnabled bool `json:"sever_auth_enabled"`
|
||||
NucleiDastProxyAddr string `json:"nuclei_dast_proxy_address,omitempty"`
|
||||
ProxyAuthEnabled bool `json:"proxy_auth_enabled"`
|
||||
}
|
||||
|
||||
type DASTScanStatistics struct {
|
||||
@@ -247,17 +367,29 @@ type DASTScanStatistics struct {
|
||||
TotalEndpointsTested int64 `json:"total_endpoints_tested"`
|
||||
TotalFuzzedRequests int64 `json:"total_fuzzed_requests"`
|
||||
TotalErroredRequests int64 `json:"total_errored_requests"`
|
||||
|
||||
// Proxy counters, populated only in proxy mode. A rising drop count means
|
||||
// the scanner cannot keep up with the proxied traffic.
|
||||
ProxyRequestsIntercepted int64 `json:"proxy_requests_intercepted"`
|
||||
ProxyTunnelsPassedThrough int64 `json:"proxy_tunnels_passed_through"`
|
||||
ProxyRequestsDropped int64 `json:"proxy_requests_dropped"`
|
||||
}
|
||||
|
||||
func (s *DASTServer) getStats() (StatsResponse, error) {
|
||||
cfg := config.DefaultConfig
|
||||
|
||||
api := ""
|
||||
if s.options.EnableFuzzAPI {
|
||||
api = s.buildURL("/fuzz")
|
||||
}
|
||||
resp := StatsResponse{
|
||||
DASTServerInfo: DASTServerInfo{
|
||||
NucleiVersion: config.Version,
|
||||
NucleiTemplateVersion: cfg.TemplateVersion,
|
||||
NucleiDastServerAPI: s.buildURL("/fuzz"),
|
||||
NucleiDastServerAPI: api,
|
||||
ServerAuthEnabled: s.options.Token != "",
|
||||
NucleiDastProxyAddr: s.options.ProxyAddress,
|
||||
ProxyAuthEnabled: s.options.ProxyUsername != "",
|
||||
},
|
||||
DASTScanStartTime: s.startTime,
|
||||
DASTScanStatistics: DASTScanStatistics{
|
||||
@@ -266,6 +398,12 @@ func (s *DASTServer) getStats() (StatsResponse, error) {
|
||||
TotalTemplatesLoaded: int64(len(s.nucleiExecutor.store.Templates())),
|
||||
},
|
||||
}
|
||||
if s.proxy != nil {
|
||||
proxyStats := s.proxy.Stats()
|
||||
resp.DASTScanStatistics.ProxyRequestsIntercepted = proxyStats.Intercepted
|
||||
resp.DASTScanStatistics.ProxyTunnelsPassedThrough = proxyStats.TunnelsPassedThrough
|
||||
resp.DASTScanStatistics.ProxyRequestsDropped = proxyStats.Dropped
|
||||
}
|
||||
if s.nucleiExecutor.executorOpts.FuzzStatsDB != nil {
|
||||
fuzzStats := s.nucleiExecutor.executorOpts.FuzzStatsDB.GetStats()
|
||||
resp.DASTScanSeverityBreakdown = fuzzStats.SeverityCounts
|
||||
|
||||
@@ -3,8 +3,11 @@ package server
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/alitto/pond"
|
||||
"github.com/projectdiscovery/nuclei/v3/internal/server/scope"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -64,3 +67,69 @@ func TestStatsServerCloseDoesNotRequireFullExecutorOptions(t *testing.T) {
|
||||
|
||||
require.NotPanics(t, server.Close)
|
||||
}
|
||||
|
||||
func TestShouldInterceptOnlySkipsExplicitlyExcludedHosts(t *testing.T) {
|
||||
scopeManager, err := scope.NewManager(
|
||||
// An in-scope rule carrying a path can never match a CONNECT host, so
|
||||
// it must not be what decides whether the tunnel is decrypted.
|
||||
[]string{`^https://app\.example\.com/api/`},
|
||||
[]string{`^https://telemetry\.example\.com/`},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
server := &DASTServer{options: &Options{}, scopeManager: scopeManager}
|
||||
|
||||
require.True(t, server.shouldIntercept("app.example.com:443"))
|
||||
require.True(t, server.shouldIntercept("unrelated.example.com:443"))
|
||||
require.False(t, server.shouldIntercept("telemetry.example.com:443"))
|
||||
|
||||
// A non-default port stays in the URL, matching what the same rules see
|
||||
// once a request off that connection is captured.
|
||||
staging, err := scope.NewManager(nil, []string{`^https://staging\.example\.com:8443/`})
|
||||
require.NoError(t, err)
|
||||
server.scopeManager = staging
|
||||
require.False(t, server.shouldIntercept("staging.example.com:8443"))
|
||||
require.True(t, server.shouldIntercept("staging.example.com:443"))
|
||||
}
|
||||
|
||||
func TestSubmitDropsInsteadOfBlockingWhenQueueIsFull(t *testing.T) {
|
||||
occupied, release := make(chan struct{}), make(chan struct{})
|
||||
|
||||
// One worker and no queue slot: once the worker is busy there is nowhere
|
||||
// left for a task to go.
|
||||
server := &DASTServer{options: &Options{}, tasksPool: pond.New(1, 0)}
|
||||
server.tasksPool.Submit(func() {
|
||||
close(occupied)
|
||||
<-release
|
||||
})
|
||||
<-occupied
|
||||
defer close(release)
|
||||
|
||||
require.False(t, server.Submit("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n", "http://example.com/"),
|
||||
"a saturated queue must drop rather than block live proxy traffic")
|
||||
require.Zero(t, server.endpointsInQueue.Load(), "a dropped request must not stay counted as queued")
|
||||
}
|
||||
|
||||
func TestProxyOnlyDoesNotExposeFuzzAPI(t *testing.T) {
|
||||
server := &DASTServer{options: &Options{ProxyAddress: "127.0.0.1:9056"}}
|
||||
server.setupHandlers(false)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/fuzz", strings.NewReader(`{"raw_http":"GET / HTTP/1.1\r\n\r\n","url":"http://example.com/"}`))
|
||||
response := httptest.NewRecorder()
|
||||
server.httpServer.Handler.ServeHTTP(response, request)
|
||||
require.Equal(t, http.StatusNotFound, response.Code)
|
||||
|
||||
request = httptest.NewRequest(http.MethodGet, "/ca", nil)
|
||||
response = httptest.NewRecorder()
|
||||
server.httpServer.Handler.ServeHTTP(response, request)
|
||||
require.Equal(t, http.StatusNotFound, response.Code, "CA download needs a running proxy instance")
|
||||
}
|
||||
|
||||
func TestFuzzAPIEnabledWhenRequested(t *testing.T) {
|
||||
server := &DASTServer{options: &Options{EnableFuzzAPI: true}}
|
||||
server.setupHandlers(false)
|
||||
|
||||
request := httptest.NewRequest(http.MethodPost, "/fuzz", strings.NewReader(`{"raw_http":"","url":""}`))
|
||||
response := httptest.NewRecorder()
|
||||
server.httpServer.Handler.ServeHTTP(response, request)
|
||||
require.Equal(t, http.StatusBadRequest, response.Code)
|
||||
}
|
||||
@@ -241,6 +241,10 @@ Dynamic Application Security Testing (DAST) API Server
|
||||
<div class="terminal-line"><span class="key">Template Version</span><span class="value">{{.DASTServerInfo.NucleiTemplateVersion}}</span></div>
|
||||
<div class="terminal-line"><span class="key">DAST Server API</span><span class="value">{{.DASTServerInfo.NucleiDastServerAPI}}</span></div>
|
||||
<div class="terminal-line"><span class="key">Auth Status</span><span class="value">{{if .DASTServerInfo.ServerAuthEnabled}}ENABLED{{else}}DISABLED{{end}}</span></div>
|
||||
{{if .DASTServerInfo.NucleiDastProxyAddr}}
|
||||
<div class="terminal-line"><span class="key">DAST Proxy</span><span class="value">{{.DASTServerInfo.NucleiDastProxyAddr}}</span></div>
|
||||
<div class="terminal-line"><span class="key">Proxy Auth Status</span><span class="value">{{if .DASTServerInfo.ProxyAuthEnabled}}ENABLED{{else}}DISABLED{{end}}</span></div>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
@@ -256,6 +260,15 @@ Dynamic Application Security Testing (DAST) API Server
|
||||
<div class="terminal-line"><span class="key">Total Errors</span><span class="value">{{.DASTScanStatistics.TotalErroredRequests}}</span></div>
|
||||
</div>
|
||||
|
||||
{{if .DASTServerInfo.NucleiDastProxyAddr}}
|
||||
<div class="section">
|
||||
<div class="section-header">[~] Proxy Traffic</div>
|
||||
<div class="terminal-line"><span class="key">Requests Intercepted</span><span class="value">{{.DASTScanStatistics.ProxyRequestsIntercepted}}</span></div>
|
||||
<div class="terminal-line"><span class="key">Tunnels Passed Through</span><span class="value">{{.DASTScanStatistics.ProxyTunnelsPassedThrough}}</span></div>
|
||||
<div class="terminal-line"><span class="key">Requests Dropped</span><span class="value">{{.DASTScanStatistics.ProxyRequestsDropped}}</span></div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="section">
|
||||
<div class="section-header">[!] Security Findings</div>
|
||||
<div class="grid">
|
||||
|
||||
@@ -5,6 +5,7 @@ package functional_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -280,6 +281,12 @@ func trimMatchingQuotes(value string) string {
|
||||
return value
|
||||
}
|
||||
|
||||
func TestIsRetryableFunctionalError(t *testing.T) {
|
||||
if isRetryableFunctionalError(nil) || isRetryableFunctionalError(fmt.Errorf("load mismatch")) {
|
||||
t.Fatal("non-exit errors should not retry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitFunctionalArgs(t *testing.T) {
|
||||
t.Run("quoted values", func(t *testing.T) {
|
||||
args := splitFunctionalArgs(`{{binary}} -tags "cve,exposure" -author "geeknik,pdteam" -severity high,critical`)
|
||||
@@ -338,6 +345,29 @@ func runFunctionalCase(args []string, debug bool) error {
|
||||
return fmt.Errorf("functional runners are not initialized")
|
||||
}
|
||||
|
||||
const attempts = 2
|
||||
var last error
|
||||
for attempt := 1; attempt <= attempts; attempt++ {
|
||||
last = compareFunctionalLoad(args, debug)
|
||||
if last == nil {
|
||||
return nil
|
||||
}
|
||||
if !isRetryableFunctionalError(last) {
|
||||
return last
|
||||
}
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
func isRetryableFunctionalError(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var exitErr *exec.ExitError
|
||||
return errors.As(err, &exitErr)
|
||||
}
|
||||
|
||||
func compareFunctionalLoad(args []string, debug bool) error {
|
||||
var (
|
||||
releaseOutput string
|
||||
currentOutput string
|
||||
|
||||
@@ -18,9 +18,11 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
dbName = "test"
|
||||
dbRepository = "mongo"
|
||||
dbTag = "8"
|
||||
dbName = "test"
|
||||
dbRepository = "mongo"
|
||||
// 8.x refuses to start on kernels 6.19 through 7.0.13 (SERVER-125742).
|
||||
// The GitHub Ubuntu image is in that range. 7.0 stays up.
|
||||
dbTag = "7.0"
|
||||
dbPort = "27017/tcp"
|
||||
mongoDatabaseReadyTimeout = 3 * time.Minute
|
||||
mongoServerSelectionDelay = time.Second
|
||||
|
||||
@@ -73,17 +73,27 @@ func TestMain(m *testing.M) {
|
||||
templatesDir := filepath.Join(homeDir, config.NucleiTemplatesDirName)
|
||||
config.DefaultConfig.SetTemplatesDir(templatesDir)
|
||||
|
||||
baseEnv := []string{
|
||||
"NUCLEI_TEMPLATES_DIR=" + templatesDir,
|
||||
"XDG_CONFIG_HOME=" + filepath.Join(tempDir, "config"),
|
||||
"XDG_STATE_HOME=" + filepath.Join(tempDir, "state"),
|
||||
"XDG_CACHE_HOME=" + filepath.Join(tempDir, "cache"),
|
||||
}
|
||||
// cases run nuclei with -duc, so the official templates some of them read
|
||||
// are installed once here rather than by whichever cases start first, which
|
||||
// queued them behind each other's downloads inside their own timeouts
|
||||
if err := installTemplates(binaryPath, baseEnv); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
_ = os.RemoveAll(tempDir)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
previousRunner := testutils.DefaultRunner()
|
||||
runner := testutils.NewRunner(
|
||||
testutils.WithBinaryPath(binaryPath),
|
||||
testutils.WithWorkingDir(workingFixturesDir),
|
||||
testutils.WithExtraArgs(integrationExtraArgs...),
|
||||
testutils.WithBaseEnv(
|
||||
"NUCLEI_TEMPLATES_DIR="+templatesDir,
|
||||
"XDG_CONFIG_HOME="+filepath.Join(tempDir, "config"),
|
||||
"XDG_STATE_HOME="+filepath.Join(tempDir, "state"),
|
||||
"XDG_CACHE_HOME="+filepath.Join(tempDir, "cache"),
|
||||
),
|
||||
testutils.WithBaseEnv(baseEnv...),
|
||||
)
|
||||
testutils.SetDefaultRunner(runner)
|
||||
|
||||
@@ -124,6 +134,16 @@ func buildNucleiBinary(repoRoot, binaryPath string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func installTemplates(binaryPath string, env []string) error {
|
||||
cmd := exec.Command(binaryPath, "-update-templates", "-no-color")
|
||||
cmd.Env = append(os.Environ(), env...)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to install nuclei templates: %w\n%s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isDebugMode() bool {
|
||||
for _, envVar := range []string{"DEBUG", "ACTIONS_STEP_DEBUG", "ACTIONS_RUNNER_DEBUG", "RUNNER_DEBUG"} {
|
||||
if envTruthy(envVar) {
|
||||
|
||||
@@ -37,6 +37,8 @@ var jsTestcases = []integrationCase{
|
||||
{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/websocket-echo.yaml", TestCase: &javascriptWebSocketEcho{}},
|
||||
{Path: "protocols/javascript/websocket-denied.yaml", TestCase: &javascriptWebSocketDenied{}},
|
||||
{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{}},
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
//go:build integration
|
||||
// +build integration
|
||||
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
|
||||
"github.com/gobwas/ws"
|
||||
"github.com/gobwas/ws/wsutil"
|
||||
)
|
||||
|
||||
// startWebSocketEchoServer starts a WebSocket server on a random loopback port
|
||||
// that accepts the "chat" subprotocol, greets, then echoes every message.
|
||||
func startWebSocketEchoServer() (string, func(), error) {
|
||||
lis, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("websocket listen: %w", err)
|
||||
}
|
||||
srv := &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
upgrader := ws.HTTPUpgrader{Protocol: func(protocol string) bool { return protocol == "chat" }}
|
||||
conn, _, _, err := upgrader.Upgrade(r, w)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
if err := wsutil.WriteServerText(conn, []byte("welcome")); err != nil {
|
||||
return
|
||||
}
|
||||
for {
|
||||
payload, op, err := wsutil.ReadClientData(conn)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if err := wsutil.WriteServerMessage(conn, op, payload); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
})}
|
||||
go func() { _ = srv.Serve(lis) }()
|
||||
return lis.Addr().String(), func() { _ = srv.Close() }, nil
|
||||
}
|
||||
|
||||
// javascriptWebSocketEcho exercises the nuclei/websocket library end to end
|
||||
// against a real WebSocket server.
|
||||
type javascriptWebSocketEcho struct{}
|
||||
|
||||
func (j *javascriptWebSocketEcho) Execute(filePath string) error {
|
||||
address, stop, err := startWebSocketEchoServer()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer stop()
|
||||
|
||||
results, err := runSignedNucleiTemplateAndGetResults(filePath, address, debug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return expectResultsCount(results, 1)
|
||||
}
|
||||
|
||||
// javascriptWebSocketDenied asserts the nuclei/websocket library refuses a host
|
||||
// on the exclude list (network-policy enforcement) before connecting.
|
||||
type javascriptWebSocketDenied struct{}
|
||||
|
||||
func (j *javascriptWebSocketDenied) 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)
|
||||
}
|
||||
@@ -17,7 +17,7 @@ type multiProtoDynamicExtractor struct{}
|
||||
|
||||
// Execute executes a test case and returns an error if occurred
|
||||
func (h *multiProtoDynamicExtractor) Execute(templatePath string) error {
|
||||
results, err := testutils.RunNucleiTemplateAndGetResults(templatePath, "docs.projectdiscovery.io", debug)
|
||||
results, err := testutils.RunNucleiTemplateAndGetResults(templatePath, "https://docs.projectdiscovery.io/", debug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
@@ -174,6 +175,11 @@ func executeIntegrationCase(testCase integrationCase) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
// a killed nuclei is not a flake; retrying it just spends the package
|
||||
// timeout that every remaining case shares
|
||||
if errors.Is(err, testutils.ErrCommandTimeout) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if retries > 1 {
|
||||
return fmt.Errorf("test %s failed after %d attempts: %w", testCase.Path, retries, err)
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
id: websocket-denied
|
||||
|
||||
info:
|
||||
name: WebSocket Network Policy Denial
|
||||
author: pdteam
|
||||
severity: info
|
||||
description: |
|
||||
Asserts the nuclei/websocket javascript library refuses a host on the
|
||||
exclude list before connecting.
|
||||
|
||||
javascript:
|
||||
- code: |
|
||||
const websocket = require('nuclei/websocket');
|
||||
let out = "";
|
||||
try {
|
||||
const client = new websocket.Client('ws://203.0.113.10/socket');
|
||||
client.Connect();
|
||||
} 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,33 @@
|
||||
id: websocket-echo
|
||||
|
||||
info:
|
||||
name: WebSocket Echo
|
||||
author: pdteam
|
||||
severity: info
|
||||
description: |
|
||||
Exercises the nuclei/websocket javascript library end to end: dial through
|
||||
the network policy, negotiate a subprotocol, read the server greeting and
|
||||
round-trip a text message.
|
||||
|
||||
javascript:
|
||||
- code: |
|
||||
const websocket = require('nuclei/websocket');
|
||||
const opts = new websocket.Options();
|
||||
opts.Protocols = ['chat'];
|
||||
const client = new websocket.Client('ws://' + address + '/echo', opts);
|
||||
const greeting = client.Receive();
|
||||
client.Send('nuclei-websocket');
|
||||
const echo = client.Receive();
|
||||
client.Close();
|
||||
greeting + ' ' + client.Protocol + ' ' + echo;
|
||||
args:
|
||||
address: "{{Host}}:{{Port}}"
|
||||
matchers-condition: and
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- "success == true"
|
||||
- type: word
|
||||
part: response
|
||||
words:
|
||||
- "welcome chat nuclei-websocket"
|
||||
@@ -24,6 +24,6 @@ http:
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- contains(body,'home') # check for http string
|
||||
- location == '/home' # the docs host redirects here
|
||||
- blogid == 'cname' # check for cname (extracted information from dns response)
|
||||
condition: and
|
||||
+1
-1
@@ -24,7 +24,7 @@ http:
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- contains(http_body,'home') # check for http string
|
||||
- http_location == '/home' # the docs host redirects here
|
||||
- cname_filtered == 'cname' # check for cname (extracted information from dns response)
|
||||
- ssl_subject_cn == 'docs.projectdiscovery.io'
|
||||
condition: and
|
||||
+1
-1
@@ -20,7 +20,7 @@ http:
|
||||
matchers:
|
||||
- type: dsl
|
||||
dsl:
|
||||
- contains(http_body,'home') # check for http string
|
||||
- http_location == '/home' # the docs host redirects here
|
||||
- trim_suffix(dns_cname,'.vercel-dns.com') == 'cname' # check for cname (extracted information from dns response)
|
||||
- ssl_subject_cn == 'docs.projectdiscovery.io'
|
||||
condition: and
|
||||
@@ -11,7 +11,6 @@ network:
|
||||
inputs:
|
||||
- data: "50494e47"
|
||||
type: hex
|
||||
- data: "\r\n"
|
||||
|
||||
read-size: 4
|
||||
matchers:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package testutils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -11,12 +12,29 @@ import (
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gobwas/ws"
|
||||
"github.com/julienschmidt/httprouter"
|
||||
"github.com/projectdiscovery/utils/conversion"
|
||||
)
|
||||
|
||||
// defaultCommandTimeout bounds a single nuclei process in integration tests.
|
||||
// The CI job is 50m; without this a hung scan leaves the suite nameless until
|
||||
// the runner kills the step. It must stay well above the slowest legitimate
|
||||
// case: raw-unsafe-path scans scanme.sh over the network and can take minutes.
|
||||
const defaultCommandTimeout = 10 * time.Minute
|
||||
|
||||
// commandWaitDelay bounds the wait for output pipes after the process is
|
||||
// killed. A grandchild that inherited stdout/stderr keeps the pipe open, and
|
||||
// without this Output/CombinedOutput blocks forever despite the killed process.
|
||||
const commandWaitDelay = 10 * time.Second
|
||||
|
||||
// ErrCommandTimeout reports that nuclei was killed by commandTimeout. Callers
|
||||
// treat it as fatal rather than flaky: retrying a hang only burns the budget
|
||||
// the whole suite shares.
|
||||
var ErrCommandTimeout = errors.New("nuclei timed out")
|
||||
|
||||
type Runner struct {
|
||||
BinaryPath string
|
||||
WorkingDir string
|
||||
@@ -26,6 +44,7 @@ type Runner struct {
|
||||
AllowLocalFileAccess bool
|
||||
InteractionsPollDuration string
|
||||
InteractionsCooldownPeriod string
|
||||
CommandTimeout time.Duration
|
||||
}
|
||||
|
||||
type RunnerOption func(*Runner)
|
||||
@@ -38,6 +57,7 @@ func NewRunner(options ...RunnerOption) *Runner {
|
||||
AllowLocalFileAccess: true,
|
||||
InteractionsPollDuration: "1",
|
||||
InteractionsCooldownPeriod: "10",
|
||||
CommandTimeout: defaultCommandTimeout,
|
||||
}
|
||||
for _, option := range options {
|
||||
option(runner)
|
||||
@@ -56,6 +76,7 @@ func (r *Runner) Clone(options ...RunnerOption) *Runner {
|
||||
AllowLocalFileAccess: r.AllowLocalFileAccess,
|
||||
InteractionsPollDuration: r.InteractionsPollDuration,
|
||||
InteractionsCooldownPeriod: r.InteractionsCooldownPeriod,
|
||||
CommandTimeout: r.CommandTimeout,
|
||||
}
|
||||
for _, option := range options {
|
||||
option(clone)
|
||||
@@ -106,7 +127,14 @@ func SetDefaultRunner(runner *Runner) {
|
||||
defaultRunner = runner.Clone()
|
||||
}
|
||||
|
||||
func (r *Runner) command(binaryPath string, args ...string) *exec.Cmd {
|
||||
func (r *Runner) commandTimeout() time.Duration {
|
||||
if r != nil && r.CommandTimeout > 0 {
|
||||
return r.CommandTimeout
|
||||
}
|
||||
return defaultCommandTimeout
|
||||
}
|
||||
|
||||
func (r *Runner) command(ctx context.Context, binaryPath string, args ...string) *exec.Cmd {
|
||||
resolvedBinary := strings.TrimSpace(binaryPath)
|
||||
if resolvedBinary == "" {
|
||||
resolvedBinary = strings.TrimSpace(r.BinaryPath)
|
||||
@@ -116,7 +144,8 @@ func (r *Runner) command(binaryPath string, args ...string) *exec.Cmd {
|
||||
resolvedBinary = "nuclei"
|
||||
}
|
||||
|
||||
cmd := exec.Command(resolvedBinary, args...)
|
||||
cmd := exec.CommandContext(ctx, resolvedBinary, args...)
|
||||
cmd.WaitDelay = commandWaitDelay
|
||||
if r.WorkingDir != "" {
|
||||
cmd.Dir = r.WorkingDir
|
||||
}
|
||||
@@ -124,6 +153,28 @@ func (r *Runner) command(binaryPath string, args ...string) *exec.Cmd {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (r *Runner) runCommand(combined bool, binaryPath string, setup func(cmd *exec.Cmd)) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), r.commandTimeout())
|
||||
defer cancel()
|
||||
|
||||
cmd := r.command(ctx, binaryPath)
|
||||
setup(cmd)
|
||||
|
||||
var (
|
||||
output []byte
|
||||
err error
|
||||
)
|
||||
if combined {
|
||||
output, err = cmd.CombinedOutput()
|
||||
} else {
|
||||
output, err = cmd.Output()
|
||||
}
|
||||
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||
return output, fmt.Errorf("%w after %s: %v", ErrCommandTimeout, r.commandTimeout(), err)
|
||||
}
|
||||
return output, err
|
||||
}
|
||||
|
||||
func (r *Runner) buildArgs(args []string, pollDuration string) []string {
|
||||
builtArgs := append([]string{}, args...)
|
||||
|
||||
@@ -188,20 +239,17 @@ func (r *Runner) WorkflowResults(workflow, url string, debug bool, extra ...stri
|
||||
}
|
||||
|
||||
func (r *Runner) BareResults(debug bool, env []string, args ...string) ([]string, error) {
|
||||
cmd := r.command("")
|
||||
cmd.Args = append(cmd.Args[:1], r.buildArgs(args, r.InteractionsPollDuration)...)
|
||||
|
||||
cmd.Env = r.buildEnv(env)
|
||||
|
||||
if debug {
|
||||
cmd.Args = append(cmd.Args, "-debug")
|
||||
cmd.Stderr = os.Stderr
|
||||
fmt.Println(cmd.String())
|
||||
} else {
|
||||
cmd.Args = append(cmd.Args, "-silent")
|
||||
}
|
||||
|
||||
output, err := cmd.Output()
|
||||
output, err := r.runCommand(false, "", func(cmd *exec.Cmd) {
|
||||
cmd.Args = append(cmd.Args[:1], r.buildArgs(args, r.InteractionsPollDuration)...)
|
||||
cmd.Env = r.buildEnv(env)
|
||||
if debug {
|
||||
cmd.Args = append(cmd.Args, "-debug")
|
||||
cmd.Stderr = os.Stderr
|
||||
fmt.Println(cmd.String())
|
||||
} else {
|
||||
cmd.Args = append(cmd.Args, "-silent")
|
||||
}
|
||||
})
|
||||
if debug && len(output) > 0 {
|
||||
fmt.Println(strings.TrimSpace(conversion.String(output)))
|
||||
}
|
||||
@@ -210,19 +258,20 @@ func (r *Runner) BareResults(debug bool, env []string, args ...string) ([]string
|
||||
}
|
||||
|
||||
func (r *Runner) ArgsResults(debug bool, args ...string) ([]string, error) {
|
||||
cmd := r.command("", append(append([]string{}, args...), r.ExtraArgs...)...)
|
||||
|
||||
cmd.Env = r.buildEnv(nil)
|
||||
|
||||
if debug {
|
||||
cmd.Args = append(cmd.Args, "-debug")
|
||||
cmd.Stderr = os.Stderr
|
||||
fmt.Println(cmd.String())
|
||||
} else {
|
||||
cmd.Args = append(cmd.Args, "-silent")
|
||||
}
|
||||
|
||||
output, err := cmd.Output()
|
||||
output, err := r.runCommand(false, "", func(cmd *exec.Cmd) {
|
||||
cmd.Args = append(cmd.Args[:1], append(append([]string{}, args...), r.ExtraArgs...)...)
|
||||
cmd.Env = r.buildEnv(nil)
|
||||
if r.DisableAutoUpdate {
|
||||
cmd.Args = append(cmd.Args, "-duc")
|
||||
}
|
||||
if debug {
|
||||
cmd.Args = append(cmd.Args, "-debug")
|
||||
cmd.Stderr = os.Stderr
|
||||
fmt.Println(cmd.String())
|
||||
} else {
|
||||
cmd.Args = append(cmd.Args, "-silent")
|
||||
}
|
||||
})
|
||||
if debug && len(output) > 0 {
|
||||
fmt.Println(strings.TrimSpace(conversion.String(output)))
|
||||
}
|
||||
@@ -231,12 +280,11 @@ func (r *Runner) ArgsResults(debug bool, args ...string) ([]string, error) {
|
||||
}
|
||||
|
||||
func (r *Runner) ArgsErrors(debug bool, env []string, args ...string) ([]string, error) {
|
||||
cmd := r.command("")
|
||||
cmd.Args = append(cmd.Args[:1], r.buildArgs(args, r.InteractionsPollDuration)...)
|
||||
cmd.Args = append(cmd.Args, "-nc")
|
||||
cmd.Env = r.buildEnv(env)
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
output, err := r.runCommand(true, "", func(cmd *exec.Cmd) {
|
||||
cmd.Args = append(cmd.Args[:1], r.buildArgs(args, r.InteractionsPollDuration)...)
|
||||
cmd.Args = append(cmd.Args, "-nc")
|
||||
cmd.Env = r.buildEnv(env)
|
||||
})
|
||||
if debug && len(output) > 0 {
|
||||
fmt.Println(string(output))
|
||||
}
|
||||
@@ -263,25 +311,26 @@ func (r *Runner) ArgsResultsWithEnv(debug bool, env []string, args ...string) ([
|
||||
}
|
||||
|
||||
func (r *Runner) LoadedTemplates(binaryPath string, debug bool, args []string) (string, error) {
|
||||
cmd := r.command(binaryPath, append(append([]string{}, args...), r.ExtraArgs...)...)
|
||||
|
||||
cmd.Env = r.buildEnv(nil)
|
||||
|
||||
if r.DisableAutoUpdate {
|
||||
cmd.Args = append(cmd.Args, "-duc")
|
||||
}
|
||||
|
||||
if debug {
|
||||
cmd.Args = append(cmd.Args, "-debug")
|
||||
fmt.Println(cmd.String())
|
||||
}
|
||||
|
||||
data, err := cmd.CombinedOutput()
|
||||
data, err := r.runCommand(true, binaryPath, func(cmd *exec.Cmd) {
|
||||
cmd.Args = append(cmd.Args[:1], append(append([]string{}, args...), r.ExtraArgs...)...)
|
||||
cmd.Env = r.buildEnv(nil)
|
||||
if r.DisableAutoUpdate {
|
||||
cmd.Args = append(cmd.Args, "-duc")
|
||||
}
|
||||
if debug {
|
||||
cmd.Args = append(cmd.Args, "-debug")
|
||||
fmt.Println(cmd.String())
|
||||
}
|
||||
})
|
||||
if debug && len(data) > 0 {
|
||||
fmt.Println(string(data))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
out := strings.TrimSpace(string(data))
|
||||
if out != "" {
|
||||
return "", fmt.Errorf("%w: %s", err, out)
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -295,16 +344,14 @@ func (r *Runner) LoadedTemplates(binaryPath string, debug bool, args []string) (
|
||||
|
||||
func (r *Runner) CombinedOutput(debug bool, args []string) (string, error) {
|
||||
builtArgs := r.buildArgs(args, r.InteractionsPollDuration)
|
||||
cmd := r.command("", builtArgs...)
|
||||
|
||||
cmd.Env = r.buildEnv(nil)
|
||||
|
||||
if debug {
|
||||
cmd.Args = append(cmd.Args, "-debug")
|
||||
fmt.Println(cmd.String())
|
||||
}
|
||||
|
||||
data, err := cmd.CombinedOutput()
|
||||
data, err := r.runCommand(true, "", func(cmd *exec.Cmd) {
|
||||
cmd.Args = append(cmd.Args[:1], builtArgs...)
|
||||
cmd.Env = r.buildEnv(nil)
|
||||
if debug {
|
||||
cmd.Args = append(cmd.Args, "-debug")
|
||||
fmt.Println(cmd.String())
|
||||
}
|
||||
})
|
||||
if debug && len(data) > 0 {
|
||||
fmt.Println(string(data))
|
||||
}
|
||||
|
||||
@@ -40,43 +40,46 @@ func Cleanup(options *types.Options) {
|
||||
|
||||
// DefaultOptions is the default options structure for nuclei during mocking.
|
||||
var DefaultOptions = &types.Options{
|
||||
Debug: false,
|
||||
DebugRequests: false,
|
||||
DebugResponse: false,
|
||||
Silent: false,
|
||||
Verbose: false,
|
||||
NoColor: true,
|
||||
UpdateTemplates: false,
|
||||
JSONL: false,
|
||||
OmitRawRequests: false,
|
||||
EnableProgressBar: false,
|
||||
TemplateList: false,
|
||||
Stdin: false,
|
||||
StopAtFirstMatch: false,
|
||||
NoMeta: false,
|
||||
Project: false,
|
||||
MetricsPort: 0,
|
||||
BulkSize: 25,
|
||||
TemplateThreads: 10,
|
||||
Timeout: 5,
|
||||
Retries: 1,
|
||||
RateLimit: 150,
|
||||
RateLimitDuration: time.Second,
|
||||
ProbeConcurrency: 50,
|
||||
ProjectPath: "",
|
||||
Severities: severity.Severities{},
|
||||
Targets: []string{},
|
||||
TargetsFilePath: "",
|
||||
Output: "",
|
||||
Proxy: []string{},
|
||||
TraceLogFile: "",
|
||||
Templates: []string{},
|
||||
ExcludedTemplates: []string{},
|
||||
CustomHeaders: []string{},
|
||||
InteractshURL: "https://oast.fun",
|
||||
InteractionsCacheSize: 5000,
|
||||
InteractionsEviction: 60,
|
||||
InteractionsCoolDownPeriod: 5,
|
||||
Debug: false,
|
||||
DebugRequests: false,
|
||||
DebugResponse: false,
|
||||
Silent: false,
|
||||
Verbose: false,
|
||||
NoColor: true,
|
||||
UpdateTemplates: false,
|
||||
JSONL: false,
|
||||
OmitRawRequests: false,
|
||||
EnableProgressBar: false,
|
||||
TemplateList: false,
|
||||
Stdin: false,
|
||||
StopAtFirstMatch: false,
|
||||
NoMeta: false,
|
||||
Project: false,
|
||||
MetricsPort: 0,
|
||||
BulkSize: 25,
|
||||
TemplateThreads: 10,
|
||||
Timeout: 5,
|
||||
Retries: 1,
|
||||
RateLimit: 150,
|
||||
RateLimitDuration: time.Second,
|
||||
ProbeConcurrency: 50,
|
||||
ProjectPath: "",
|
||||
Severities: severity.Severities{},
|
||||
Targets: []string{},
|
||||
TargetsFilePath: "",
|
||||
Output: "",
|
||||
Proxy: []string{},
|
||||
TraceLogFile: "",
|
||||
Templates: []string{},
|
||||
ExcludedTemplates: []string{},
|
||||
CustomHeaders: []string{},
|
||||
InteractshURL: "https://oast.fun",
|
||||
InteractionsCacheSize: 5000,
|
||||
InteractionsEviction: 60,
|
||||
// Closing an interactsh client sleeps for the cooldown, waiting on callbacks
|
||||
// that a unit test never receives, so every test generating an oast url paid
|
||||
// five seconds for nothing.
|
||||
InteractionsCoolDownPeriod: 0,
|
||||
InteractionsPollDuration: 5,
|
||||
GitHubTemplateRepo: []string{},
|
||||
GitHubToken: "",
|
||||
|
||||
+14
-14
@@ -12,7 +12,6 @@ import (
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/loader/workflow"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/output"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/utils"
|
||||
"github.com/projectdiscovery/utils/errkit"
|
||||
@@ -23,8 +22,7 @@ import (
|
||||
// hence they are ephemeral and are created on every ExecuteNucleiWithOpts invocation
|
||||
// in ThreadSafeNucleiEngine
|
||||
type unsafeOptions struct {
|
||||
executerOpts *protocols.ExecutorOptions
|
||||
executionParser *templates.Parser
|
||||
executerOpts *protocols.ExecutorOptions
|
||||
}
|
||||
|
||||
// createEphemeralObjects creates ephemeral nuclei objects/instances/types.
|
||||
@@ -33,9 +31,7 @@ func createEphemeralObjects(ctx context.Context, base *NucleiEngine, opts *types
|
||||
if outputWriter == nil {
|
||||
outputWriter = base.customWriter
|
||||
}
|
||||
u := &unsafeOptions{
|
||||
executionParser: templates.NewExecutionParser(base.parser),
|
||||
}
|
||||
u := &unsafeOptions{}
|
||||
u.executerOpts = &protocols.ExecutorOptions{
|
||||
Output: outputWriter,
|
||||
Options: opts,
|
||||
@@ -46,12 +42,16 @@ func createEphemeralObjects(ctx context.Context, base *NucleiEngine, opts *types
|
||||
Interactsh: base.interactshClient,
|
||||
Colorizer: aurora.New(aurora.WithColors(true)),
|
||||
ResumeCfg: types.NewResumeCfg(),
|
||||
Parser: u.executionParser,
|
||||
Parser: base.parser,
|
||||
Browser: base.browserInstance,
|
||||
// Each execution has a private compiled-template cache because compiled
|
||||
// requests retain mutable ExecutorOptions. Only clean parsed templates are
|
||||
// shared with the long-lived engine parser.
|
||||
DoNotCache: false,
|
||||
// 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 base.interactshClient != nil {
|
||||
u.executerOpts.InteractshScope = base.interactshClient.NewRequestScope()
|
||||
}
|
||||
if opts.ShouldUseHostError() && base.hostErrCache != nil {
|
||||
u.executerOpts.HostErrorsCache = base.hostErrCache
|
||||
@@ -119,9 +119,8 @@ func resolveEphemeralOutput(base, call *NucleiEngine) output.Writer {
|
||||
|
||||
// closeEphemeralObjects closes all resources used by ephemeral nuclei objects/instances/types
|
||||
func closeEphemeralObjects(u *unsafeOptions) {
|
||||
if u.executionParser != nil {
|
||||
u.executionParser.PurgeCompiled()
|
||||
u.executionParser = nil
|
||||
if u.executerOpts.InteractshScope != nil {
|
||||
u.executerOpts.InteractshScope.Close()
|
||||
}
|
||||
if u.executerOpts.RateLimiter != nil {
|
||||
u.executerOpts.RateLimiter.Stop()
|
||||
@@ -131,6 +130,7 @@ func closeEphemeralObjects(u *unsafeOptions) {
|
||||
u.executerOpts.Output = nil
|
||||
u.executerOpts.IssuesClient = nil
|
||||
u.executerOpts.Interactsh = nil
|
||||
u.executerOpts.InteractshScope = nil
|
||||
u.executerOpts.HostErrorsCache = nil
|
||||
u.executerOpts.Progress = nil
|
||||
u.executerOpts.Catalog = nil
|
||||
|
||||
@@ -1,48 +1,13 @@
|
||||
package nuclei
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestEphemeralObjectsUseExecutionLocalParser(t *testing.T) {
|
||||
baseParser := templates.NewParser()
|
||||
baseParser.Cache().Store("parsed", &templates.Template{}, []byte("raw"), nil)
|
||||
base := &NucleiEngine{parser: baseParser}
|
||||
|
||||
first, err := createEphemeralObjects(context.Background(), base, types.DefaultOptions(), nil)
|
||||
require.NoError(t, err)
|
||||
second, err := createEphemeralObjects(context.Background(), base, types.DefaultOptions(), nil)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { closeEphemeralObjects(second) })
|
||||
|
||||
firstParser, ok := first.executerOpts.Parser.(*templates.Parser)
|
||||
require.True(t, ok)
|
||||
secondParser, ok := second.executerOpts.Parser.(*templates.Parser)
|
||||
require.True(t, ok)
|
||||
|
||||
require.NotSame(t, baseParser, firstParser)
|
||||
require.NotSame(t, firstParser, secondParser)
|
||||
require.Same(t, baseParser.Cache(), firstParser.Cache())
|
||||
require.Same(t, baseParser.Cache(), secondParser.Cache())
|
||||
require.NotSame(t, firstParser.CompiledCache(), secondParser.CompiledCache())
|
||||
require.False(t, first.executerOpts.DoNotCache)
|
||||
|
||||
firstParser.CompiledCache().StoreWithoutRaw("compiled", &templates.Template{}, nil)
|
||||
require.Equal(t, 1, firstParser.CompiledCount())
|
||||
require.Zero(t, secondParser.CompiledCount())
|
||||
|
||||
closeEphemeralObjects(first)
|
||||
require.Zero(t, firstParser.CompiledCount())
|
||||
require.Equal(t, 1, baseParser.ParsedCount())
|
||||
}
|
||||
|
||||
func TestRestoreBaseExcludeTags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -18,6 +18,9 @@ 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"),
|
||||
// go 1.27 moved the bundled http2 transport out of net/http, which renamed
|
||||
// the same idle connection goroutine
|
||||
goleak.IgnoreAnyFunction("net/http/internal/http2.(*ClientConn).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
|
||||
|
||||
+60
-2
@@ -386,6 +386,21 @@
|
||||
"type": "boolean",
|
||||
"title": "use case insensitive extract",
|
||||
"description": "use case insensitive extract"
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"title": "llm extraction prompt",
|
||||
"description": "Natural language instruction describing what to extract; supports {{...}} interpolation of variables and target values"
|
||||
},
|
||||
"schema": {
|
||||
"$ref": "#/$defs/map[string]string",
|
||||
"title": "llm extraction schema",
|
||||
"description": "Fields to extract mapped to their type"
|
||||
},
|
||||
"max-input-tokens": {
|
||||
"type": "integer",
|
||||
"title": "max llm input tokens",
|
||||
"description": "Approximate cap on response tokens sent to the model"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false,
|
||||
@@ -401,7 +416,8 @@
|
||||
"kval",
|
||||
"xpath",
|
||||
"json",
|
||||
"dsl"
|
||||
"dsl",
|
||||
"llm"
|
||||
],
|
||||
"title": "type of the extractor",
|
||||
"description": "Type of the extractor"
|
||||
@@ -1159,6 +1175,47 @@
|
||||
"title": "xpath queries to match in response",
|
||||
"description": "xpath are the XPath queries that will be evaluated against the response part of nuclei matching rules"
|
||||
},
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"title": "llm prompt",
|
||||
"description": "Natural language question the model answers about the response part; supports {{...}} interpolation of variables and target values"
|
||||
},
|
||||
"expect": {
|
||||
"type": "string",
|
||||
"title": "expected llm verdict",
|
||||
"description": "Verdict that counts as a match; defaults to yes"
|
||||
},
|
||||
"options": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "allowed llm verdicts",
|
||||
"description": "Fixed set of verdicts the model may return; defaults to yes and no"
|
||||
},
|
||||
"min-confidence": {
|
||||
"type": "number",
|
||||
"title": "minimum llm confidence",
|
||||
"description": "Minimum confidence between 0 and 1 for an llm verdict to match"
|
||||
},
|
||||
"inputs": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "llm inputs to compare",
|
||||
"description": "Responses to compare instead of a single part; each entry supports {{...}} interpolation"
|
||||
},
|
||||
"max-input-tokens": {
|
||||
"type": "integer",
|
||||
"title": "max llm input tokens",
|
||||
"description": "Approximate cap on response tokens sent to the model"
|
||||
},
|
||||
"allow-sole": {
|
||||
"type": "boolean",
|
||||
"title": "allow llm as sole matcher",
|
||||
"description": "Permit an llm matcher to be the only matcher on a high or critical template"
|
||||
},
|
||||
"encoding": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
@@ -1198,7 +1255,8 @@
|
||||
"status",
|
||||
"size",
|
||||
"dsl",
|
||||
"xpath"
|
||||
"xpath",
|
||||
"llm"
|
||||
],
|
||||
"title": "type of the matcher",
|
||||
"description": "Type of the matcher"
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package authx
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
httputil "github.com/projectdiscovery/nuclei/v3/pkg/protocols/utils/http"
|
||||
)
|
||||
|
||||
type cookieAuthContextKey struct{}
|
||||
|
||||
// IsAuthCookie reports whether an auth strategy installed the named cookie.
|
||||
// HTTP cookie reuse must preserve these explicit credentials over jar values.
|
||||
func IsAuthCookie(ctx context.Context, name string) bool {
|
||||
names, _ := ctx.Value(cookieAuthContextKey{}).(map[string]struct{})
|
||||
_, ok := names[name]
|
||||
return ok
|
||||
}
|
||||
|
||||
func markCookieAuth(req *http.Request, names []string) {
|
||||
if len(names) == 0 {
|
||||
return
|
||||
}
|
||||
existing, _ := req.Context().Value(cookieAuthContextKey{}).(map[string]struct{})
|
||||
// Contexts can be shared by clones and redirects. Publish a new set rather
|
||||
// than modifying the names recorded by an earlier strategy.
|
||||
protected := make(map[string]struct{}, len(existing)+len(names))
|
||||
// A Header strategy can replace the entire Cookie header. Keep earlier
|
||||
// ownership only for cookie names still present in the request.
|
||||
for _, header := range req.Header.Values("Cookie") {
|
||||
for _, part := range httputil.SplitCookieHeader(header) {
|
||||
name, _, _ := strings.Cut(part, "=")
|
||||
name = strings.TrimSpace(name)
|
||||
if _, ok := existing[name]; ok {
|
||||
protected[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, name := range names {
|
||||
protected[name] = struct{}{}
|
||||
}
|
||||
*req = *req.WithContext(context.WithValue(req.Context(), cookieAuthContextKey{}, protected))
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package authx
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/retryablehttp-go"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCookieAuthOwnershipIsRequestLocal(t *testing.T) {
|
||||
req, err := retryablehttp.NewRequest(http.MethodGet, "https://example.com", nil)
|
||||
require.NoError(t, err)
|
||||
NewCookiesAuthStrategy(&Secret{Cookies: []Cookie{{Key: "session", Value: "configured"}}}).ApplyOnRR(req)
|
||||
cloned := req.Clone(req.Context())
|
||||
NewCookiesAuthStrategy(&Secret{Cookies: []Cookie{{Key: "other", Value: "configured"}}}).ApplyOnRR(cloned)
|
||||
require.True(t, IsAuthCookie(req.Context(), "session"))
|
||||
require.False(t, IsAuthCookie(req.Context(), "other"))
|
||||
require.True(t, IsAuthCookie(cloned.Context(), "session"))
|
||||
require.True(t, IsAuthCookie(cloned.Context(), "other"))
|
||||
require.False(t, IsAuthCookie(cloned.Context(), "Session"))
|
||||
|
||||
NewHeadersAuthStrategy(&Secret{Headers: []KV{{Key: "Cookie", Value: "replacement=configured"}}}).ApplyOnRR(cloned)
|
||||
require.True(t, IsAuthCookie(cloned.Context(), "replacement"))
|
||||
require.False(t, IsAuthCookie(cloned.Context(), "session"))
|
||||
require.False(t, IsAuthCookie(cloned.Context(), "other"))
|
||||
require.True(t, IsAuthCookie(req.Context(), "session"))
|
||||
}
|
||||
|
||||
func TestHeaderAuthCookieOwnershipIgnoresQuotedFragments(t *testing.T) {
|
||||
for _, header := range []string{`payload="a;session=probe"`, `payload="a\";session=probe"`, `payload="a;session=probe`} {
|
||||
t.Run(header, func(t *testing.T) {
|
||||
req, err := retryablehttp.NewRequest(http.MethodGet, "https://example.com", nil)
|
||||
require.NoError(t, err)
|
||||
NewCookiesAuthStrategy(&Secret{Cookies: []Cookie{{Key: "session", Value: "configured"}}}).ApplyOnRR(req)
|
||||
NewHeadersAuthStrategy(&Secret{Headers: []KV{{Key: "Cookie", Value: header}}}).ApplyOnRR(req)
|
||||
require.Equal(t, header, req.Header.Get("Cookie"))
|
||||
require.True(t, IsAuthCookie(req.Context(), "payload"))
|
||||
require.False(t, IsAuthCookie(req.Context(), "session"))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -23,13 +23,16 @@ func NewCookiesAuthStrategy(data *Secret) *CookiesAuthStrategy {
|
||||
|
||||
// Apply applies the cookies auth strategy to the request
|
||||
func (s *CookiesAuthStrategy) Apply(req *http.Request) {
|
||||
var names []string
|
||||
for _, cookie := range s.Data.Cookies {
|
||||
c := &http.Cookie{
|
||||
Name: cookie.Key,
|
||||
Value: cookie.Value,
|
||||
}
|
||||
req.AddCookie(c)
|
||||
names = append(names, cookie.Key)
|
||||
}
|
||||
markCookieAuth(req, names)
|
||||
}
|
||||
|
||||
// ApplyOnRR applies the cookies auth strategy to the retryable request
|
||||
@@ -51,10 +54,13 @@ func (s *CookiesAuthStrategy) ApplyOnRR(req *retryablehttp.Request) {
|
||||
req.AddCookie(cookie)
|
||||
}
|
||||
// Add new cookies
|
||||
var names []string
|
||||
for _, cookie := range s.Data.Cookies {
|
||||
req.AddCookie(&http.Cookie{
|
||||
Name: cookie.Key,
|
||||
Value: cookie.Value,
|
||||
})
|
||||
names = append(names, cookie.Key)
|
||||
}
|
||||
markCookieAuth(req.Request, names)
|
||||
}
|
||||
@@ -2,7 +2,9 @@ package authx
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
httputil "github.com/projectdiscovery/nuclei/v3/pkg/protocols/utils/http"
|
||||
"github.com/projectdiscovery/retryablehttp-go"
|
||||
)
|
||||
|
||||
@@ -26,6 +28,14 @@ func NewHeadersAuthStrategy(data *Secret) *HeadersAuthStrategy {
|
||||
func (s *HeadersAuthStrategy) Apply(req *http.Request) {
|
||||
for _, header := range s.Data.Headers {
|
||||
req.Header[header.Key] = []string{header.Value}
|
||||
if strings.EqualFold(header.Key, "Cookie") {
|
||||
var names []string
|
||||
for _, part := range httputil.SplitCookieHeader(header.Value) {
|
||||
name, _, _ := strings.Cut(part, "=")
|
||||
names = append(names, strings.TrimSpace(name))
|
||||
}
|
||||
markCookieAuth(req, names)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,7 +43,5 @@ func (s *HeadersAuthStrategy) Apply(req *http.Request) {
|
||||
// NOTE: This preserves exact header casing (e.g., barAuthToken stays as barAuthToken)
|
||||
// This is useful for APIs that require case-sensitive header names
|
||||
func (s *HeadersAuthStrategy) ApplyOnRR(req *retryablehttp.Request) {
|
||||
for _, header := range s.Data.Headers {
|
||||
req.Header[header.Key] = []string{header.Value}
|
||||
}
|
||||
s.Apply(req.Request)
|
||||
}
|
||||
@@ -59,6 +59,35 @@ func (c *Config) ReadTemplatesConfig() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReloadTemplateVersion refreshes the installed templates version from disk.
|
||||
// Concurrent processes read the version once at startup, so one that waited on
|
||||
// another's install or update must reload it to see the result instead of
|
||||
// downloading the same release again. State recorded for a different templates
|
||||
// directory is ignored.
|
||||
func (c *Config) ReloadTemplateVersion() error {
|
||||
statePath := c.getTemplatesConfigFilePath()
|
||||
data, err := os.ReadFile(statePath)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("read templates state %q: %w", statePath, err)
|
||||
}
|
||||
|
||||
var state templatesState
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return fmt.Errorf("decode templates state %q: %w", statePath, err)
|
||||
}
|
||||
|
||||
c.m.Lock()
|
||||
defer c.m.Unlock()
|
||||
// compare the resolved directory, so state written through a symlink is
|
||||
// visible to a process using the real path, and the other way around
|
||||
if CanonicalTemplatesPath(state.TemplatesDirectory) == CanonicalTemplatesPath(c.TemplatesDirectory) {
|
||||
c.TemplateVersion = state.TemplateVersion
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WriteTemplatesConfig atomically writes restart-persistent templates state.
|
||||
func (c *Config) WriteTemplatesConfig() error {
|
||||
state := templatesState{
|
||||
|
||||
@@ -139,3 +139,94 @@ func writeTestFile(t *testing.T, path string, data []byte, mode os.FileMode) {
|
||||
t.Fatalf("write test file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReloadTemplateVersion(t *testing.T) {
|
||||
newConfig := func(t *testing.T, templatesDir string) *Config {
|
||||
t.Helper()
|
||||
cfg := &Config{stateDir: t.TempDir()}
|
||||
cfg.setTemplatesDir(templatesDir)
|
||||
return cfg
|
||||
}
|
||||
|
||||
t.Run("adopts the version another process installed", func(t *testing.T) {
|
||||
templatesDir := t.TempDir()
|
||||
cfg := newConfig(t, templatesDir)
|
||||
other := &Config{stateDir: cfg.stateDir, TemplateVersion: "v2.0.0"}
|
||||
other.setTemplatesDir(templatesDir)
|
||||
if err := other.WriteTemplatesConfig(); err != nil {
|
||||
t.Fatalf("write templates state: %v", err)
|
||||
}
|
||||
|
||||
if err := cfg.ReloadTemplateVersion(); err != nil {
|
||||
t.Fatalf("reload templates version: %v", err)
|
||||
}
|
||||
if cfg.TemplateVersion != "v2.0.0" {
|
||||
t.Fatalf("template version = %q, want v2.0.0", cfg.TemplateVersion)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ignores state of another templates directory", func(t *testing.T) {
|
||||
cfg := newConfig(t, t.TempDir())
|
||||
cfg.TemplateVersion = "v1.0.0"
|
||||
other := &Config{stateDir: cfg.stateDir, TemplateVersion: "v2.0.0"}
|
||||
other.setTemplatesDir(t.TempDir())
|
||||
if err := other.WriteTemplatesConfig(); err != nil {
|
||||
t.Fatalf("write templates state: %v", err)
|
||||
}
|
||||
|
||||
if err := cfg.ReloadTemplateVersion(); err != nil {
|
||||
t.Fatalf("reload templates version: %v", err)
|
||||
}
|
||||
if cfg.TemplateVersion != "v1.0.0" {
|
||||
t.Fatalf("template version = %q, want v1.0.0", cfg.TemplateVersion)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("keeps the loaded version without state", func(t *testing.T) {
|
||||
cfg := newConfig(t, t.TempDir())
|
||||
cfg.TemplateVersion = "v1.0.0"
|
||||
|
||||
if err := cfg.ReloadTemplateVersion(); err != nil {
|
||||
t.Fatalf("reload templates version: %v", err)
|
||||
}
|
||||
if cfg.TemplateVersion != "v1.0.0" {
|
||||
t.Fatalf("template version = %q, want v1.0.0", cfg.TemplateVersion)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("adopts the version written through the other name of a symlink", func(t *testing.T) {
|
||||
templatesDir := t.TempDir()
|
||||
link := filepath.Join(t.TempDir(), "templates-link")
|
||||
if err := os.Symlink(templatesDir, link); err != nil {
|
||||
t.Skipf("symlinks are unavailable: %v", err)
|
||||
}
|
||||
|
||||
cfg := newConfig(t, link)
|
||||
other := &Config{stateDir: cfg.stateDir, TemplateVersion: "v2.0.0"}
|
||||
other.setTemplatesDir(templatesDir)
|
||||
if err := other.WriteTemplatesConfig(); err != nil {
|
||||
t.Fatalf("write templates state: %v", err)
|
||||
}
|
||||
|
||||
if err := cfg.ReloadTemplateVersion(); err != nil {
|
||||
t.Fatalf("reload templates version: %v", err)
|
||||
}
|
||||
if cfg.TemplateVersion != "v2.0.0" {
|
||||
t.Fatalf("template version = %q, want v2.0.0", cfg.TemplateVersion)
|
||||
}
|
||||
|
||||
cfg.TemplateVersion = "v1.0.0"
|
||||
cfg.setTemplatesDir(templatesDir)
|
||||
writer := &Config{stateDir: cfg.stateDir, TemplateVersion: "v3.0.0"}
|
||||
writer.setTemplatesDir(link)
|
||||
if err := writer.WriteTemplatesConfig(); err != nil {
|
||||
t.Fatalf("write templates state through the link: %v", err)
|
||||
}
|
||||
if err := cfg.ReloadTemplateVersion(); err != nil {
|
||||
t.Fatalf("reload templates version: %v", err)
|
||||
}
|
||||
if cfg.TemplateVersion != "v3.0.0" {
|
||||
t.Fatalf("template version = %q, want v3.0.0", cfg.TemplateVersion)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// CanonicalTemplatesPath is the identity of a templates directory. A symlink
|
||||
// and its target are the same directory, including while the target does not
|
||||
// exist yet, so a lock and a reloaded version follow either name.
|
||||
func CanonicalTemplatesPath(path string) string {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return ""
|
||||
}
|
||||
return resolveTemplatesPath(path, map[string]struct{}{})
|
||||
}
|
||||
|
||||
func resolveTemplatesPath(path string, seen map[string]struct{}) string {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
abs = filepath.Clean(path)
|
||||
}
|
||||
if _, ok := seen[abs]; ok {
|
||||
return abs
|
||||
}
|
||||
seen[abs] = struct{}{}
|
||||
|
||||
dir := abs
|
||||
var missing []string
|
||||
for {
|
||||
info, err := os.Lstat(dir)
|
||||
if err == nil {
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
if resolved, err := filepath.EvalSymlinks(dir); err == nil {
|
||||
return filepath.Join(append([]string{resolved}, missing...)...)
|
||||
}
|
||||
target, readErr := os.Readlink(dir)
|
||||
if readErr != nil {
|
||||
return filepath.Join(append([]string{dir}, missing...)...)
|
||||
}
|
||||
if !filepath.IsAbs(target) {
|
||||
target = filepath.Join(filepath.Dir(dir), target)
|
||||
}
|
||||
resolvedTarget := resolveTemplatesPath(target, seen)
|
||||
return filepath.Join(append([]string{resolvedTarget}, missing...)...)
|
||||
}
|
||||
resolved := dir
|
||||
if r, err := filepath.EvalSymlinks(dir); err == nil {
|
||||
resolved = r
|
||||
}
|
||||
return filepath.Join(append([]string{resolved}, missing...)...)
|
||||
}
|
||||
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
return filepath.Join(append([]string{dir}, missing...)...)
|
||||
}
|
||||
missing = append([]string{filepath.Base(dir)}, missing...)
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ const (
|
||||
|
||||
// IndexVersion is the schema version for cache invalidation on breaking
|
||||
// changes.
|
||||
IndexVersion = 2
|
||||
IndexVersion = 3
|
||||
|
||||
// DefaultMaxSize is the default maximum number of templates to cache.
|
||||
DefaultMaxSize = 50000
|
||||
@@ -70,6 +70,7 @@ func NewIndex(cacheDir string) (*Index, error) {
|
||||
weight += len(value.Name)
|
||||
weight += len(value.Severity)
|
||||
weight += len(value.ProtocolType)
|
||||
weight += len(value.Product)
|
||||
weight += len(value.TemplateVerifier)
|
||||
weight += len(value.VerifierFingerprint)
|
||||
weight += len(value.ContentDigest)
|
||||
|
||||
@@ -905,6 +905,7 @@ func TestNewMetadataFromTemplate(t *testing.T) {
|
||||
SeverityHolder: severity.Holder{
|
||||
Severity: severity.Low,
|
||||
},
|
||||
Metadata: map[string]any{"product": " Tomcat "},
|
||||
},
|
||||
Verified: true,
|
||||
TemplateVerifier: "verifier",
|
||||
@@ -922,4 +923,5 @@ func TestNewMetadataFromTemplate(t *testing.T) {
|
||||
require.Equal(t, tmpl.Type().String(), metadata.ProtocolType)
|
||||
require.Equal(t, tmpl.Verified, metadata.Verified)
|
||||
require.Equal(t, tmpl.TemplateVerifier, metadata.TemplateVerifier)
|
||||
require.Equal(t, "tomcat", metadata.Product)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package index
|
||||
import (
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/model/types/severity"
|
||||
@@ -48,6 +49,9 @@ type Metadata struct {
|
||||
// ProtocolType is the primary protocol type of the template.
|
||||
ProtocolType string `gob:"protocol_type"`
|
||||
|
||||
// Product is the lowercased info.metadata.product of the template.
|
||||
Product string `gob:"product,omitempty"`
|
||||
|
||||
// Verified indicates whether the template is verified.
|
||||
Verified bool `gob:"verified"`
|
||||
|
||||
@@ -114,6 +118,7 @@ func NewMetadataFromTemplate(path string, tpl *templates.Template) *Metadata {
|
||||
Severity: tpl.Info.SeverityHolder.Severity.String(),
|
||||
|
||||
ProtocolType: tpl.Type().String(),
|
||||
Product: templateProduct(tpl),
|
||||
|
||||
Verified: tpl.Verified,
|
||||
TemplateVerifier: tpl.TemplateVerifier,
|
||||
@@ -122,6 +127,11 @@ func NewMetadataFromTemplate(path string, tpl *templates.Template) *Metadata {
|
||||
}
|
||||
}
|
||||
|
||||
func templateProduct(tpl *templates.Template) string {
|
||||
product, _ := tpl.Info.Metadata["product"].(string)
|
||||
return strings.ToLower(strings.TrimSpace(product))
|
||||
}
|
||||
|
||||
// IsValid checks if the cached metadata is still valid by comparing the file
|
||||
// modification time.
|
||||
func (m *Metadata) IsValid() bool {
|
||||
|
||||
@@ -76,6 +76,10 @@ type Config struct {
|
||||
// MetadataIndex is an optional shared index borrowed by the store. The
|
||||
// caller remains responsible for persisting it.
|
||||
MetadataIndex *index.Index
|
||||
|
||||
// TargetFilter narrows loading to the templates per-target profiles
|
||||
// select for at least one target; nil loads every filtered template.
|
||||
TargetFilter index.FilterFunc
|
||||
}
|
||||
|
||||
// Store is a storage for loaded nuclei templates
|
||||
@@ -387,6 +391,11 @@ func (store *Store) buildIndexFilter() *index.Filter {
|
||||
}
|
||||
}
|
||||
|
||||
// selectedByTargets reports whether some target's profile selects the template.
|
||||
func (store *Store) selectedByTargets(metadata *index.Metadata) bool {
|
||||
return store.config.TargetFilter == nil || store.config.TargetFilter(metadata)
|
||||
}
|
||||
|
||||
func (store *Store) loadTemplatesIndex() *index.Index {
|
||||
var metadataIdx *index.Index
|
||||
|
||||
@@ -882,6 +891,10 @@ func (store *Store) LoadTemplatesWithTags(templatesList, tags []string) ([]*temp
|
||||
return
|
||||
}
|
||||
|
||||
if !store.selectedByTargets(metadata) {
|
||||
return
|
||||
}
|
||||
|
||||
if len(tags) > 0 && !slices.ContainsFunc(tags, metadata.HasTag) {
|
||||
return
|
||||
}
|
||||
@@ -916,6 +929,10 @@ func (store *Store) LoadTemplatesWithTags(templatesList, tags []string) ([]*temp
|
||||
noteExcludedByTag(templatePath, metadata)
|
||||
return
|
||||
}
|
||||
|
||||
if metadata != nil && !store.selectedByTargets(metadata) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
|
||||
+60
-6
@@ -1,12 +1,38 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"sync"
|
||||
|
||||
"github.com/projectdiscovery/gologger"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/output"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
)
|
||||
|
||||
// TemplateExecutionState identifies a template execution lifecycle transition.
|
||||
type TemplateExecutionState uint8
|
||||
|
||||
const (
|
||||
// TemplateExecutionStarted is emitted immediately before a template starts on a target.
|
||||
TemplateExecutionStarted TemplateExecutionState = iota + 1
|
||||
// TemplateExecutionFinished is emitted after that template execution returns.
|
||||
TemplateExecutionFinished
|
||||
)
|
||||
|
||||
// TemplateExecutionEvent identifies one template execution on one target.
|
||||
type TemplateExecutionEvent struct {
|
||||
TemplateID string
|
||||
TemplatePath string
|
||||
Target string
|
||||
State TemplateExecutionState
|
||||
ContextErr error
|
||||
}
|
||||
|
||||
// TemplateExecutionCallback observes template execution lifecycle transitions.
|
||||
// Nuclei can invoke the callback concurrently; implementations must be concurrency-safe.
|
||||
type TemplateExecutionCallback func(TemplateExecutionEvent)
|
||||
|
||||
// Engine is an executer for running Nuclei Templates/Workflows.
|
||||
//
|
||||
// The engine contains multiple thread pools which allow using different
|
||||
@@ -16,11 +42,16 @@ import (
|
||||
// templates to leading to the final execution by the work pool, it is
|
||||
// handled by the engine.
|
||||
type Engine struct {
|
||||
workPool *WorkPool
|
||||
options *types.Options
|
||||
executerOpts *protocols.ExecutorOptions
|
||||
Callback func(*output.ResultEvent) // Executed on results
|
||||
Logger *gologger.Logger
|
||||
workPool *WorkPool
|
||||
// originScoped tracks origin-scoped template attempts. A pair is finished
|
||||
// only after a successful execution, so a failed first target does not
|
||||
// suppress every later URL on that origin.
|
||||
originScoped sync.Map
|
||||
options *types.Options
|
||||
executerOpts *protocols.ExecutorOptions
|
||||
Callback func(*output.ResultEvent) // Executed on results
|
||||
templateExecutionCallback TemplateExecutionCallback
|
||||
Logger *gologger.Logger
|
||||
}
|
||||
|
||||
// New returns a new Engine instance
|
||||
@@ -36,7 +67,7 @@ func New(options *types.Options) *Engine {
|
||||
func (e *Engine) GetWorkPoolConfig() WorkPoolConfig {
|
||||
config := WorkPoolConfig{
|
||||
InputConcurrency: e.options.BulkSize,
|
||||
TypeConcurrency: e.options.TemplateThreads,
|
||||
TypeConcurrency: e.options.CurrentTemplateThreads(),
|
||||
HeadlessInputConcurrency: e.options.HeadlessBulkSize,
|
||||
HeadlessTypeConcurrency: e.options.HeadlessTemplateThreads,
|
||||
}
|
||||
@@ -54,6 +85,29 @@ func (e *Engine) SetExecuterOptions(options *protocols.ExecutorOptions) {
|
||||
e.executerOpts = options
|
||||
}
|
||||
|
||||
// SetTemplateExecutionCallback registers a callback for template execution lifecycle events.
|
||||
func (e *Engine) SetTemplateExecutionCallback(callback TemplateExecutionCallback) {
|
||||
e.templateExecutionCallback = callback
|
||||
}
|
||||
|
||||
func (e *Engine) templateExecutionStarted(template *templates.Template, target string) func(error) {
|
||||
if e.templateExecutionCallback == nil {
|
||||
return func(error) {}
|
||||
}
|
||||
event := TemplateExecutionEvent{
|
||||
TemplateID: template.ID,
|
||||
TemplatePath: template.Path,
|
||||
Target: target,
|
||||
State: TemplateExecutionStarted,
|
||||
}
|
||||
e.templateExecutionCallback(event)
|
||||
return func(contextErr error) {
|
||||
event.State = TemplateExecutionFinished
|
||||
event.ContextErr = contextErr
|
||||
e.templateExecutionCallback(event)
|
||||
}
|
||||
}
|
||||
|
||||
// ExecuterOptions returns protocols.ExecutorOptions for nuclei engine.
|
||||
func (e *Engine) ExecuterOptions() *protocols.ExecutorOptions {
|
||||
return e.executerOpts
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/input/provider"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/output"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/templates/types"
|
||||
@@ -67,7 +68,7 @@ func (e *Engine) ExecuteScanWithOpts(ctx context.Context, templatesList []*templ
|
||||
// workflow requests are not counted as they can be conditional
|
||||
// templateList count is user requested templates count (before clustering)
|
||||
// totalReqAfterClustering is total requests count after clustering
|
||||
e.executerOpts.Progress.Init(target.Count(), len(templatesList), int64(totalReqAfterClustering))
|
||||
e.executerOpts.Progress.Init(target.Count(), len(templatesList), e.expectedRequests(finalTemplates, target, totalReqAfterClustering))
|
||||
}
|
||||
|
||||
if stringsutil.EqualFoldAny(e.options.ScanStrategy, scanstrategy.Auto.String(), "") {
|
||||
@@ -132,14 +133,26 @@ func (e *Engine) executeTemplateSpray(ctx context.Context, templatesList []*temp
|
||||
wg = wp.Default
|
||||
}
|
||||
|
||||
wg.Add()
|
||||
go func(tpl *templates.Template) {
|
||||
if err := wg.AddWithContext(ctx); err != nil {
|
||||
return results
|
||||
}
|
||||
usesSharedTemplateBudget := templateType != types.HeadlessProtocol
|
||||
if usesSharedTemplateBudget {
|
||||
if err := e.options.AcquireTemplateThread(ctx); err != nil {
|
||||
wg.Done()
|
||||
return results
|
||||
}
|
||||
}
|
||||
go func(tpl *templates.Template, sharedBudget bool) {
|
||||
defer wg.Done()
|
||||
if sharedBudget {
|
||||
defer e.options.ReleaseTemplateThread()
|
||||
}
|
||||
// All other request types are executed here
|
||||
// Note: executeTemplateWithTargets creates goroutines and blocks
|
||||
// given template is executed on all targets
|
||||
e.executeTemplateWithTargets(ctx, tpl, target, results)
|
||||
}(template)
|
||||
}(template, usesSharedTemplateBudget)
|
||||
}
|
||||
return results
|
||||
}
|
||||
@@ -167,8 +180,33 @@ func (e *Engine) executeHostSpray(ctx context.Context, templatesList []*template
|
||||
return results
|
||||
}
|
||||
|
||||
// expectedRequests counts only the template and target pairs that per-target
|
||||
// profiles select, so progress reflects the requests that will be sent.
|
||||
func (e *Engine) expectedRequests(templatesList []*templates.Template, target provider.InputProvider, unscoped int) int64 {
|
||||
scope := e.executerOpts.TargetScope
|
||||
if scope == nil {
|
||||
return int64(unscoped)
|
||||
}
|
||||
targets := make(map[protocols.TemplateSelection]int64)
|
||||
target.Iterate(func(value *contextargs.MetaInput) bool {
|
||||
targets[scope.For(value)]++
|
||||
return true
|
||||
})
|
||||
var total int64
|
||||
for selection, count := range targets {
|
||||
total += count * int64(getSelectedRequestCount(templatesList, selection))
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
// returns total requests count
|
||||
func getRequestCount(templates []*templates.Template) int {
|
||||
return getSelectedRequestCount(templates, nil)
|
||||
}
|
||||
|
||||
// getSelectedRequestCount returns the requests of the templates selection
|
||||
// selects; a nil selection selects every template.
|
||||
func getSelectedRequestCount(templates []*templates.Template, selection protocols.TemplateSelection) int {
|
||||
count := 0
|
||||
for _, template := range templates {
|
||||
// ignore requests in workflows as total requests in workflow
|
||||
@@ -176,6 +214,9 @@ func getRequestCount(templates []*templates.Template) int {
|
||||
if len(template.Workflows) > 0 {
|
||||
continue
|
||||
}
|
||||
if selection != nil && !template.SelectedBy(selection) {
|
||||
continue
|
||||
}
|
||||
count += template.TotalRequests
|
||||
}
|
||||
return count
|
||||
|
||||
+122
-10
@@ -6,6 +6,7 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/input/normalize"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/input/provider"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/output"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
|
||||
@@ -21,9 +22,20 @@ import (
|
||||
// executeAllSelfContained executes all self contained templates that do not use `target`
|
||||
func (e *Engine) executeAllSelfContained(ctx context.Context, alltemplates []*templates.Template, results *atomic.Bool, sg *sync.WaitGroup) {
|
||||
for _, v := range alltemplates {
|
||||
usesSharedTemplateBudget := v.Type() != types.HeadlessProtocol
|
||||
if usesSharedTemplateBudget {
|
||||
if err := e.options.AcquireTemplateThread(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
sg.Add(1)
|
||||
go func(template *templates.Template) {
|
||||
go func(template *templates.Template, sharedBudget bool) {
|
||||
defer sg.Done()
|
||||
if sharedBudget {
|
||||
defer e.options.ReleaseTemplateThread()
|
||||
}
|
||||
finished := e.templateExecutionStarted(template, "")
|
||||
defer func() { finished(ctx.Err()) }()
|
||||
var err error
|
||||
var match bool
|
||||
ctx := scan.NewScanContext(ctx, contextargs.New(ctx))
|
||||
@@ -42,7 +54,7 @@ func (e *Engine) executeAllSelfContained(ctx context.Context, alltemplates []*te
|
||||
e.options.Logger.Warning().Msgf("[%s] Could not execute step (self-contained): %s\n", e.executerOpts.Colorizer.BrightBlue(template.ID), err)
|
||||
}
|
||||
results.CompareAndSwap(false, match)
|
||||
}(v)
|
||||
}(v, usesSharedTemplateBudget)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,12 +155,18 @@ func (e *Engine) executeTemplateWithTargets(ctx context.Context, template *templ
|
||||
skip = false
|
||||
}
|
||||
|
||||
// out of scope pairs keep their index so resume positions stay stable
|
||||
inScope := e.inScope(template, scannedValue)
|
||||
if !inScope {
|
||||
skip = true
|
||||
}
|
||||
|
||||
currentInfo.Lock()
|
||||
currentInfo.InFlight[index] = struct{}{}
|
||||
currentInfo.Unlock()
|
||||
|
||||
// Skip if the host has had errors
|
||||
if e.executerOpts.HostErrorsCache != nil && e.executerOpts.HostErrorsCache.Check(e.executerOpts.ProtocolType.String(), contextargs.NewWithMetaInput(ctx, scannedValue)) {
|
||||
if inScope && e.executerOpts.HostErrorsCache != nil && e.executerOpts.HostErrorsCache.Check(e.executerOpts.ProtocolType.String(), contextargs.NewWithMetaInput(ctx, scannedValue)) {
|
||||
skipEvent := &output.ResultEvent{
|
||||
TemplateID: template.ID,
|
||||
TemplatePath: template.Path,
|
||||
@@ -199,6 +217,10 @@ func (e *Engine) executeTemplatesOnTarget(ctx context.Context, alltemplates []*t
|
||||
default:
|
||||
}
|
||||
|
||||
if !e.inScope(tpl, target) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check whether the target has already been marked as permanently
|
||||
// unresponsive by HostErrorsCache before spawning another goroutine.
|
||||
if e.executerOpts.HostErrorsCache != nil &&
|
||||
@@ -230,21 +252,54 @@ func (e *Engine) executeTemplatesOnTarget(ctx context.Context, alltemplates []*t
|
||||
} else {
|
||||
sg = wp.Default
|
||||
}
|
||||
sg.Add()
|
||||
go func(template *templates.Template, value *contextargs.MetaInput, wg *syncutil.AdaptiveWaitGroup) {
|
||||
if err := sg.AddWithContext(ctx); err != nil {
|
||||
return
|
||||
}
|
||||
usesSharedTemplateBudget := tpl.Type() != types.HeadlessProtocol
|
||||
if usesSharedTemplateBudget {
|
||||
if err := e.options.AcquireTemplateThread(ctx); err != nil {
|
||||
sg.Done()
|
||||
return
|
||||
}
|
||||
}
|
||||
go func(template *templates.Template, value *contextargs.MetaInput, wg *syncutil.AdaptiveWaitGroup, sharedBudget bool) {
|
||||
defer wg.Done()
|
||||
if sharedBudget {
|
||||
defer e.options.ReleaseTemplateThread()
|
||||
}
|
||||
|
||||
match, err := e.executeTemplateOnInput(ctx, template, value)
|
||||
if err != nil {
|
||||
e.options.Logger.Warning().Msgf("[%s] Could not execute step on %s: %s\n", template.ID, value.Input, err)
|
||||
}
|
||||
results.CompareAndSwap(false, match)
|
||||
}(tpl, target, sg)
|
||||
}(tpl, target, sg, usesSharedTemplateBudget)
|
||||
}
|
||||
}
|
||||
|
||||
// inScope reports whether per-target profiles select template for input.
|
||||
func (e *Engine) inScope(template *templates.Template, input *contextargs.MetaInput) bool {
|
||||
if e.executerOpts.TargetScope == nil {
|
||||
return true
|
||||
}
|
||||
selection := e.executerOpts.TargetScope.For(input)
|
||||
return selection == nil || template.SelectedBy(selection)
|
||||
}
|
||||
|
||||
// executeTemplateOnInput performs template execution for a single input and returns match status and error
|
||||
func (e *Engine) executeTemplateOnInput(ctx context.Context, template *templates.Template, value *contextargs.MetaInput) (bool, error) {
|
||||
func (e *Engine) executeTemplateOnInput(ctx context.Context, template *templates.Template, value *contextargs.MetaInput) (matched bool, err error) {
|
||||
// A template fixed to the target's origin makes the same requests for every
|
||||
// target that shares one, which a crawled list produces by the hundred.
|
||||
// Running it once per origin sends exactly the same traffic, minus the
|
||||
// repeats. The reservation is kept only when execution succeeds, so a
|
||||
// failed attempt leaves the origin free for a later target.
|
||||
finish, run := e.claimOriginScoped(template, value)
|
||||
if !run {
|
||||
return false, nil
|
||||
}
|
||||
defer func() { finish(err == nil) }()
|
||||
finished := e.templateExecutionStarted(template, value.Input)
|
||||
defer func() { finished(ctx.Err()) }()
|
||||
ctxArgs := contextargs.New(ctx)
|
||||
ctxArgs.MetaInput = value
|
||||
scanCtx := scan.NewScanContext(ctx, ctxArgs)
|
||||
@@ -254,9 +309,9 @@ func (e *Engine) executeTemplateOnInput(ctx context.Context, template *templates
|
||||
return e.executeWorkflow(scanCtx, template.CompiledWorkflow), nil
|
||||
default:
|
||||
if e.Callback != nil {
|
||||
results, err := template.Executer.ExecuteWithResults(scanCtx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
results, execErr := template.Executer.ExecuteWithResults(scanCtx)
|
||||
if execErr != nil {
|
||||
return false, execErr
|
||||
}
|
||||
for _, result := range results {
|
||||
e.Callback(result)
|
||||
@@ -266,3 +321,60 @@ func (e *Engine) executeTemplateOnInput(ctx context.Context, template *templates
|
||||
return template.Executer.Execute(scanCtx)
|
||||
}
|
||||
}
|
||||
|
||||
// originAttempt is one origin-scoped execution. done is set only after a
|
||||
// successful run. While inflight is set, other callers wait, then retry if
|
||||
// that attempt failed.
|
||||
type originAttempt struct {
|
||||
mu sync.Mutex
|
||||
cond *sync.Cond
|
||||
inflight bool
|
||||
done bool
|
||||
}
|
||||
|
||||
func newOriginAttempt() *originAttempt {
|
||||
attempt := &originAttempt{}
|
||||
attempt.cond = sync.NewCond(&attempt.mu)
|
||||
return attempt
|
||||
}
|
||||
|
||||
// claimOriginScoped reports whether this caller should execute an origin-scoped
|
||||
// template. finish(true) records a successful run. finish(false) releases the
|
||||
// claim so a later target, including one that waited, can try again. Templates
|
||||
// whose requests depend on the target path always run. CustomIP is part of the
|
||||
// key so each address selected for one URL is tested on its own.
|
||||
func (e *Engine) claimOriginScoped(template *templates.Template, value *contextargs.MetaInput) (finish func(bool), run bool) {
|
||||
noop := func(bool) {}
|
||||
if template == nil || value == nil || !template.IsOriginScoped() {
|
||||
return noop, true
|
||||
}
|
||||
origin := normalize.Origin(value.Input)
|
||||
if origin == "" {
|
||||
return noop, true
|
||||
}
|
||||
key := template.ID + "\x00" + origin + "\x00" + value.CustomIP
|
||||
actual, _ := e.originScoped.LoadOrStore(key, newOriginAttempt())
|
||||
slot := actual.(*originAttempt)
|
||||
|
||||
slot.mu.Lock()
|
||||
for {
|
||||
if slot.done {
|
||||
slot.mu.Unlock()
|
||||
return noop, false
|
||||
}
|
||||
if !slot.inflight {
|
||||
slot.inflight = true
|
||||
slot.mu.Unlock()
|
||||
return func(success bool) {
|
||||
slot.mu.Lock()
|
||||
slot.inflight = false
|
||||
if success {
|
||||
slot.done = true
|
||||
}
|
||||
slot.cond.Broadcast()
|
||||
slot.mu.Unlock()
|
||||
}, true
|
||||
}
|
||||
slot.cond.Wait()
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,10 @@ package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -11,6 +14,7 @@ import (
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/output"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
|
||||
httpprotocol "github.com/projectdiscovery/nuclei/v3/pkg/protocols/http"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/scan"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
|
||||
tmpltypes "github.com/projectdiscovery/nuclei/v3/pkg/templates/types"
|
||||
@@ -95,6 +99,332 @@ func Test_executeTemplateOnInput_CallbackErrorPropagates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteTemplateOnInputReportsLifecycle(t *testing.T) {
|
||||
e := newTestEngine()
|
||||
var events []TemplateExecutionEvent
|
||||
e.SetTemplateExecutionCallback(func(event TemplateExecutionEvent) {
|
||||
events = append(events, event)
|
||||
})
|
||||
|
||||
tpl := &templates.Template{ID: "template-id", Path: "http/example.yaml"}
|
||||
tpl.Executer = &fakeExecuter{withResults: false}
|
||||
|
||||
_, err := e.executeTemplateOnInput(context.Background(), tpl, &contextargs.MetaInput{Input: "https://example.com"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
want := []TemplateExecutionEvent{
|
||||
{TemplateID: "template-id", TemplatePath: "http/example.yaml", Target: "https://example.com", State: TemplateExecutionStarted},
|
||||
{TemplateID: "template-id", TemplatePath: "http/example.yaml", Target: "https://example.com", State: TemplateExecutionFinished},
|
||||
}
|
||||
if !reflect.DeepEqual(events, want) {
|
||||
t.Fatalf("unexpected lifecycle events: got %#v want %#v", events, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteTemplateOnInputReportsCancellationOnFinish(t *testing.T) {
|
||||
e := newTestEngine()
|
||||
var finished TemplateExecutionEvent
|
||||
e.SetTemplateExecutionCallback(func(event TemplateExecutionEvent) {
|
||||
if event.State == TemplateExecutionFinished {
|
||||
finished = event
|
||||
}
|
||||
})
|
||||
tpl := &templates.Template{ID: "interrupted"}
|
||||
tpl.Executer = &fakeExecuter{withResults: false}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
_, _ = e.executeTemplateOnInput(ctx, tpl, &contextargs.MetaInput{Input: "target"})
|
||||
if finished.ContextErr != context.Canceled {
|
||||
t.Fatalf("got finish context error %v, want context canceled", finished.ContextErr)
|
||||
}
|
||||
}
|
||||
|
||||
type scriptedExecuter struct {
|
||||
calls int
|
||||
errs []error
|
||||
miss bool
|
||||
}
|
||||
|
||||
func (s *scriptedExecuter) Compile() error { return nil }
|
||||
func (s *scriptedExecuter) Requests() int { return 1 }
|
||||
func (s *scriptedExecuter) Execute(ctx *scan.ScanContext) (bool, error) {
|
||||
s.calls++
|
||||
if len(s.errs) == 0 {
|
||||
return !s.miss, nil
|
||||
}
|
||||
err := s.errs[0]
|
||||
s.errs = s.errs[1:]
|
||||
return false, err
|
||||
}
|
||||
func (s *scriptedExecuter) ExecuteWithResults(ctx *scan.ScanContext) ([]*output.ResultEvent, error) {
|
||||
_, err := s.Execute(ctx)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func originScopedTemplate(path string) *templates.Template {
|
||||
return &templates.Template{
|
||||
ID: "root-health",
|
||||
RequestsHTTP: []*httpprotocol.Request{{
|
||||
Path: []string{path},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestOriginScopedRetriesAfterFailure(t *testing.T) {
|
||||
e := newTestEngine()
|
||||
exec := &scriptedExecuter{errs: []error{errors.New("timeout")}}
|
||||
tpl := originScopedTemplate("{{RootURL}}/health")
|
||||
tpl.Executer = exec
|
||||
|
||||
_, err := e.executeTemplateOnInput(context.Background(), tpl, &contextargs.MetaInput{Input: "https://example.com/a"})
|
||||
if err == nil {
|
||||
t.Fatal("expected the first attempt to fail")
|
||||
}
|
||||
ok, err := e.executeTemplateOnInput(context.Background(), tpl, &contextargs.MetaInput{Input: "https://example.com/b"})
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("later target should retry the origin, ok=%v err=%v", ok, err)
|
||||
}
|
||||
if exec.calls != 2 {
|
||||
t.Fatalf("calls = %d, want 2", exec.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOriginScopedSkipsAfterSuccess(t *testing.T) {
|
||||
e := newTestEngine()
|
||||
exec := &scriptedExecuter{}
|
||||
tpl := originScopedTemplate("{{RootURL}}/health")
|
||||
tpl.Executer = exec
|
||||
|
||||
if _, err := e.executeTemplateOnInput(context.Background(), tpl, &contextargs.MetaInput{Input: "https://example.com/a"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := e.executeTemplateOnInput(context.Background(), tpl, &contextargs.MetaInput{Input: "https://example.com/b?q=1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if exec.calls != 1 {
|
||||
t.Fatalf("calls = %d, want 1", exec.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOriginScopedKeepsEachCustomIP(t *testing.T) {
|
||||
e := newTestEngine()
|
||||
exec := &scriptedExecuter{}
|
||||
tpl := originScopedTemplate("{{RootURL}}/health")
|
||||
tpl.Executer = exec
|
||||
|
||||
for _, ip := range []string{"203.0.113.1", "203.0.113.2"} {
|
||||
_, err := e.executeTemplateOnInput(context.Background(), tpl, &contextargs.MetaInput{
|
||||
Input: "https://example.com/a",
|
||||
CustomIP: ip,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if exec.calls != 2 {
|
||||
t.Fatalf("calls = %d, want 2", exec.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBaseURLTemplateIsNotOriginScoped(t *testing.T) {
|
||||
e := newTestEngine()
|
||||
exec := &scriptedExecuter{}
|
||||
tpl := originScopedTemplate("{{BaseURL}}/health")
|
||||
tpl.Executer = exec
|
||||
|
||||
for _, raw := range []string{"https://example.com/a", "https://example.com/b"} {
|
||||
if _, err := e.executeTemplateOnInput(context.Background(), tpl, &contextargs.MetaInput{Input: raw}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if exec.calls != 2 {
|
||||
t.Fatalf("calls = %d, want 2", exec.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOriginScopedWaiterRetriesAfterFailedAttempt(t *testing.T) {
|
||||
e := newTestEngine()
|
||||
started := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
var calls atomic.Int32
|
||||
tpl := originScopedTemplate("{{RootURL}}/health")
|
||||
tpl.Executer = &gatedExecuter{started: started, release: release, calls: &calls}
|
||||
|
||||
firstDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := e.executeTemplateOnInput(context.Background(), tpl, &contextargs.MetaInput{Input: "https://example.com/a"})
|
||||
firstDone <- err
|
||||
}()
|
||||
<-started
|
||||
secondDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := e.executeTemplateOnInput(context.Background(), tpl, &contextargs.MetaInput{Input: "https://example.com/b"})
|
||||
secondDone <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-secondDone:
|
||||
t.Fatalf("second target returned while the first attempt was still running: %v", err)
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
}
|
||||
close(release)
|
||||
if err := <-firstDone; err == nil {
|
||||
t.Fatal("expected the first attempt to fail")
|
||||
}
|
||||
if err := <-secondDone; err != nil {
|
||||
t.Fatalf("waiter should run after the failed attempt: %v", err)
|
||||
}
|
||||
if calls.Load() != 2 {
|
||||
t.Fatalf("calls = %d, want 2", calls.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOriginScopedKeepsDistinctOriginsAndTemplates(t *testing.T) {
|
||||
e := newTestEngine()
|
||||
exec := &scriptedExecuter{}
|
||||
first := originScopedTemplate("{{RootURL}}/health")
|
||||
first.Executer = exec
|
||||
second := originScopedTemplate("{{RootURL}}/health")
|
||||
second.ID = "other-template"
|
||||
second.Executer = exec
|
||||
|
||||
inputs := []*contextargs.MetaInput{
|
||||
{Input: "https://example.com/a"},
|
||||
{Input: "https://other.test/a"},
|
||||
{Input: "http://example.com/a"},
|
||||
{Input: "https://example.com:8443/a"},
|
||||
}
|
||||
for _, value := range inputs {
|
||||
if _, err := e.executeTemplateOnInput(context.Background(), first, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := e.executeTemplateOnInput(context.Background(), second, inputs[0]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if exec.calls != 5 {
|
||||
t.Fatalf("calls = %d, want 5", exec.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOriginScopedFoldsDefaultPort(t *testing.T) {
|
||||
e := newTestEngine()
|
||||
exec := &scriptedExecuter{}
|
||||
tpl := originScopedTemplate("{{RootURL}}/health")
|
||||
tpl.Executer = exec
|
||||
|
||||
for _, raw := range []string{"https://example.com/a", "https://example.com:443/b"} {
|
||||
if _, err := e.executeTemplateOnInput(context.Background(), tpl, &contextargs.MetaInput{Input: raw}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if exec.calls != 1 {
|
||||
t.Fatalf("calls = %d, want 1", exec.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOriginScopedSameCustomIPSkips(t *testing.T) {
|
||||
e := newTestEngine()
|
||||
exec := &scriptedExecuter{}
|
||||
tpl := originScopedTemplate("{{RootURL}}/health")
|
||||
tpl.Executer = exec
|
||||
|
||||
for range 2 {
|
||||
_, err := e.executeTemplateOnInput(context.Background(), tpl, &contextargs.MetaInput{
|
||||
Input: "https://example.com/a",
|
||||
CustomIP: "203.0.113.1",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if exec.calls != 1 {
|
||||
t.Fatalf("calls = %d, want 1", exec.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOriginScopedMissStillCounts(t *testing.T) {
|
||||
e := newTestEngine()
|
||||
exec := &scriptedExecuter{miss: true}
|
||||
tpl := originScopedTemplate("{{RootURL}}/health")
|
||||
tpl.Executer = exec
|
||||
|
||||
for _, raw := range []string{"https://example.com/a", "https://example.com/b"} {
|
||||
ok, err := e.executeTemplateOnInput(context.Background(), tpl, &contextargs.MetaInput{Input: raw})
|
||||
if err != nil || ok {
|
||||
t.Fatalf("non-match ok=%v err=%v", ok, err)
|
||||
}
|
||||
}
|
||||
if exec.calls != 1 {
|
||||
t.Fatalf("calls = %d, want 1", exec.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOriginScopedCallbackErrorRetries(t *testing.T) {
|
||||
e := newTestEngine()
|
||||
e.Callback = func(*output.ResultEvent) {}
|
||||
exec := &scriptedExecuter{errs: []error{errors.New("timeout")}}
|
||||
tpl := originScopedTemplate("{{RootURL}}/health")
|
||||
tpl.Executer = exec
|
||||
|
||||
if _, err := e.executeTemplateOnInput(context.Background(), tpl, &contextargs.MetaInput{Input: "https://example.com/a"}); err == nil {
|
||||
t.Fatal("expected the callback path to fail")
|
||||
}
|
||||
if _, err := e.executeTemplateOnInput(context.Background(), tpl, &contextargs.MetaInput{Input: "https://example.com/b"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if exec.calls != 2 {
|
||||
t.Fatalf("calls = %d, want 2", exec.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOriginScopedLeavesValuesWithoutAHost(t *testing.T) {
|
||||
e := newTestEngine()
|
||||
exec := &scriptedExecuter{}
|
||||
tpl := originScopedTemplate("{{RootURL}}/health")
|
||||
tpl.Executer = exec
|
||||
|
||||
for range 2 {
|
||||
if _, err := e.executeTemplateOnInput(context.Background(), tpl, &contextargs.MetaInput{Input: "not a url"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, raw := range []string{"example.com", "other.test", "example.com"} {
|
||||
if _, err := e.executeTemplateOnInput(context.Background(), tpl, &contextargs.MetaInput{Input: raw}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if exec.calls != 4 {
|
||||
t.Fatalf("calls = %d, want 4", exec.calls)
|
||||
}
|
||||
}
|
||||
|
||||
type gatedExecuter struct {
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
calls *atomic.Int32
|
||||
}
|
||||
|
||||
func (g *gatedExecuter) Compile() error { return nil }
|
||||
func (g *gatedExecuter) Requests() int { return 1 }
|
||||
func (g *gatedExecuter) Execute(ctx *scan.ScanContext) (bool, error) {
|
||||
if g.calls.Add(1) == 1 {
|
||||
close(g.started)
|
||||
<-g.release
|
||||
return false, errors.New("timeout")
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
func (g *gatedExecuter) ExecuteWithResults(ctx *scan.ScanContext) ([]*output.ResultEvent, error) {
|
||||
ok, err := g.Execute(ctx)
|
||||
if err != nil || !ok {
|
||||
return nil, err
|
||||
}
|
||||
return []*output.ResultEvent{{}}, nil
|
||||
}
|
||||
|
||||
type fakeTargetProvider struct {
|
||||
values []*contextargs.MetaInput
|
||||
}
|
||||
@@ -127,6 +457,185 @@ func (s *slowExecuter) Execute(ctx *scan.ScanContext) (bool, error) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
type adaptiveConcurrencyExecuter struct {
|
||||
delay time.Duration
|
||||
started atomic.Int32
|
||||
completed atomic.Int32
|
||||
inFlight atomic.Int32
|
||||
maxInFlight atomic.Int32
|
||||
onStart func(int32)
|
||||
}
|
||||
|
||||
func (s *adaptiveConcurrencyExecuter) Compile() error { return nil }
|
||||
func (s *adaptiveConcurrencyExecuter) Requests() int { return 1 }
|
||||
func (s *adaptiveConcurrencyExecuter) Execute(ctx *scan.ScanContext) (bool, error) {
|
||||
started := s.started.Add(1)
|
||||
if s.onStart != nil {
|
||||
s.onStart(started)
|
||||
}
|
||||
inFlight := s.inFlight.Add(1)
|
||||
for {
|
||||
maximum := s.maxInFlight.Load()
|
||||
if inFlight <= maximum || s.maxInFlight.CompareAndSwap(maximum, inFlight) {
|
||||
break
|
||||
}
|
||||
}
|
||||
defer s.inFlight.Add(-1)
|
||||
|
||||
select {
|
||||
case <-ctx.Context().Done():
|
||||
return false, ctx.Context().Err()
|
||||
case <-time.After(s.delay):
|
||||
s.completed.Add(1)
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
func (s *adaptiveConcurrencyExecuter) ExecuteWithResults(*scan.ScanContext) ([]*output.ResultEvent, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func TestExecuteTemplateSprayRefreshesDynamicTemplateConcurrency(t *testing.T) {
|
||||
const (
|
||||
templateCount = 8
|
||||
initialConcurrency = 2
|
||||
finalConcurrency = 8
|
||||
)
|
||||
|
||||
var currentConcurrency atomic.Int32
|
||||
currentConcurrency.Store(initialConcurrency)
|
||||
var expandOnce sync.Once
|
||||
executer := &adaptiveConcurrencyExecuter{
|
||||
delay: 100 * time.Millisecond,
|
||||
onStart: func(started int32) {
|
||||
if started == initialConcurrency {
|
||||
expandOnce.Do(func() { currentConcurrency.Store(finalConcurrency) })
|
||||
}
|
||||
},
|
||||
}
|
||||
options := &types.Options{
|
||||
BulkSize: 1,
|
||||
TemplateThreads: initialConcurrency,
|
||||
HeadlessBulkSize: 1,
|
||||
HeadlessTemplateThreads: 1,
|
||||
}
|
||||
options.SetTemplateThreadsProvider(func() int { return int(currentConcurrency.Load()) })
|
||||
if got := options.Copy().CurrentTemplateThreads(); got != initialConcurrency {
|
||||
t.Fatalf("copied options returned concurrency %d, expected %d", got, initialConcurrency)
|
||||
}
|
||||
engine := New(options)
|
||||
engine.SetExecuterOptions(&protocols.ExecutorOptions{
|
||||
Logger: engine.Logger,
|
||||
Options: options,
|
||||
ResumeCfg: types.NewResumeCfg(),
|
||||
ProtocolType: tmpltypes.NetworkProtocol,
|
||||
})
|
||||
templatesList := make([]*templates.Template, 0, templateCount)
|
||||
for index := range templateCount {
|
||||
templatesList = append(templatesList, &templates.Template{ID: fmt.Sprintf("adaptive-%d", index), Executer: executer})
|
||||
}
|
||||
targets := &fakeTargetProvider{values: []*contextargs.MetaInput{{Input: "slow-target"}}}
|
||||
|
||||
started := time.Now()
|
||||
engine.ExecuteScanWithOpts(context.Background(), templatesList, targets, true)
|
||||
elapsed := time.Since(started)
|
||||
|
||||
if got := executer.completed.Load(); got != templateCount {
|
||||
t.Fatalf("completed %d templates, expected %d", got, templateCount)
|
||||
}
|
||||
if got := executer.maxInFlight.Load(); got <= initialConcurrency {
|
||||
t.Fatalf("maximum concurrency stayed at %d; expected it to expand beyond %d", got, initialConcurrency)
|
||||
}
|
||||
if elapsed >= 350*time.Millisecond {
|
||||
t.Fatalf("dynamic execution took %s; fixed concurrency would take about 400ms", elapsed)
|
||||
}
|
||||
t.Logf("dynamic execution completed all %d templates in %s with peak concurrency %d", templateCount, elapsed, executer.maxInFlight.Load())
|
||||
}
|
||||
|
||||
func TestExecuteTemplateSprayHonorsSharedConcurrencyLimiter(t *testing.T) {
|
||||
const (
|
||||
templateCount = 8
|
||||
sharedBudget = 2
|
||||
)
|
||||
|
||||
tokens := make(chan struct{}, sharedBudget)
|
||||
executer := &adaptiveConcurrencyExecuter{delay: 50 * time.Millisecond}
|
||||
options := &types.Options{
|
||||
BulkSize: 1,
|
||||
TemplateThreads: templateCount,
|
||||
HeadlessBulkSize: 1,
|
||||
HeadlessTemplateThreads: 1,
|
||||
}
|
||||
options.SetTemplateThreadsLimiter(func(ctx context.Context) error {
|
||||
select {
|
||||
case tokens <- struct{}{}:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}, func() {
|
||||
<-tokens
|
||||
})
|
||||
engine := New(options)
|
||||
engine.SetExecuterOptions(&protocols.ExecutorOptions{
|
||||
Logger: engine.Logger,
|
||||
Options: options,
|
||||
ResumeCfg: types.NewResumeCfg(),
|
||||
ProtocolType: tmpltypes.NetworkProtocol,
|
||||
})
|
||||
templatesList := make([]*templates.Template, 0, templateCount)
|
||||
for index := range templateCount {
|
||||
templatesList = append(templatesList, &templates.Template{ID: fmt.Sprintf("shared-limit-%d", index), Executer: executer})
|
||||
}
|
||||
|
||||
engine.ExecuteScanWithOpts(context.Background(), templatesList,
|
||||
&fakeTargetProvider{values: []*contextargs.MetaInput{{Input: "slow-target"}}}, true)
|
||||
|
||||
if got := executer.completed.Load(); got != templateCount {
|
||||
t.Fatalf("completed %d templates, expected %d", got, templateCount)
|
||||
}
|
||||
if got := executer.maxInFlight.Load(); got > sharedBudget {
|
||||
t.Fatalf("maximum concurrency was %d, shared budget is %d", got, sharedBudget)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteAllSelfContainedHonorsSharedConcurrencyLimiter(t *testing.T) {
|
||||
const (
|
||||
templateCount = 8
|
||||
sharedBudget = 2
|
||||
)
|
||||
|
||||
tokens := make(chan struct{}, sharedBudget)
|
||||
executer := &adaptiveConcurrencyExecuter{delay: 20 * time.Millisecond}
|
||||
options := &types.Options{}
|
||||
options.SetTemplateThreadsLimiter(func(ctx context.Context) error {
|
||||
select {
|
||||
case tokens <- struct{}{}:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}, func() {
|
||||
<-tokens
|
||||
})
|
||||
engine := New(options)
|
||||
templatesList := make([]*templates.Template, 0, templateCount)
|
||||
for index := range templateCount {
|
||||
templatesList = append(templatesList, &templates.Template{ID: fmt.Sprintf("self-contained-%d", index), Executer: executer})
|
||||
}
|
||||
|
||||
var results atomic.Bool
|
||||
var waitGroup sync.WaitGroup
|
||||
engine.executeAllSelfContained(context.Background(), templatesList, &results, &waitGroup)
|
||||
waitGroup.Wait()
|
||||
|
||||
if got := executer.completed.Load(); got != templateCount {
|
||||
t.Fatalf("completed %d templates, expected %d", got, templateCount)
|
||||
}
|
||||
if got := executer.maxInFlight.Load(); got > sharedBudget {
|
||||
t.Fatalf("maximum concurrency was %d, shared budget is %d", got, sharedBudget)
|
||||
}
|
||||
}
|
||||
func (s *slowExecuter) ExecuteWithResults(ctx *scan.ScanContext) ([]*output.ResultEvent, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/output"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/progress"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/scan"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
|
||||
tmpltypes "github.com/projectdiscovery/nuclei/v3/pkg/templates/types"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types/scanstrategy"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// allowList selects the templates it lists.
|
||||
type allowList struct{ paths []string }
|
||||
|
||||
func (a *allowList) Allows(templatePath string) bool { return slices.Contains(a.paths, templatePath) }
|
||||
|
||||
// scopeByInput maps inputs to selections; unlisted inputs are unrestricted.
|
||||
type scopeByInput map[string]*allowList
|
||||
|
||||
func (s scopeByInput) For(input *contextargs.MetaInput) protocols.TemplateSelection {
|
||||
if selection, ok := s[input.Input]; ok {
|
||||
return selection
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// recordingExecuter records the inputs it runs on.
|
||||
type recordingExecuter struct {
|
||||
path string
|
||||
mu *sync.Mutex
|
||||
runs *[]string
|
||||
}
|
||||
|
||||
func (r *recordingExecuter) Compile() error { return nil }
|
||||
func (r *recordingExecuter) Requests() int { return 1 }
|
||||
func (r *recordingExecuter) Execute(ctx *scan.ScanContext) (bool, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
*r.runs = append(*r.runs, r.path+"@"+ctx.Input.MetaInput.Input)
|
||||
return false, nil
|
||||
}
|
||||
func (r *recordingExecuter) ExecuteWithResults(*scan.ScanContext) ([]*output.ResultEvent, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// progressRecorder captures the expected request count; the engine calls no
|
||||
// other progress method on this path.
|
||||
type progressRecorder struct {
|
||||
progress.Progress
|
||||
requests int64
|
||||
}
|
||||
|
||||
func (p *progressRecorder) Init(_ int64, _ int, requests int64) { p.requests = requests }
|
||||
|
||||
func TestExecuteScanRunsOnlyScopedPairs(t *testing.T) {
|
||||
for _, strategy := range []scanstrategy.ScanStrategy{scanstrategy.TemplateSpray, scanstrategy.HostSpray} {
|
||||
t.Run(strategy.String(), func(t *testing.T) {
|
||||
options := &types.Options{
|
||||
BulkSize: 2,
|
||||
TemplateThreads: 2,
|
||||
HeadlessBulkSize: 1,
|
||||
HeadlessTemplateThreads: 1,
|
||||
ScanStrategy: strategy.String(),
|
||||
}
|
||||
progress := &progressRecorder{}
|
||||
engine := New(options)
|
||||
engine.SetExecuterOptions(&protocols.ExecutorOptions{
|
||||
Logger: engine.Logger,
|
||||
Options: options,
|
||||
ResumeCfg: types.NewResumeCfg(),
|
||||
ProtocolType: tmpltypes.HTTPProtocol,
|
||||
Progress: progress,
|
||||
TargetScope: scopeByInput{
|
||||
"php": {paths: []string{"thinkphp.yaml"}},
|
||||
"tomcat": {paths: []string{"tomcat.yaml", "generic.yaml"}},
|
||||
"none": {},
|
||||
},
|
||||
})
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
runs []string
|
||||
)
|
||||
var templatesList []*templates.Template
|
||||
for _, path := range []string{"thinkphp.yaml", "tomcat.yaml", "generic.yaml"} {
|
||||
templatesList = append(templatesList, &templates.Template{
|
||||
ID: path,
|
||||
Path: path,
|
||||
TotalRequests: 1,
|
||||
Executer: &recordingExecuter{path: path, mu: &mu, runs: &runs},
|
||||
})
|
||||
}
|
||||
targets := &fakeTargetProvider{values: []*contextargs.MetaInput{
|
||||
{Input: "php"}, {Input: "tomcat"}, {Input: "none"}, {Input: "plain"},
|
||||
}}
|
||||
|
||||
engine.ExecuteScanWithOpts(context.Background(), templatesList, targets, true)
|
||||
|
||||
require.ElementsMatch(t, []string{
|
||||
"thinkphp.yaml@php",
|
||||
"tomcat.yaml@tomcat", "generic.yaml@tomcat",
|
||||
"thinkphp.yaml@plain", "tomcat.yaml@plain", "generic.yaml@plain",
|
||||
}, runs)
|
||||
require.EqualValues(t, len(runs), progress.requests, "progress counts only scoped pairs")
|
||||
})
|
||||
}
|
||||
}
|
||||
+18
-14
@@ -2,13 +2,12 @@ package customtemplates
|
||||
|
||||
import (
|
||||
"context"
|
||||
httpclient "net/http"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing/transport/http"
|
||||
"github.com/google/go-github/v30/github"
|
||||
"github.com/google/go-github/v92/github"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/projectdiscovery/gologger"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
|
||||
@@ -89,7 +88,10 @@ func (customTemplate *customTemplateGitHubRepo) logPullError(err error) {
|
||||
// NewGitHubProviders returns new instance of GitHub providers for downloading custom templates
|
||||
func NewGitHubProviders(options *types.Options) ([]*customTemplateGitHubRepo, error) {
|
||||
providers := []*customTemplateGitHubRepo{}
|
||||
gitHubClient := getGHClientIncognito()
|
||||
gitHubClient, err := github.NewClient()
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "could not create github client")
|
||||
}
|
||||
|
||||
if options.GitHubTemplateDisableDownload {
|
||||
return providers, nil
|
||||
@@ -150,9 +152,16 @@ getRepo:
|
||||
repo, _, err := gitHubClient.Repositories.Get(context.Background(), repoOwner, repoName)
|
||||
if err != nil {
|
||||
// retry with authentication
|
||||
if gitHubClient = getGHClientWithToken(githubToken); gitHubClient != nil && !retried {
|
||||
retried = true
|
||||
goto getRepo
|
||||
if !retried {
|
||||
authClient, authErr := getGHClientWithToken(githubToken)
|
||||
if authErr != nil {
|
||||
return nil, errors.Wrap(authErr, "could not create authenticated github client")
|
||||
}
|
||||
if authClient != nil {
|
||||
gitHubClient = authClient
|
||||
retried = true
|
||||
goto getRepo
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
@@ -233,20 +242,15 @@ func getAuth(username, password string) *http.BasicAuth {
|
||||
return nil
|
||||
}
|
||||
|
||||
func getGHClientWithToken(token string) *github.Client {
|
||||
func getGHClientWithToken(token string) (*github.Client, error) {
|
||||
if token != "" {
|
||||
ctx := context.Background()
|
||||
ts := oauth2.StaticTokenSource(
|
||||
&oauth2.Token{AccessToken: token},
|
||||
)
|
||||
oauthClient := oauth2.NewClient(ctx, ts)
|
||||
return github.NewClient(oauthClient)
|
||||
return github.NewClient(github.WithHTTPClient(oauthClient))
|
||||
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func getGHClientIncognito() *github.Client {
|
||||
var tc *httpclient.Client
|
||||
return github.NewClient(tc)
|
||||
return nil, nil
|
||||
}
|
||||
@@ -55,18 +55,28 @@ func (b *Body) Parse(req *retryablehttp.Request) (bool, error) {
|
||||
|
||||
b.value = NewValue(dataStr)
|
||||
tmp := b.value.Parsed()
|
||||
if !tmp.IsNIL() {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.Contains(contentType, "application/json") && tmp.IsNIL():
|
||||
case strings.Contains(contentType, "application/json"):
|
||||
// GraphQL-over-HTTP uses application/json; prefer the GraphQL decoder
|
||||
// so variables / inline args become fuzz points instead of raw JSON.
|
||||
if graphql := dataformat.Get(dataformat.GraphqlDataFormat); graphql != nil && graphql.IsType(dataStr) {
|
||||
return b.parseBody(dataformat.GraphqlDataFormat, req)
|
||||
}
|
||||
if !tmp.IsNIL() {
|
||||
return true, nil
|
||||
}
|
||||
return b.parseBody(dataformat.JSONDataFormat, req)
|
||||
case strings.Contains(contentType, "application/xml") && tmp.IsNIL():
|
||||
return b.parseBody(dataformat.XMLDataFormat, req)
|
||||
case strings.Contains(contentType, "multipart/form-data") && tmp.IsNIL():
|
||||
return b.parseBody(dataformat.MultiPartFormDataFormat, req)
|
||||
}
|
||||
|
||||
if !tmp.IsNIL() {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
parsed, err := b.parseBody(dataformat.FormDataFormat, req)
|
||||
if err != nil {
|
||||
gologger.Warning().Msgf("Could not parse body as form data: %s\n", err)
|
||||
|
||||
@@ -244,3 +244,109 @@ func TestMultiPartFormConcurrentParse(t *testing.T) {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBodyGraphqlVariablesComponent(t *testing.T) {
|
||||
raw := `{
|
||||
"query": "mutation ImportPaste($host: String!, $path: String!) { importPaste(host: $host, path: $path) { result } }",
|
||||
"variables": {
|
||||
"host": "example.com",
|
||||
"path": "/robots.txt"
|
||||
}
|
||||
}`
|
||||
req, err := retryablehttp.NewRequest("POST", "https://example.com/graphql", strings.NewReader(raw))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
body := New(RequestBodyComponent)
|
||||
parsed, err := body.Parse(req)
|
||||
require.NoError(t, err)
|
||||
require.True(t, parsed)
|
||||
|
||||
keys := map[string]struct{}{}
|
||||
_ = body.Iterate(func(key string, value interface{}) error {
|
||||
keys[key] = struct{}{}
|
||||
return nil
|
||||
})
|
||||
require.Contains(t, keys, "host")
|
||||
require.Contains(t, keys, "path")
|
||||
require.NotContains(t, keys, "query")
|
||||
|
||||
require.NoError(t, body.SetValue("path", "/robots.txt; id"))
|
||||
rebuilt, err := body.Rebuild()
|
||||
require.NoError(t, err)
|
||||
|
||||
newBody, err := io.ReadAll(rebuilt.Body)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(newBody), `/robots.txt; id`)
|
||||
require.Contains(t, string(newBody), `"host":"example.com"`)
|
||||
require.Contains(t, string(newBody), "mutation ImportPaste")
|
||||
}
|
||||
|
||||
func TestBodyGraphqlInlineArgsComponent(t *testing.T) {
|
||||
raw := `{"query":"query { jobs(jobType: \"front-end\") { id } }"}`
|
||||
req, err := retryablehttp.NewRequest("POST", "https://example.com/graphql", strings.NewReader(raw))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
body := New(RequestBodyComponent)
|
||||
parsed, err := body.Parse(req)
|
||||
require.NoError(t, err)
|
||||
require.True(t, parsed)
|
||||
|
||||
require.NoError(t, body.SetValue("jobType", "canary"))
|
||||
rebuilt, err := body.Rebuild()
|
||||
require.NoError(t, err)
|
||||
|
||||
newBody, err := io.ReadAll(rebuilt.Body)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(newBody), "canary")
|
||||
require.NotContains(t, string(newBody), "front-end")
|
||||
}
|
||||
|
||||
func TestBodyGraphqlNestedVariablesComponent(t *testing.T) {
|
||||
raw := `{
|
||||
"query": "mutation ($input: ImportInput!) { importPaste(input: $input) { result } }",
|
||||
"variables": {
|
||||
"input": {
|
||||
"host": "example.com",
|
||||
"path": "/robots.txt"
|
||||
}
|
||||
}
|
||||
}`
|
||||
req, err := retryablehttp.NewRequest("POST", "https://example.com/graphql", strings.NewReader(raw))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
body := New(RequestBodyComponent)
|
||||
parsed, err := body.Parse(req)
|
||||
require.NoError(t, err)
|
||||
require.True(t, parsed)
|
||||
|
||||
require.NoError(t, body.SetValue("input~path", "/etc/passwd"))
|
||||
rebuilt, err := body.Rebuild()
|
||||
require.NoError(t, err)
|
||||
|
||||
newBody, err := io.ReadAll(rebuilt.Body)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(newBody), `/etc/passwd`)
|
||||
require.Contains(t, string(newBody), `"host":"example.com"`)
|
||||
}
|
||||
|
||||
func TestBodyRegularJSONNotForcedToGraphql(t *testing.T) {
|
||||
req, err := retryablehttp.NewRequest("POST", "https://example.com", strings.NewReader(`{"foo":"bar"}`))
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
body := New(RequestBodyComponent)
|
||||
parsed, err := body.Parse(req)
|
||||
require.NoError(t, err)
|
||||
require.True(t, parsed)
|
||||
|
||||
require.NoError(t, body.SetValue("foo", "baz"))
|
||||
rebuilt, err := body.Rebuild()
|
||||
require.NoError(t, err)
|
||||
newBody, err := io.ReadAll(rebuilt.Body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, `{"foo":"baz"}`, string(newBody))
|
||||
}
|
||||
|
||||
@@ -3,9 +3,10 @@ package component
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/fuzz/dataformat"
|
||||
httputil "github.com/projectdiscovery/nuclei/v3/pkg/protocols/utils/http"
|
||||
"github.com/projectdiscovery/retryablehttp-go"
|
||||
mapsutil "github.com/projectdiscovery/utils/maps"
|
||||
)
|
||||
@@ -85,14 +86,17 @@ func (c *Cookie) Rebuild() (*retryablehttp.Request, error) {
|
||||
cloned := c.req.Clone(context.Background())
|
||||
|
||||
cloned.Header.Del("Cookie")
|
||||
var fields []string
|
||||
c.value.parsed.Iterate(func(key string, value any) bool {
|
||||
cookie := &http.Cookie{
|
||||
Name: key,
|
||||
Value: fmt.Sprint(value), // Assume the value is always a string for cookies
|
||||
}
|
||||
cloned.AddCookie(cookie)
|
||||
fields = append(fields, key+"="+fmt.Sprint(value)) // Assume the value is always a string for cookies
|
||||
return true
|
||||
})
|
||||
// Cookie.String sanitizes bytes that can be intentional fuzzing payloads.
|
||||
// Header validation still applies when the HTTP transport sends the request.
|
||||
if len(fields) > 0 {
|
||||
cloned.Header.Set("Cookie", strings.Join(fields, "; "))
|
||||
}
|
||||
httputil.SetCookieFields(cloned.Request, fields)
|
||||
return cloned, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,55 @@ package component
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/retryablehttp-go"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCookieComponentPreservesRawPayload(t *testing.T) {
|
||||
for _, payload := range []string{"old'", `old"`, `old";probe=value`} {
|
||||
t.Run(payload, func(t *testing.T) {
|
||||
req, err := retryablehttp.NewRequest(http.MethodGet, "https://example.com", nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Cookie", "session=old; preference=dark")
|
||||
cookies := NewCookie()
|
||||
parsed, err := cookies.Parse(req)
|
||||
require.NoError(t, err)
|
||||
require.True(t, parsed)
|
||||
require.NoError(t, cookies.SetValue("session", payload))
|
||||
rebuilt, err := cookies.Rebuild()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "session="+payload+"; preference=dark", rebuilt.Header.Get("Cookie"))
|
||||
require.Equal(t, "session=old; preference=dark", req.Header.Get("Cookie"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCookieComponentRejectsHeaderNewlines(t *testing.T) {
|
||||
var requests atomic.Int32
|
||||
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { requests.Add(1) }))
|
||||
defer server.Close()
|
||||
req, err := retryablehttp.NewRequest(http.MethodGet, server.URL, nil)
|
||||
require.NoError(t, err)
|
||||
req.Header.Set("Cookie", "session=old")
|
||||
cookies := NewCookie()
|
||||
parsed, err := cookies.Parse(req)
|
||||
require.NoError(t, err)
|
||||
require.True(t, parsed)
|
||||
require.NoError(t, cookies.SetValue("session", "old\r\nX-Injected: yes"))
|
||||
rebuilt, err := cookies.Rebuild()
|
||||
require.NoError(t, err)
|
||||
resp, err := server.Client().Do(rebuilt.Request)
|
||||
if resp != nil {
|
||||
require.NoError(t, resp.Body.Close())
|
||||
}
|
||||
require.ErrorContains(t, err, "invalid header field value")
|
||||
require.Equal(t, int32(0), requests.Load())
|
||||
}
|
||||
|
||||
func TestCookieComponent(t *testing.T) {
|
||||
req, err := retryablehttp.NewRequest(http.MethodGet, "https://example.com", nil)
|
||||
if err != nil {
|
||||
@@ -55,3 +98,21 @@ func TestCookieComponent(t *testing.T) {
|
||||
newCookie, _ := rebuilt.Cookie("session")
|
||||
require.Equal(t, "new-session", newCookie.Value, "unexpected cookie value")
|
||||
}
|
||||
|
||||
func BenchmarkCookieComponentRebuild(b *testing.B) {
|
||||
req, err := retryablehttp.NewRequest(http.MethodGet, "https://example.com", nil)
|
||||
require.NoError(b, err)
|
||||
req.Header.Set("Cookie", "session=old; preference=dark")
|
||||
cookies := NewCookie()
|
||||
parsed, err := cookies.Parse(req)
|
||||
require.NoError(b, err)
|
||||
require.True(b, parsed)
|
||||
require.NoError(b, cookies.SetValue("session", "old'"))
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if _, err := cookies.Rebuild(); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ func init() {
|
||||
dataformats = make(map[string]DataFormat)
|
||||
|
||||
// register the default data formats
|
||||
RegisterDataFormat(NewGraphql())
|
||||
RegisterDataFormat(NewJSON())
|
||||
RegisterDataFormat(NewXML())
|
||||
RegisterDataFormat(NewRaw())
|
||||
@@ -26,6 +27,8 @@ func init() {
|
||||
}
|
||||
|
||||
const (
|
||||
// GraphqlDataFormat is the name of the GraphQL-over-HTTP JSON data format
|
||||
GraphqlDataFormat = "graphql"
|
||||
// JSONDataFormat is the name of the JSON data format
|
||||
JSONDataFormat = "json"
|
||||
// XMLDataFormat is the name of the XML data format
|
||||
@@ -38,6 +41,17 @@ const (
|
||||
MultiPartFormDataFormat = "multipart/form-data"
|
||||
)
|
||||
|
||||
// decodePriority prefers more specific formats before generic ones (GraphQL
|
||||
// bodies are valid JSON, so GraphQL must win over JSON).
|
||||
var decodePriority = []string{
|
||||
GraphqlDataFormat,
|
||||
JSONDataFormat,
|
||||
XMLDataFormat,
|
||||
MultiPartFormDataFormat,
|
||||
FormDataFormat,
|
||||
RawDataFormat,
|
||||
}
|
||||
|
||||
// Get returns the dataformat by name
|
||||
func Get(name string) DataFormat {
|
||||
return dataformats[name]
|
||||
@@ -70,22 +84,53 @@ type Decoded struct {
|
||||
|
||||
// Decode decodes the data from a format
|
||||
func Decode(data string) (*Decoded, error) {
|
||||
for _, dataformat := range dataformats {
|
||||
if dataformat.IsType(data) {
|
||||
decoded, err := dataformat.Decode(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
value := &Decoded{
|
||||
DataFormat: dataformat.Name(),
|
||||
Data: decoded,
|
||||
}
|
||||
return value, nil
|
||||
tryDecode := func(dataformat DataFormat) (*Decoded, error) {
|
||||
if dataformat == nil || !dataformat.IsType(data) {
|
||||
return nil, nil
|
||||
}
|
||||
decoded, err := dataformat.Decode(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Decoded{
|
||||
DataFormat: dataformat.Name(),
|
||||
Data: decoded,
|
||||
}, nil
|
||||
}
|
||||
|
||||
for _, name := range decodePriority {
|
||||
decoded, err := tryDecode(dataformats[name])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if decoded != nil {
|
||||
return decoded, nil
|
||||
}
|
||||
}
|
||||
for name, dataformat := range dataformats {
|
||||
if containsString(decodePriority, name) {
|
||||
continue
|
||||
}
|
||||
decoded, err := tryDecode(dataformat)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if decoded != nil {
|
||||
return decoded, nil
|
||||
}
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func containsString(items []string, want string) bool {
|
||||
for _, item := range items {
|
||||
if item == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Encode encodes the data into a format
|
||||
func Encode(data KV, dataformat string) (string, error) {
|
||||
if dataformat == "" {
|
||||
|
||||
@@ -0,0 +1,515 @@
|
||||
package dataformat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/graphql-go/graphql/language/ast"
|
||||
"github.com/graphql-go/graphql/language/kinds"
|
||||
"github.com/graphql-go/graphql/language/lexer"
|
||||
"github.com/graphql-go/graphql/language/parser"
|
||||
"github.com/graphql-go/graphql/language/printer"
|
||||
"github.com/graphql-go/graphql/language/source"
|
||||
"github.com/graphql-go/graphql/language/visitor"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
|
||||
)
|
||||
|
||||
const (
|
||||
graphqlMetaQuery = "#_query"
|
||||
graphqlMetaOperationName = "#_operationName"
|
||||
graphqlMetaHasVariables = "#_hasVariables"
|
||||
graphqlMetaExtensions = "#_extensions"
|
||||
)
|
||||
|
||||
// Graphql encodes and decodes GraphQL-over-HTTP JSON bodies
|
||||
// ({"query","variables","operationName"}) so DAST fuzzing can target
|
||||
// variables and inline field arguments instead of the raw query string.
|
||||
type Graphql struct{}
|
||||
|
||||
var _ DataFormat = &Graphql{}
|
||||
|
||||
// NewGraphql returns a new GraphQL data format encoder/decoder.
|
||||
func NewGraphql() *Graphql {
|
||||
return &Graphql{}
|
||||
}
|
||||
|
||||
// Name returns the name of the data format.
|
||||
func (g *Graphql) Name() string {
|
||||
return GraphqlDataFormat
|
||||
}
|
||||
|
||||
// IsType reports whether data is a GraphQL HTTP request body.
|
||||
func (g *Graphql) IsType(data string) bool {
|
||||
_, ok := parseGraphQLHTTPBody(data, true)
|
||||
return ok
|
||||
}
|
||||
|
||||
type graphQLHTTPBody struct {
|
||||
Query string `json:"query,omitempty"`
|
||||
OperationName string `json:"operationName,omitempty"`
|
||||
Variables *map[string]any `json:"variables,omitempty"`
|
||||
Extensions json.Message `json:"extensions,omitempty"`
|
||||
}
|
||||
|
||||
func parseGraphQLHTTPBody(data string, validateQuery bool) (graphQLHTTPBody, bool) {
|
||||
trimmed := strings.TrimSpace(data)
|
||||
if !strings.HasPrefix(trimmed, "{") || !strings.HasSuffix(trimmed, "}") {
|
||||
return graphQLHTTPBody{}, false
|
||||
}
|
||||
|
||||
var body graphQLHTTPBody
|
||||
if err := json.Unmarshal([]byte(trimmed), &body); err != nil {
|
||||
return graphQLHTTPBody{}, false
|
||||
}
|
||||
if strings.TrimSpace(body.Query) == "" {
|
||||
return graphQLHTTPBody{}, false
|
||||
}
|
||||
if !validateQuery {
|
||||
return body, true
|
||||
}
|
||||
if _, err := parseQueryAST(body.Query); err != nil {
|
||||
return graphQLHTTPBody{}, false
|
||||
}
|
||||
return body, true
|
||||
}
|
||||
|
||||
func parseQueryAST(query string) (*ast.Document, error) {
|
||||
doc, err := parser.Parse(parser.ParseParams{
|
||||
Source: &source.Source{Body: []byte(query)},
|
||||
})
|
||||
if err == nil {
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
// graphql-go v0.8.1 predates GraphQL null literal support. Replace null
|
||||
// name tokens with an enum sentinel for parsing, then restore the AST.
|
||||
rewritten, sentinel, replaced, rewriteErr := rewriteGraphQLNullTokens(query)
|
||||
if rewriteErr != nil || !replaced {
|
||||
return nil, err
|
||||
}
|
||||
doc, rewriteErr = parser.Parse(parser.ParseParams{
|
||||
Source: &source.Source{Body: []byte(rewritten)},
|
||||
})
|
||||
if rewriteErr != nil {
|
||||
return nil, err
|
||||
}
|
||||
visitor.Visit(doc, &visitor.VisitorOptions{
|
||||
Enter: func(params visitor.VisitFuncParams) (string, interface{}) {
|
||||
switch node := params.Node.(type) {
|
||||
case *ast.Name:
|
||||
if node.Value == sentinel {
|
||||
node.Value = "null"
|
||||
}
|
||||
case *ast.EnumValue:
|
||||
if node.Value == sentinel {
|
||||
node.Value = "null"
|
||||
}
|
||||
}
|
||||
return visitor.ActionNoChange, nil
|
||||
},
|
||||
}, nil)
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
func rewriteGraphQLNullTokens(query string) (string, string, bool, error) {
|
||||
sentinel := "__NUCLEI_GRAPHQL_NULL__"
|
||||
for strings.Contains(query, sentinel) {
|
||||
sentinel += "_"
|
||||
}
|
||||
|
||||
src := &source.Source{Body: []byte(query)}
|
||||
next := lexer.Lex(src)
|
||||
var out strings.Builder
|
||||
last := 0
|
||||
replaced := false
|
||||
for {
|
||||
token, err := next(0)
|
||||
if err != nil {
|
||||
return "", "", false, err
|
||||
}
|
||||
if token.Kind == lexer.EOF {
|
||||
break
|
||||
}
|
||||
if token.Kind != lexer.NAME || token.Value != "null" {
|
||||
continue
|
||||
}
|
||||
out.WriteString(query[last:token.Start])
|
||||
out.WriteString(sentinel)
|
||||
last = token.End
|
||||
replaced = true
|
||||
}
|
||||
if !replaced {
|
||||
return query, sentinel, false, nil
|
||||
}
|
||||
out.WriteString(query[last:])
|
||||
return out.String(), sentinel, true, nil
|
||||
}
|
||||
|
||||
// Decode extracts fuzzable GraphQL variables / inline arguments.
|
||||
func (g *Graphql) Decode(data string) (KV, error) {
|
||||
body, ok := parseGraphQLHTTPBody(data, true)
|
||||
if !ok {
|
||||
return KV{}, fmt.Errorf("not a graphql http body")
|
||||
}
|
||||
doc, err := parseQueryAST(body.Query)
|
||||
if err != nil {
|
||||
return KV{}, fmt.Errorf("could not parse graphql query: %w", err)
|
||||
}
|
||||
|
||||
kv := KVMap(map[string]any{})
|
||||
kv.Set(graphqlMetaQuery, body.Query)
|
||||
if body.OperationName != "" {
|
||||
kv.Set(graphqlMetaOperationName, body.OperationName)
|
||||
}
|
||||
if body.Extensions != nil {
|
||||
kv.Set(graphqlMetaExtensions, body.Extensions)
|
||||
}
|
||||
|
||||
hasVariables := body.Variables != nil
|
||||
if hasVariables {
|
||||
kv.Set(graphqlMetaHasVariables, true)
|
||||
for key, value := range *body.Variables {
|
||||
kv.Set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
// Inline arguments are only exposed when variables are absent; otherwise
|
||||
// variables are the stable fuzz surface (and rewrites stay in JSON).
|
||||
if !hasVariables {
|
||||
for key, value := range collectInlineArguments(doc) {
|
||||
if kv.Get(key) != nil {
|
||||
continue
|
||||
}
|
||||
kv.Set(key, value)
|
||||
}
|
||||
}
|
||||
return kv, nil
|
||||
}
|
||||
|
||||
// Encode rebuilds a GraphQL HTTP JSON body from fuzzed KV values.
|
||||
func (g *Graphql) Encode(data KV) (string, error) {
|
||||
queryVal := data.Get(graphqlMetaQuery)
|
||||
if queryVal == nil {
|
||||
return "", fmt.Errorf("graphql query metadata missing")
|
||||
}
|
||||
query := types.ToString(queryVal)
|
||||
|
||||
body := graphQLHTTPBody{Query: query}
|
||||
if op := data.Get(graphqlMetaOperationName); op != nil {
|
||||
body.OperationName = types.ToString(op)
|
||||
}
|
||||
if extensions, ok := data.Get(graphqlMetaExtensions).(json.Message); ok {
|
||||
body.Extensions = extensions
|
||||
}
|
||||
|
||||
hasVariables, _ := data.Get(graphqlMetaHasVariables).(bool)
|
||||
if hasVariables {
|
||||
variables := make(map[string]any)
|
||||
body.Variables = &variables
|
||||
data.Iterate(func(key string, value any) bool {
|
||||
if strings.HasPrefix(key, "#_") {
|
||||
return true
|
||||
}
|
||||
variables[key] = value
|
||||
return true
|
||||
})
|
||||
} else {
|
||||
doc, err := parseQueryAST(query)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not parse graphql query: %w", err)
|
||||
}
|
||||
data.Iterate(func(key string, value any) bool {
|
||||
if strings.HasPrefix(key, "#_") {
|
||||
return true
|
||||
}
|
||||
applyInlineArgument(doc, key, value)
|
||||
return true
|
||||
})
|
||||
body.Query = types.ToString(printer.Print(doc))
|
||||
}
|
||||
|
||||
encoded, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(encoded), nil
|
||||
}
|
||||
|
||||
func collectInlineArguments(doc *ast.Document) map[string]any {
|
||||
args := make(map[string]any)
|
||||
for _, ref := range inlineArgumentRefs(doc) {
|
||||
if containsVariable(ref.argument.Value) {
|
||||
continue
|
||||
}
|
||||
args[ref.key] = astValueToGo(ref.argument.Value)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func applyInlineArgument(doc *ast.Document, key string, value any) {
|
||||
for _, ref := range inlineArgumentRefs(doc) {
|
||||
if ref.key == key && !containsVariable(ref.argument.Value) {
|
||||
ref.argument.Value = goValueToAST(value, ref.argument.Value)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func containsVariable(value ast.Value) bool {
|
||||
switch typed := value.(type) {
|
||||
case *ast.Variable:
|
||||
return true
|
||||
case *ast.ListValue:
|
||||
for _, item := range typed.Values {
|
||||
if containsVariable(item) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
case *ast.ObjectValue:
|
||||
for _, field := range typed.Fields {
|
||||
if containsVariable(field.Value) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type inlineArgumentRef struct {
|
||||
key string
|
||||
path string
|
||||
argument *ast.Argument
|
||||
}
|
||||
|
||||
func inlineArgumentRefs(doc *ast.Document) []inlineArgumentRef {
|
||||
var refs []inlineArgumentRef
|
||||
walkFields(doc, func(path string, field *ast.Field) {
|
||||
for _, argument := range field.Arguments {
|
||||
if argument.Name == nil {
|
||||
continue
|
||||
}
|
||||
refs = append(refs, inlineArgumentRef{
|
||||
path: path,
|
||||
argument: argument,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
counts := make(map[string]int)
|
||||
for _, ref := range refs {
|
||||
counts[ref.argument.Name.Value]++
|
||||
}
|
||||
for index := range refs {
|
||||
name := refs[index].argument.Name.Value
|
||||
refs[index].key = name
|
||||
if counts[name] > 1 {
|
||||
refs[index].key = refs[index].path + "." + name
|
||||
}
|
||||
}
|
||||
|
||||
keyCounts := make(map[string]int)
|
||||
for _, ref := range refs {
|
||||
keyCounts[ref.key]++
|
||||
}
|
||||
keyIndexes := make(map[string]int)
|
||||
for index := range refs {
|
||||
key := refs[index].key
|
||||
keyIndexes[key]++
|
||||
if keyCounts[key] > 1 {
|
||||
refs[index].key = fmt.Sprintf("%s[%d]", key, keyIndexes[key])
|
||||
}
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
func walkFields(doc *ast.Document, fn func(string, *ast.Field)) {
|
||||
if doc == nil {
|
||||
return
|
||||
}
|
||||
for _, def := range doc.Definitions {
|
||||
switch typed := def.(type) {
|
||||
case *ast.OperationDefinition:
|
||||
prefix := ""
|
||||
if typed.Name != nil {
|
||||
prefix = typed.Name.Value
|
||||
}
|
||||
walkSelectionSet(typed.SelectionSet, prefix, fn)
|
||||
case *ast.FragmentDefinition:
|
||||
prefix := "fragment"
|
||||
if typed.Name != nil {
|
||||
prefix += "." + typed.Name.Value
|
||||
}
|
||||
walkSelectionSet(typed.SelectionSet, prefix, fn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func walkSelectionSet(set *ast.SelectionSet, parentPath string, fn func(string, *ast.Field)) {
|
||||
if set == nil {
|
||||
return
|
||||
}
|
||||
|
||||
fieldCounts := make(map[string]int)
|
||||
for _, selection := range set.Selections {
|
||||
if field, ok := selection.(*ast.Field); ok {
|
||||
fieldCounts[fieldResponseName(field)]++
|
||||
}
|
||||
}
|
||||
fieldIndexes := make(map[string]int)
|
||||
|
||||
for _, selection := range set.Selections {
|
||||
switch typed := selection.(type) {
|
||||
case *ast.Field:
|
||||
name := fieldResponseName(typed)
|
||||
fieldIndexes[name]++
|
||||
if fieldCounts[name] > 1 {
|
||||
name = fmt.Sprintf("%s[%d]", name, fieldIndexes[name])
|
||||
}
|
||||
path := name
|
||||
if parentPath != "" {
|
||||
path = parentPath + "." + name
|
||||
}
|
||||
fn(path, typed)
|
||||
walkSelectionSet(typed.SelectionSet, path, fn)
|
||||
case *ast.InlineFragment:
|
||||
walkSelectionSet(typed.SelectionSet, parentPath, fn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func fieldResponseName(field *ast.Field) string {
|
||||
if field.Alias != nil {
|
||||
return field.Alias.Value
|
||||
}
|
||||
if field.Name != nil {
|
||||
return field.Name.Value
|
||||
}
|
||||
return "field"
|
||||
}
|
||||
|
||||
func goValueToAST(value any, original ast.Value) ast.Value {
|
||||
if enum, ok := original.(*ast.EnumValue); ok && enum.Value == "null" && value == nil {
|
||||
return &ast.EnumValue{Kind: kinds.EnumValue, Value: "null"}
|
||||
}
|
||||
|
||||
switch original.(type) {
|
||||
case *ast.IntValue:
|
||||
return &ast.IntValue{Kind: kinds.IntValue, Value: types.ToString(value)}
|
||||
case *ast.FloatValue:
|
||||
return &ast.FloatValue{Kind: kinds.FloatValue, Value: types.ToString(value)}
|
||||
case *ast.EnumValue:
|
||||
return &ast.EnumValue{Kind: kinds.EnumValue, Value: types.ToString(value)}
|
||||
case *ast.StringValue:
|
||||
return &ast.StringValue{Kind: kinds.StringValue, Value: types.ToString(value)}
|
||||
}
|
||||
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return &ast.StringValue{Kind: kinds.StringValue, Value: v}
|
||||
case bool:
|
||||
return &ast.BooleanValue{Kind: kinds.BooleanValue, Value: v}
|
||||
case int:
|
||||
return &ast.IntValue{Kind: kinds.IntValue, Value: fmt.Sprintf("%d", v)}
|
||||
case int32:
|
||||
return &ast.IntValue{Kind: kinds.IntValue, Value: fmt.Sprintf("%d", v)}
|
||||
case int64:
|
||||
return &ast.IntValue{Kind: kinds.IntValue, Value: fmt.Sprintf("%d", v)}
|
||||
case float32:
|
||||
return &ast.FloatValue{Kind: kinds.FloatValue, Value: fmt.Sprintf("%v", v)}
|
||||
case float64:
|
||||
// JSON numbers decode as float64; keep integers looking like ints when possible.
|
||||
if v == float64(int64(v)) {
|
||||
return &ast.IntValue{Kind: kinds.IntValue, Value: fmt.Sprintf("%d", int64(v))}
|
||||
}
|
||||
return &ast.FloatValue{Kind: kinds.FloatValue, Value: fmt.Sprintf("%v", v)}
|
||||
case []any:
|
||||
originalList, _ := original.(*ast.ListValue)
|
||||
values := make([]ast.Value, 0, len(v))
|
||||
for index, item := range v {
|
||||
var originalItem ast.Value
|
||||
if originalList != nil && index < len(originalList.Values) {
|
||||
originalItem = originalList.Values[index]
|
||||
}
|
||||
values = append(values, goValueToAST(item, originalItem))
|
||||
}
|
||||
return &ast.ListValue{Kind: kinds.ListValue, Values: values}
|
||||
case map[string]any:
|
||||
originalObject, _ := original.(*ast.ObjectValue)
|
||||
fields := make([]*ast.ObjectField, 0, len(v))
|
||||
used := make(map[string]struct{}, len(v))
|
||||
if originalObject != nil {
|
||||
for _, field := range originalObject.Fields {
|
||||
if field.Name == nil {
|
||||
continue
|
||||
}
|
||||
name := field.Name.Value
|
||||
value, ok := v[name]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
fields = append(fields, &ast.ObjectField{
|
||||
Kind: kinds.ObjectField,
|
||||
Name: &ast.Name{Kind: kinds.Name, Value: name},
|
||||
Value: goValueToAST(value, field.Value),
|
||||
})
|
||||
used[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(v)-len(used))
|
||||
for name := range v {
|
||||
if _, ok := used[name]; !ok {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(names)
|
||||
|
||||
for _, name := range names {
|
||||
fields = append(fields, &ast.ObjectField{
|
||||
Kind: kinds.ObjectField,
|
||||
Name: &ast.Name{Kind: kinds.Name, Value: name},
|
||||
Value: goValueToAST(v[name], nil),
|
||||
})
|
||||
}
|
||||
return &ast.ObjectValue{Kind: kinds.ObjectValue, Fields: fields}
|
||||
default:
|
||||
return &ast.StringValue{Kind: kinds.StringValue, Value: types.ToString(v)}
|
||||
}
|
||||
}
|
||||
|
||||
func astValueToGo(value ast.Value) any {
|
||||
switch v := value.(type) {
|
||||
case *ast.StringValue:
|
||||
return v.Value
|
||||
case *ast.IntValue:
|
||||
return v.Value
|
||||
case *ast.FloatValue:
|
||||
return v.Value
|
||||
case *ast.BooleanValue:
|
||||
return v.Value
|
||||
case *ast.EnumValue:
|
||||
if v.Value == "null" {
|
||||
return nil
|
||||
}
|
||||
return v.Value
|
||||
case *ast.ListValue:
|
||||
out := make([]any, 0, len(v.Values))
|
||||
for _, item := range v.Values {
|
||||
out = append(out, astValueToGo(item))
|
||||
}
|
||||
return out
|
||||
case *ast.ObjectValue:
|
||||
out := make(map[string]any, len(v.Fields))
|
||||
for _, field := range v.Fields {
|
||||
if field.Name == nil {
|
||||
continue
|
||||
}
|
||||
out[field.Name.Value] = astValueToGo(field.Value)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package dataformat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
graphqlInlineArgsBody = `{
|
||||
"query": "query { jobs(jobType: \"front-end\") { id name } }"
|
||||
}`
|
||||
|
||||
graphqlVariablesBody = `{
|
||||
"query": "mutation ImportPaste($host: String!, $port: Int!, $path: String!, $scheme: String!) { importPaste(host: $host, port: $port, path: $path, scheme: $scheme) { result } }",
|
||||
"variables": {
|
||||
"host": "example.com",
|
||||
"port": 80,
|
||||
"path": "/robots.txt",
|
||||
"scheme": "http"
|
||||
}
|
||||
}`
|
||||
|
||||
graphqlNestedVariablesBody = `{
|
||||
"query": "mutation ($input: ImportInput!) { importPaste(input: $input) { result } }",
|
||||
"operationName": "Import",
|
||||
"variables": {
|
||||
"input": {
|
||||
"host": "example.com",
|
||||
"path": "/robots.txt"
|
||||
}
|
||||
}
|
||||
}`
|
||||
)
|
||||
|
||||
func fuzzableKV(t *testing.T, decoded KV) map[string]any {
|
||||
t.Helper()
|
||||
out := make(map[string]any)
|
||||
decoded.Iterate(func(key string, value any) bool {
|
||||
if strings.HasPrefix(key, "#_") {
|
||||
return true
|
||||
}
|
||||
out[key] = value
|
||||
return true
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func TestGraphqlIsType(t *testing.T) {
|
||||
g := NewGraphql()
|
||||
|
||||
require.True(t, g.IsType(graphqlInlineArgsBody))
|
||||
require.True(t, g.IsType(graphqlVariablesBody))
|
||||
require.False(t, g.IsType("not-json"))
|
||||
require.False(t, g.IsType(`{"foo":"bar"}`))
|
||||
require.False(t, g.IsType(`{"query":"not a graphql query"}`))
|
||||
require.False(t, g.IsType(`{"variables":{"host":"example.com"}}`))
|
||||
}
|
||||
|
||||
func TestGraphqlDecodeEncodeVariables(t *testing.T) {
|
||||
g := NewGraphql()
|
||||
|
||||
decoded, err := g.Decode(graphqlVariablesBody)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, map[string]any{
|
||||
"host": "example.com",
|
||||
"port": float64(80),
|
||||
"path": "/robots.txt",
|
||||
"scheme": "http",
|
||||
}, fuzzableKV(t, decoded))
|
||||
|
||||
decoded.Set("path", "/robots.txt; cat /etc/passwd")
|
||||
encoded, err := g.Encode(decoded)
|
||||
require.NoError(t, err)
|
||||
|
||||
roundTrip, err := g.Decode(encoded)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "/robots.txt; cat /etc/passwd", fuzzableKV(t, roundTrip)["path"])
|
||||
require.Contains(t, encoded, `"variables"`)
|
||||
require.Contains(t, encoded, `/robots.txt; cat /etc/passwd`)
|
||||
// Injection stays in variables JSON, query text still references $path only.
|
||||
require.Contains(t, encoded, `path: $path`)
|
||||
require.NotContains(t, encoded, `path: "/robots.txt; cat /etc/passwd"`)
|
||||
}
|
||||
|
||||
func TestGraphqlDecodeEncodeEmptyVariables(t *testing.T) {
|
||||
g := NewGraphql()
|
||||
body := `{
|
||||
"query": "query Lookup($input: String) { lookup(input: $input) { id } }",
|
||||
"variables": {}
|
||||
}`
|
||||
|
||||
decoded, err := g.Decode(body)
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, fuzzableKV(t, decoded))
|
||||
require.Equal(t, true, decoded.Get(graphqlMetaHasVariables))
|
||||
|
||||
encoded, err := g.Encode(decoded)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, encoded, `"variables":{}`)
|
||||
require.Contains(t, encoded, `input: $input`)
|
||||
require.NotContains(t, encoded, `input: ""`)
|
||||
|
||||
roundTrip, err := g.Decode(encoded)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, true, roundTrip.Get(graphqlMetaHasVariables))
|
||||
require.Empty(t, fuzzableKV(t, roundTrip))
|
||||
}
|
||||
|
||||
func TestGraphqlDecodeEncodeInlineArgs(t *testing.T) {
|
||||
g := NewGraphql()
|
||||
|
||||
decoded, err := g.Decode(graphqlInlineArgsBody)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, map[string]any{"jobType": "front-end"}, fuzzableKV(t, decoded))
|
||||
|
||||
decoded.Set("jobType", "canary-payload")
|
||||
encoded, err := g.Encode(decoded)
|
||||
require.NoError(t, err)
|
||||
|
||||
roundTrip, err := g.Decode(encoded)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, map[string]any{"jobType": "canary-payload"}, fuzzableKV(t, roundTrip))
|
||||
require.Contains(t, encoded, "canary-payload")
|
||||
}
|
||||
|
||||
func TestGraphqlDecodeEncodeNullLiteral(t *testing.T) {
|
||||
g := NewGraphql()
|
||||
body := `{"query":"query { jobs(filter: null, nested: {value: null}) { id } }"}`
|
||||
|
||||
decoded, err := g.Decode(body)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, fuzzableKV(t, decoded), "filter")
|
||||
require.Nil(t, decoded.Get("filter"))
|
||||
|
||||
encoded, err := g.Encode(decoded)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, encoded, "filter: null")
|
||||
require.Contains(t, encoded, "nested: {value: null}")
|
||||
|
||||
roundTrip, err := g.Decode(encoded)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, fuzzableKV(t, roundTrip), "filter")
|
||||
require.Nil(t, roundTrip.Get("filter"))
|
||||
}
|
||||
|
||||
func TestGraphqlInlineArgsPreserveDefaultedVariable(t *testing.T) {
|
||||
g := NewGraphql()
|
||||
body := `{"query":"query Jobs($limit: Int = 10) { jobs(limit: $limit, status: ACTIVE) { id } }"}`
|
||||
|
||||
decoded, err := g.Decode(body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, map[string]any{"status": "ACTIVE"}, fuzzableKV(t, decoded))
|
||||
|
||||
decoded.Set("status", "PAUSED")
|
||||
encoded, err := g.Encode(decoded)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, encoded, "$limit: Int = 10")
|
||||
require.Contains(t, encoded, "limit: $limit")
|
||||
require.Contains(t, encoded, "status: PAUSED")
|
||||
|
||||
roundTrip, err := g.Decode(encoded)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, map[string]any{"status": "PAUSED"}, fuzzableKV(t, roundTrip))
|
||||
}
|
||||
|
||||
func TestGraphqlDecodeEncodePreservesExtensions(t *testing.T) {
|
||||
g := NewGraphql()
|
||||
body := `{"query":"query { viewer { id } }","extensions":{"persistedQuery":{"version":1,"sha256Hash":"abc123"}}}`
|
||||
|
||||
decoded, err := g.Decode(body)
|
||||
require.NoError(t, err)
|
||||
|
||||
encoded, err := g.Encode(decoded)
|
||||
require.NoError(t, err)
|
||||
|
||||
var roundTrip map[string]any
|
||||
require.NoError(t, json.Unmarshal([]byte(encoded), &roundTrip))
|
||||
require.Equal(t, map[string]any{
|
||||
"persistedQuery": map[string]any{
|
||||
"version": float64(1),
|
||||
"sha256Hash": "abc123",
|
||||
},
|
||||
}, roundTrip["extensions"])
|
||||
}
|
||||
|
||||
func TestGraphqlInlineArgumentKeysIdentifyLocations(t *testing.T) {
|
||||
g := NewGraphql()
|
||||
body := `{"query":"query { users(first: 10) { id } posts(first: 20) { id } }"}`
|
||||
|
||||
decoded, err := g.Decode(body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, map[string]any{
|
||||
"users.first": "10",
|
||||
"posts.first": "20",
|
||||
}, fuzzableKV(t, decoded))
|
||||
|
||||
decoded.Set("users.first", "99")
|
||||
encoded, err := g.Encode(decoded)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, encoded, "users(first: 99)")
|
||||
require.Contains(t, encoded, "posts(first: 20)")
|
||||
|
||||
roundTrip, err := g.Decode(encoded)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "99", roundTrip.Get("users.first"))
|
||||
require.Equal(t, "20", roundTrip.Get("posts.first"))
|
||||
}
|
||||
|
||||
func TestGraphqlInlineArgumentsPreserveLiteralTypes(t *testing.T) {
|
||||
g := NewGraphql()
|
||||
body := `{"query":"query { jobs(name: \"example\", status: ACTIVE, limit: 10, ratio: 1.5, flags: [ACTIVE, PAUSED], filter: {retries: 3, state: READY}) { id } }"}`
|
||||
|
||||
decoded, err := g.Decode(body)
|
||||
require.NoError(t, err)
|
||||
decoded.Set("name", "canary")
|
||||
|
||||
flags, ok := decoded.Get("flags").([]any)
|
||||
require.True(t, ok)
|
||||
flags[0] = "DISABLED"
|
||||
decoded.Set("flags", flags)
|
||||
|
||||
filter, ok := decoded.Get("filter").(map[string]any)
|
||||
require.True(t, ok)
|
||||
filter["retries"] = "4"
|
||||
decoded.Set("filter", filter)
|
||||
|
||||
encoded, err := g.Encode(decoded)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, encoded, `name: \"canary\"`)
|
||||
require.Contains(t, encoded, "status: ACTIVE")
|
||||
require.Contains(t, encoded, "limit: 10")
|
||||
require.Contains(t, encoded, "ratio: 1.5")
|
||||
require.Contains(t, encoded, "flags: [DISABLED, PAUSED]")
|
||||
require.Contains(t, encoded, "filter: {retries: 4, state: READY}")
|
||||
require.NotContains(t, encoded, `status: \"ACTIVE\"`)
|
||||
require.NotContains(t, encoded, `limit: \"10\"`)
|
||||
}
|
||||
|
||||
func TestGraphqlDecodeEncodeNestedVariables(t *testing.T) {
|
||||
g := NewGraphql()
|
||||
|
||||
decoded, err := g.Decode(graphqlNestedVariablesBody)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "Import", decoded.Get(graphqlMetaOperationName))
|
||||
|
||||
input, ok := fuzzableKV(t, decoded)["input"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "example.com", input["host"])
|
||||
require.Equal(t, "/robots.txt", input["path"])
|
||||
|
||||
input["path"] = "/robots.txt' OR 1=1--"
|
||||
decoded.Set("input", input)
|
||||
|
||||
encoded, err := g.Encode(decoded)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, encoded, `/robots.txt' OR 1=1--`)
|
||||
require.Contains(t, encoded, `"operationName":"Import"`)
|
||||
}
|
||||
|
||||
func TestGraphqlDoesNotTreatRegularJSONAsGraphql(t *testing.T) {
|
||||
decoded, err := Decode(`{"foo":"bar","query":123}`)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, decoded)
|
||||
require.Equal(t, JSONDataFormat, decoded.DataFormat)
|
||||
}
|
||||
|
||||
func TestGraphqlPreferredOverJSONInDecode(t *testing.T) {
|
||||
decoded, err := Decode(graphqlVariablesBody)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, decoded)
|
||||
require.Equal(t, GraphqlDataFormat, decoded.DataFormat)
|
||||
require.Equal(t, "example.com", decoded.Data.Get("host"))
|
||||
}
|
||||
|
||||
func TestGraphqlEncodeRoundTripPreservesQueryWhenFuzzingVariables(t *testing.T) {
|
||||
g := NewGraphql()
|
||||
decoded, err := g.Decode(graphqlVariablesBody)
|
||||
require.NoError(t, err)
|
||||
|
||||
originalQuery := decoded.Get(graphqlMetaQuery)
|
||||
decoded.Set("host", "evil.example")
|
||||
encoded, err := g.Encode(decoded)
|
||||
require.NoError(t, err)
|
||||
|
||||
again, err := g.Decode(encoded)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, originalQuery, again.Get(graphqlMetaQuery))
|
||||
require.Equal(t, "evil.example", again.Get("host"))
|
||||
}
|
||||
+9
-3
@@ -76,6 +76,8 @@ type GeneratedRequest struct {
|
||||
|
||||
// Key is the key for the request
|
||||
Key string
|
||||
// FuzzedKeys contains the keys under test, including multiple mode.
|
||||
FuzzedKeys []string
|
||||
// Value is the value for the request
|
||||
Value string
|
||||
// OriginalValue is the original value for the request
|
||||
@@ -309,8 +311,9 @@ func (rule *Rule) evaluateVars(input string) (string, error) {
|
||||
}
|
||||
|
||||
result, err := render.Render(render.Input{
|
||||
Text: input,
|
||||
Values: data,
|
||||
Options: rule.options.GetOptions(),
|
||||
Text: input,
|
||||
Values: data,
|
||||
})
|
||||
if err != nil {
|
||||
return input, err
|
||||
@@ -339,6 +342,7 @@ func (rule *Rule) evaluateVarsWithInteractsh(data map[string]interface{}, intera
|
||||
}
|
||||
|
||||
got, err := render.Render(render.Input{
|
||||
Options: rule.options.GetOptions(),
|
||||
Text: fmt.Sprint(v),
|
||||
Values: data,
|
||||
Interactsh: rule.options.Interactsh,
|
||||
@@ -412,6 +416,7 @@ func (rule *Rule) executeRuleValues(input *ExecuteRuleInput, ruleComponent compo
|
||||
|
||||
// if mode is multiple now build and execute it
|
||||
if rule.modeType == multipleModeType {
|
||||
var fuzzedKeys []string
|
||||
rule.Fuzz.KV.Iterate(func(key, value string) bool {
|
||||
var evaluated string
|
||||
var err error
|
||||
@@ -426,6 +431,7 @@ func (rule *Rule) executeRuleValues(input *ExecuteRuleInput, ruleComponent compo
|
||||
if err := ruleComponent.SetValue(key, evaluated); err != nil {
|
||||
return true
|
||||
}
|
||||
fuzzedKeys = append(fuzzedKeys, key)
|
||||
|
||||
return true
|
||||
})
|
||||
@@ -439,7 +445,7 @@ func (rule *Rule) executeRuleValues(input *ExecuteRuleInput, ruleComponent compo
|
||||
return err
|
||||
}
|
||||
|
||||
if gotErr := rule.execWithInput(input, req, input.InteractURLs, ruleComponent, "", "", "", "", "", ""); gotErr != nil {
|
||||
if gotErr := rule.execWithInput(input, req, input.InteractURLs, ruleComponent, "", "", "", "", "", "", fuzzedKeys...); gotErr != nil {
|
||||
return gotErr
|
||||
}
|
||||
}
|
||||
|
||||
+12
-3
@@ -50,6 +50,7 @@ func (rule *Rule) executePartComponent(input *ExecuteRuleInput, payload ValueOrK
|
||||
// this supports both single and multiple [ruleType] modes
|
||||
// i.e if component has multiple values, they can be replaced once or all depending on mode
|
||||
func (rule *Rule) executePartComponentOnValues(input *ExecuteRuleInput, payloadStr, originalPayload string, ruleComponent component.Component) error {
|
||||
var fuzzedKeys []string
|
||||
finalErr := ruleComponent.Iterate(func(key string, value interface{}) error {
|
||||
valueStr := types.ToString(value)
|
||||
if !rule.matchKeyOrValue(key, valueStr) {
|
||||
@@ -77,6 +78,9 @@ func (rule *Rule) executePartComponentOnValues(input *ExecuteRuleInput, payloadS
|
||||
// gologger.Warning().Msgf("could not set value due to format restriction original(%s, %s[%T]) , new(%s,%s[%T])", key, valueStr, value, key, evaluated, evaluated)
|
||||
return nil
|
||||
}
|
||||
if rule.modeType == multipleModeType {
|
||||
fuzzedKeys = append(fuzzedKeys, key)
|
||||
}
|
||||
|
||||
if rule.modeType == singleModeType {
|
||||
req, err := ruleComponent.Rebuild()
|
||||
@@ -109,7 +113,7 @@ func (rule *Rule) executePartComponentOnValues(input *ExecuteRuleInput, payloadS
|
||||
return err
|
||||
}
|
||||
|
||||
if qerr := rule.execWithInput(input, req, input.InteractURLs, ruleComponent, "", "", "", "", "", ""); qerr != nil {
|
||||
if qerr := rule.execWithInput(input, req, input.InteractURLs, ruleComponent, "", "", "", "", "", "", fuzzedKeys...); qerr != nil {
|
||||
err = qerr
|
||||
return err
|
||||
}
|
||||
@@ -149,7 +153,7 @@ func (rule *Rule) executePartComponentOnKV(input *ExecuteRuleInput, payload Valu
|
||||
return err
|
||||
}
|
||||
|
||||
if qerr := rule.execWithInput(input, req, input.InteractURLs, ruleComponent, key, value, "", "", "", ""); qerr != nil {
|
||||
if qerr := rule.execWithInput(input, req, input.InteractURLs, ruleComponent, key, value, "", "", "", "", key); qerr != nil {
|
||||
return qerr
|
||||
}
|
||||
|
||||
@@ -168,7 +172,7 @@ func (rule *Rule) executePartComponentOnKV(input *ExecuteRuleInput, payload Valu
|
||||
}
|
||||
|
||||
// execWithInput executes a rule with input via callback
|
||||
func (rule *Rule) execWithInput(input *ExecuteRuleInput, httpReq *retryablehttp.Request, interactURLs []string, component component.Component, parameter, parameterValue, originalPayload, originalValue, key, value string) error {
|
||||
func (rule *Rule) execWithInput(input *ExecuteRuleInput, httpReq *retryablehttp.Request, interactURLs []string, component component.Component, parameter, parameterValue, originalPayload, originalValue, key, value string, fuzzedKeys ...string) error {
|
||||
// If the parameter is a number, replace it with the parameter value
|
||||
// or if the parameter is empty and the parameter value is not empty
|
||||
// replace it with the parameter value
|
||||
@@ -188,6 +192,9 @@ func (rule *Rule) execWithInput(input *ExecuteRuleInput, httpReq *retryablehttp.
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if len(fuzzedKeys) == 0 && key != "" {
|
||||
fuzzedKeys = []string{key}
|
||||
}
|
||||
request := GeneratedRequest{
|
||||
Request: httpReq,
|
||||
InteractURLs: interactURLs,
|
||||
@@ -195,6 +202,7 @@ func (rule *Rule) execWithInput(input *ExecuteRuleInput, httpReq *retryablehttp.
|
||||
Component: component,
|
||||
Parameter: actualParameter,
|
||||
Key: key,
|
||||
FuzzedKeys: append([]string(nil), fuzzedKeys...),
|
||||
Value: value,
|
||||
OriginalValue: originalValue,
|
||||
OriginalPayload: originalPayload,
|
||||
@@ -214,6 +222,7 @@ func (rule *Rule) executeEvaluate(input *ExecuteRuleInput, _, value, payload str
|
||||
}, rule.options.Options.Vars.AsMap(), input.Values)
|
||||
|
||||
result, err := render.Render(render.Input{
|
||||
Options: rule.options.GetOptions(),
|
||||
Text: payload,
|
||||
Values: values,
|
||||
Interactsh: rule.options.Interactsh,
|
||||
|
||||
@@ -28,6 +28,32 @@ func NewInputProvider(opts InputOptions) (InputProvider, error)
|
||||
This function returns a InputProvider based by appropriately selecting input provider based on the input format (i.e. either list or http) and returns the provider that can handle that input format.
|
||||
|
||||
|
||||
## Per-target profiles
|
||||
|
||||
A list input line starting with `{` is a target line: a JSON object binding one target to its own template selection, so a single scan runs a different profile on each target. The keys mirror template profiles and CLI flags:
|
||||
|
||||
```jsonl
|
||||
{"target": "https://a.example", "tags": ["thinkphp"]}
|
||||
{"target": "https://b.example", "profile": "wordpress", "severity": ["critical", "high"]}
|
||||
{"target": "https://c.example", "tech": ["Apache Tomcat", "Java"]}
|
||||
{"target": "10.0.0.0/24", "type": "http", "exclude-tags": "dos"}
|
||||
https://d.example
|
||||
```
|
||||
|
||||
| Key | Selects |
|
||||
| --- | --- |
|
||||
| `target` | the target (URL, host, IP, CIDR or ASN), required |
|
||||
| `profile` | a template profile ID from the templates `profiles` directory (paths are rejected); the line's other keys override it |
|
||||
| `templates` | template paths, relative to the templates directory |
|
||||
| `tags`, `exclude-tags`, `include-tags`, `author`, `template-id`, `exclude-id`, `severity`, `exclude-severity`, `type`, `exclude-type` | same as the CLI flags |
|
||||
| `tech` | technologies the target runs: templates bound to another product (`metadata.product`) are skipped, every other template runs |
|
||||
|
||||
- Global flags apply first; a target line only narrows them. Plain targets run every template.
|
||||
- A target listed more than once runs the union of its selections, and is scanned once.
|
||||
- When every target has a selection, only the templates some target selects are loaded.
|
||||
- Unknown keys fail the run rather than widening the scan. Profile keys that configure the whole scan, such as `code` or `var`, are ignored per target.
|
||||
- Not supported with `-automatic-scan`. Global matchers still apply to every target.
|
||||
|
||||
## Formats
|
||||
|
||||
Fuzzing and DAST take the request shape (method, body, params) from the input, not from CLI flags, so a bare URL is always fuzzed as a `GET`. To fuzz anything else, feed a request-shaped input with `-im`:
|
||||
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/projectdiscovery/retryablehttp-go"
|
||||
)
|
||||
|
||||
func TestOpenAPIDownloader_SupportedExtensions(t *testing.T) {
|
||||
@@ -181,7 +183,9 @@ func TestOpenAPIDownloader_Download_InvalidJSON(t *testing.T) {
|
||||
func TestOpenAPIDownloader_Download_Timeout(t *testing.T) {
|
||||
// Create mock server with delay
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(35 * time.Second) // Longer than 30 second timeout
|
||||
// Outlasts the client timeout below. Waiting out the real 30s default
|
||||
// cost this test 35s, which was a seventh of the whole unit suite.
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"test": "data"}); err != nil {
|
||||
http.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
@@ -200,9 +204,17 @@ func TestOpenAPIDownloader_Download_Timeout(t *testing.T) {
|
||||
}()
|
||||
|
||||
downloader := &OpenAPIDownloader{}
|
||||
_, err = downloader.Download(server.URL+"/openapi.json", tmpDir, nil)
|
||||
client := retryablehttp.NewClient(retryablehttp.DefaultOptionsSingle)
|
||||
client.HTTPClient.Timeout = 100 * time.Millisecond
|
||||
_, err = downloader.Download(server.URL+"/openapi.json", tmpDir, client)
|
||||
if err == nil {
|
||||
t.Error("Expected timeout error, but got none")
|
||||
t.Fatal("Expected timeout error, but got none")
|
||||
}
|
||||
// Assert it timed out rather than merely erroring: the spec this server
|
||||
// returns is invalid, so a test that only checks for any error passes even
|
||||
// when the timeout never fires.
|
||||
if !os.IsTimeout(err) && !strings.Contains(err.Error(), "Timeout") {
|
||||
t.Fatalf("Expected a timeout error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/projectdiscovery/retryablehttp-go"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -268,7 +270,9 @@ func TestSwaggerDownloader_Download_InvalidYAML(t *testing.T) {
|
||||
func TestSwaggerDownloader_Download_Timeout(t *testing.T) {
|
||||
// Create mock server with delay
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(35 * time.Second) // Longer than 30 second timeout
|
||||
// Outlasts the client timeout below. Waiting out the real 30s default
|
||||
// cost this test 35s, which was a seventh of the whole unit suite.
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
if err := json.NewEncoder(w).Encode(map[string]interface{}{"test": "data"}); err != nil {
|
||||
http.Error(w, "failed to encode response", http.StatusInternalServerError)
|
||||
}
|
||||
@@ -287,9 +291,17 @@ func TestSwaggerDownloader_Download_Timeout(t *testing.T) {
|
||||
}()
|
||||
|
||||
downloader := &SwaggerDownloader{}
|
||||
_, err = downloader.Download(server.URL+"/swagger.json", tmpDir, nil)
|
||||
client := retryablehttp.NewClient(retryablehttp.DefaultOptionsSingle)
|
||||
client.HTTPClient.Timeout = 100 * time.Millisecond
|
||||
_, err = downloader.Download(server.URL+"/swagger.json", tmpDir, client)
|
||||
if err == nil {
|
||||
t.Error("Expected timeout error, but got none")
|
||||
t.Fatal("Expected timeout error, but got none")
|
||||
}
|
||||
// Assert it timed out rather than merely erroring: the spec this server
|
||||
// returns is invalid, so a test that only checks for any error passes even
|
||||
// when the timeout never fires.
|
||||
if !os.IsTimeout(err) && !strings.Contains(err.Error(), "Timeout") {
|
||||
t.Fatalf("Expected a timeout error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package normalize
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
urlutil "github.com/projectdiscovery/utils/url"
|
||||
)
|
||||
|
||||
// Placeholders a structurally variable path segment collapses to. They are
|
||||
// spelled out rather than hashed so a skipped-target log reads plainly.
|
||||
const (
|
||||
placeholderNum = "{num}"
|
||||
placeholderUUID = "{uuid}"
|
||||
placeholderHash = "{hash}"
|
||||
placeholderDate = "{date}"
|
||||
placeholderVar = "{var}"
|
||||
)
|
||||
|
||||
var (
|
||||
numericSegment = regexp.MustCompile(`^\d+$`)
|
||||
uuidSegment = regexp.MustCompile(`(?i)^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
|
||||
hashSegment = regexp.MustCompile(`(?i)^[0-9a-f]{16,}$`)
|
||||
// a date split across path positions is already caught as numeric segments
|
||||
dateSegment = regexp.MustCompile(`^\d{4}-\d{2}(-\d{2})?$`)
|
||||
)
|
||||
|
||||
// classifySegment replaces a path segment that identifies a particular record
|
||||
// with a placeholder, leaving segments that name a route alone.
|
||||
func classifySegment(segment string) string {
|
||||
switch {
|
||||
case segment == "":
|
||||
return segment
|
||||
case numericSegment.MatchString(segment):
|
||||
return placeholderNum
|
||||
case uuidSegment.MatchString(segment):
|
||||
return placeholderUUID
|
||||
case dateSegment.MatchString(segment):
|
||||
return placeholderDate
|
||||
case hashSegment.MatchString(segment):
|
||||
return placeholderHash
|
||||
}
|
||||
return segment
|
||||
}
|
||||
|
||||
// DefaultCollapseAfter is how many distinct values a single path position may
|
||||
// hold on one host before the position itself is treated as an identifier. It
|
||||
// catches slugs and names, which no pattern can recognise by shape alone.
|
||||
const DefaultCollapseAfter = 20
|
||||
|
||||
// Fingerprinter groups URLs by the shape of their path and query, so a crawl
|
||||
// that produced ten thousand product pages is recognised as one pattern.
|
||||
//
|
||||
// It is stateful by design: whether a path position is an identifier depends on
|
||||
// how many distinct values that position has taken on that host, which is only
|
||||
// knowable across the whole input.
|
||||
type Fingerprinter struct {
|
||||
// MaxPerPattern is how many targets to keep per pattern. Zero keeps every
|
||||
// target, which is the default: dropping one is a target never scanned.
|
||||
MaxPerPattern int
|
||||
// CollapseAfter is the distinct-value threshold for a path position.
|
||||
CollapseAfter int
|
||||
|
||||
mu sync.Mutex
|
||||
kept map[string]int
|
||||
position map[string]map[string]struct{}
|
||||
}
|
||||
|
||||
// NewFingerprinter returns a fingerprinter keeping at most maxPerPattern
|
||||
// targets for each pattern. A maxPerPattern of zero disables grouping.
|
||||
func NewFingerprinter(maxPerPattern int) *Fingerprinter {
|
||||
return &Fingerprinter{
|
||||
MaxPerPattern: maxPerPattern,
|
||||
CollapseAfter: DefaultCollapseAfter,
|
||||
kept: make(map[string]int),
|
||||
position: make(map[string]map[string]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled reports whether the fingerprinter drops anything.
|
||||
func (f *Fingerprinter) Enabled() bool {
|
||||
return f != nil && f.MaxPerPattern > 0
|
||||
}
|
||||
|
||||
// Accept reports whether a target should be scanned, recording it against its
|
||||
// pattern. A value that is not a URL is always accepted: it has no path to
|
||||
// group by, and guessing would lose targets.
|
||||
func (f *Fingerprinter) Accept(rawURL string) bool {
|
||||
if !f.Enabled() {
|
||||
return true
|
||||
}
|
||||
pattern, ok := f.pattern(rawURL)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.kept[pattern] >= f.MaxPerPattern {
|
||||
return false
|
||||
}
|
||||
f.kept[pattern]++
|
||||
return true
|
||||
}
|
||||
|
||||
// Pattern returns the structural fingerprint of a URL, for tests and logging.
|
||||
func (f *Fingerprinter) Pattern(rawURL string) string {
|
||||
pattern, _ := f.pattern(rawURL)
|
||||
return pattern
|
||||
}
|
||||
|
||||
func (f *Fingerprinter) pattern(rawURL string) (string, bool) {
|
||||
trimmed := strings.TrimSpace(rawURL)
|
||||
if idx := strings.Index(trimmed, "://"); idx > 0 {
|
||||
trimmed = strings.ToLower(trimmed[:idx]) + trimmed[idx:]
|
||||
}
|
||||
parsed, err := urlParse(trimmed)
|
||||
if err != nil || parsed == nil || parsed.Host == "" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
host := strings.ToLower(parsed.Host)
|
||||
segments := strings.Split(strings.Trim(parsed.Path, "/"), "/")
|
||||
shaped := make([]string, 0, len(segments))
|
||||
for index, segment := range segments {
|
||||
parent := strings.Join(segments[:index], "/")
|
||||
shaped = append(shaped, f.shapeSegment(host, parent, index, segment))
|
||||
}
|
||||
|
||||
var builder strings.Builder
|
||||
builder.WriteString(host)
|
||||
builder.WriteString("/")
|
||||
builder.WriteString(strings.Join(shaped, "/"))
|
||||
if keys := queryKeys(parsed); len(keys) > 0 {
|
||||
// names without values: two searches differ by what was searched for,
|
||||
// not by the shape of the request
|
||||
builder.WriteString("?")
|
||||
builder.WriteString(strings.Join(keys, "&"))
|
||||
}
|
||||
return builder.String(), true
|
||||
}
|
||||
|
||||
// shapeSegment classifies a segment by shape, then by how many distinct values
|
||||
// the same position has already taken under this parent path on this host.
|
||||
// The parent is part of the key so a busy /blog/<slug> does not also collapse
|
||||
// /admin/<name> just because both sit at the same index.
|
||||
func (f *Fingerprinter) shapeSegment(host, parent string, index int, segment string) string {
|
||||
if shaped := classifySegment(segment); shaped != segment {
|
||||
return shaped
|
||||
}
|
||||
if f.CollapseAfter <= 0 || segment == "" {
|
||||
return segment
|
||||
}
|
||||
|
||||
key := host + "\x00" + parent + "\x00" + itoa(index)
|
||||
f.mu.Lock()
|
||||
values, ok := f.position[key]
|
||||
if !ok {
|
||||
values = make(map[string]struct{})
|
||||
f.position[key] = values
|
||||
}
|
||||
if len(values) < f.CollapseAfter {
|
||||
values[segment] = struct{}{}
|
||||
}
|
||||
collapsed := len(values) >= f.CollapseAfter
|
||||
f.mu.Unlock()
|
||||
|
||||
if collapsed {
|
||||
return placeholderVar
|
||||
}
|
||||
return segment
|
||||
}
|
||||
|
||||
// urlParse and queryKeys keep the url library dependency in one place.
|
||||
func urlParse(raw string) (*urlutil.URL, error) {
|
||||
return urlutil.Parse(raw)
|
||||
}
|
||||
|
||||
// queryKeys returns the query parameter names, sorted, with values dropped.
|
||||
func queryKeys(parsed *urlutil.URL) []string {
|
||||
if parsed.Params == nil || parsed.Params.IsEmpty() {
|
||||
return nil
|
||||
}
|
||||
var keys []string
|
||||
parsed.Params.Iterate(func(key string, _ []string) bool {
|
||||
if !IsTrackingParam(key) {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func itoa(value int) string { return strconv.Itoa(value) }
|
||||
@@ -0,0 +1,101 @@
|
||||
package normalize
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPatternGroupsIdentifiers(t *testing.T) {
|
||||
f := NewFingerprinter(1)
|
||||
base := f.Pattern("http://acme.test/user/1/profile")
|
||||
for _, raw := range []string{
|
||||
"http://acme.test/user/2/profile",
|
||||
"http://acme.test/user/99999/profile",
|
||||
} {
|
||||
require.Equal(t, base, f.Pattern(raw), "numeric ids are the same shape")
|
||||
}
|
||||
require.Contains(t, base, placeholderNum)
|
||||
}
|
||||
|
||||
func TestPatternGroupsUUIDsHashesAndDates(t *testing.T) {
|
||||
f := NewFingerprinter(1)
|
||||
require.Contains(t, f.Pattern("http://acme.test/o/3f2504e0-4f89-11d3-9a0c-0305e82c3301"), placeholderUUID)
|
||||
require.Contains(t, f.Pattern("http://acme.test/a/d41d8cd98f00b204e9800998ecf8427e"), placeholderHash)
|
||||
require.Contains(t, f.Pattern("http://acme.test/posts/2024-05-01"), placeholderDate)
|
||||
// a date spread across positions is already grouped as numeric segments
|
||||
require.Equal(t, "acme.test/posts/{num}/{num}", f.Pattern("http://acme.test/posts/2024/05"))
|
||||
}
|
||||
|
||||
func TestPatternKeepsRouteNames(t *testing.T) {
|
||||
f := NewFingerprinter(1)
|
||||
require.NotEqual(t, f.Pattern("http://acme.test/admin"), f.Pattern("http://acme.test/login"),
|
||||
"route names are the structure, not identifiers within it")
|
||||
}
|
||||
|
||||
func TestPatternDropsQueryValuesButKeepsSortedNames(t *testing.T) {
|
||||
f := NewFingerprinter(1)
|
||||
a := f.Pattern("http://acme.test/search?q=admin&page=2")
|
||||
b := f.Pattern("http://acme.test/search?page=9&q=root")
|
||||
require.Equal(t, a, b, "same parameters in a different order with different values is one shape")
|
||||
require.Contains(t, a, "page&q")
|
||||
require.NotContains(t, a, "admin")
|
||||
|
||||
require.NotEqual(t, a, f.Pattern("http://acme.test/search?q=admin"), "a different parameter set is a different shape")
|
||||
}
|
||||
|
||||
func TestPatternCollapsesHighCardinalityPosition(t *testing.T) {
|
||||
f := NewFingerprinter(1)
|
||||
f.CollapseAfter = 5
|
||||
// slugs have no recognisable shape, so the position itself is what gives
|
||||
// them away once it has held enough distinct values
|
||||
for i := 0; i < 5; i++ {
|
||||
f.Pattern(fmt.Sprintf("http://acme.test/blog/%s-post", []string{"alpha", "beta", "gamma", "delta", "epsilon"}[i]))
|
||||
}
|
||||
require.Contains(t, f.Pattern("http://acme.test/blog/zeta-post"), placeholderVar)
|
||||
// a different host is tracked separately
|
||||
require.NotContains(t, f.Pattern("http://other.test/blog/zeta-post"), placeholderVar)
|
||||
}
|
||||
|
||||
func TestCollapseStaysInsideTheParentPath(t *testing.T) {
|
||||
f := NewFingerprinter(1)
|
||||
f.CollapseAfter = 3
|
||||
require.Equal(t, "acme.test/blog/one", f.Pattern("http://acme.test/blog/one"))
|
||||
require.Equal(t, "acme.test/blog/two", f.Pattern("http://acme.test/blog/two"))
|
||||
require.Equal(t, "acme.test/blog/{var}", f.Pattern("http://acme.test/blog/three"))
|
||||
|
||||
// same index, different parent: a busy /blog/<slug> must not collapse /admin/<name>
|
||||
require.Equal(t, "acme.test/admin/a", f.Pattern("http://acme.test/admin/a"))
|
||||
require.Equal(t, "acme.test/admin/b", f.Pattern("http://acme.test/admin/b"))
|
||||
require.True(t, f.Accept("http://acme.test/admin/a"))
|
||||
require.True(t, f.Accept("http://acme.test/admin/b"), "a different parent still has its own cap")
|
||||
}
|
||||
|
||||
func TestAcceptKeepsUpToTheCap(t *testing.T) {
|
||||
f := NewFingerprinter(2)
|
||||
require.True(t, f.Accept("http://acme.test/user/1"))
|
||||
require.True(t, f.Accept("http://acme.test/user/2"))
|
||||
require.False(t, f.Accept("http://acme.test/user/3"), "past the cap the pattern is already covered")
|
||||
require.True(t, f.Accept("http://acme.test/admin"), "a different pattern has its own budget")
|
||||
}
|
||||
|
||||
// Off by default: dropping a target means never scanning it, so it has to be
|
||||
// asked for.
|
||||
func TestDisabledFingerprinterKeepsEverything(t *testing.T) {
|
||||
f := NewFingerprinter(0)
|
||||
require.False(t, f.Enabled())
|
||||
for i := 0; i < 100; i++ {
|
||||
require.True(t, f.Accept(fmt.Sprintf("http://acme.test/user/%d", i)))
|
||||
}
|
||||
var nilF *Fingerprinter
|
||||
require.False(t, nilF.Enabled())
|
||||
require.True(t, nilF.Accept("http://acme.test/user/1"))
|
||||
}
|
||||
|
||||
func TestAcceptKeepsNonURLTargets(t *testing.T) {
|
||||
f := NewFingerprinter(1)
|
||||
require.True(t, f.Accept("acme.test"))
|
||||
require.True(t, f.Accept("not a url"))
|
||||
require.True(t, f.Accept("not a url"), "a value with no pattern is never grouped away")
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
// Package normalize collapses URLs that address the same resource onto one
|
||||
// form, so a target list built by a crawler does not run every template several
|
||||
// times over for what is effectively the same target.
|
||||
//
|
||||
// Only differences that cannot change the response are removed: host case, the
|
||||
// fragment (never sent to the server), a default port, a trailing slash, and
|
||||
// well known tracking parameters. Path case is left alone, because paths are
|
||||
// case sensitive on most servers.
|
||||
package normalize
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
urlutil "github.com/projectdiscovery/utils/url"
|
||||
)
|
||||
|
||||
// trackingParams are query keys added by ad and analytics platforms. They are
|
||||
// echoed back at most and never select a different resource, so two URLs that
|
||||
// differ only by these are the same target. The list is deliberately limited to
|
||||
// vendor prefixed names: a generic key like "ref" or "id" can be functional.
|
||||
var trackingParams = map[string]struct{}{
|
||||
"utm_source": {},
|
||||
"utm_medium": {},
|
||||
"utm_campaign": {},
|
||||
"utm_term": {},
|
||||
"utm_content": {},
|
||||
"utm_id": {},
|
||||
"utm_reader": {},
|
||||
"utm_name": {},
|
||||
"gclid": {},
|
||||
"gbraid": {},
|
||||
"wbraid": {},
|
||||
"dclid": {},
|
||||
"fbclid": {},
|
||||
"msclkid": {},
|
||||
"mc_cid": {},
|
||||
"mc_eid": {},
|
||||
"igshid": {},
|
||||
"twclid": {},
|
||||
"ttclid": {},
|
||||
"yclid": {},
|
||||
"_ga": {},
|
||||
"_gl": {},
|
||||
}
|
||||
|
||||
// IsTrackingParam reports whether a query key is a known tracking parameter.
|
||||
func IsTrackingParam(key string) bool {
|
||||
_, ok := trackingParams[strings.ToLower(key)]
|
||||
return ok
|
||||
}
|
||||
|
||||
// URL returns the canonical form of raw. A value that cannot be parsed, or that
|
||||
// carries no host, is returned unchanged: the input provider accepts things
|
||||
// that are not URLs at all, and guessing at those would lose targets.
|
||||
func URL(raw string) string {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return raw
|
||||
}
|
||||
|
||||
// The scheme is case insensitive per RFC 3986, but the parser rejects one
|
||||
// that is not lowercase, so fold it before handing the value over.
|
||||
if idx := strings.Index(trimmed, "://"); idx > 0 {
|
||||
trimmed = strings.ToLower(trimmed[:idx]) + trimmed[idx:]
|
||||
}
|
||||
|
||||
parsed, err := urlutil.Parse(trimmed)
|
||||
if err != nil || parsed == nil || parsed.Host == "" {
|
||||
return raw
|
||||
}
|
||||
|
||||
parsed.Scheme = strings.ToLower(parsed.Scheme)
|
||||
parsed.Host = strings.ToLower(parsed.Host)
|
||||
parsed.Fragment = ""
|
||||
|
||||
if host, port, ok := splitHostPort(parsed.Host); ok && isDefaultPort(parsed.Scheme, port) {
|
||||
parsed.Host = host
|
||||
}
|
||||
|
||||
stripTrackingParams(parsed)
|
||||
|
||||
// A trailing slash addresses the same resource as its absence for every
|
||||
// path but the root, where "/" is the path.
|
||||
if path := parsed.Path; len(path) > 1 && strings.HasSuffix(path, "/") {
|
||||
parsed.Path = strings.TrimRight(path, "/")
|
||||
}
|
||||
|
||||
return parsed.String()
|
||||
}
|
||||
|
||||
// stripTrackingParams removes known tracking keys, leaving every other
|
||||
// parameter and its value untouched.
|
||||
func stripTrackingParams(parsed *urlutil.URL) {
|
||||
if parsed.Params == nil || parsed.Params.IsEmpty() {
|
||||
return
|
||||
}
|
||||
// collected first: deleting while iterating would mutate the order under us
|
||||
var drop []string
|
||||
parsed.Params.Iterate(func(key string, _ []string) bool {
|
||||
if IsTrackingParam(key) {
|
||||
drop = append(drop, key)
|
||||
}
|
||||
return true
|
||||
})
|
||||
for _, key := range drop {
|
||||
parsed.Params.Del(key)
|
||||
}
|
||||
parsed.Update()
|
||||
}
|
||||
|
||||
// splitHostPort separates a host and port, tolerating bracketed IPv6 literals.
|
||||
func splitHostPort(host string) (string, string, bool) {
|
||||
if strings.HasPrefix(host, "[") {
|
||||
end := strings.LastIndex(host, "]")
|
||||
if end < 0 {
|
||||
return host, "", false
|
||||
}
|
||||
rest := host[end+1:]
|
||||
if !strings.HasPrefix(rest, ":") {
|
||||
return host, "", false
|
||||
}
|
||||
return host[:end+1], rest[1:], true
|
||||
}
|
||||
idx := strings.LastIndex(host, ":")
|
||||
if idx < 0 {
|
||||
return host, "", false
|
||||
}
|
||||
return host[:idx], host[idx+1:], true
|
||||
}
|
||||
|
||||
func isDefaultPort(scheme, port string) bool {
|
||||
return (scheme == "http" && port == "80") || (scheme == "https" && port == "443")
|
||||
}
|
||||
|
||||
// Origin returns the scheme and host a URL addresses, which is what a template
|
||||
// built only from {{RootURL}} resolves against. It returns "" when the value
|
||||
// carries no host, so callers can fall back to treating the target as unique.
|
||||
func Origin(raw string) string {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return ""
|
||||
}
|
||||
if idx := strings.Index(trimmed, "://"); idx > 0 {
|
||||
trimmed = strings.ToLower(trimmed[:idx]) + trimmed[idx:]
|
||||
}
|
||||
parsed, err := urlutil.Parse(trimmed)
|
||||
if err != nil || parsed == nil || parsed.Host == "" {
|
||||
return ""
|
||||
}
|
||||
scheme := strings.ToLower(parsed.Scheme)
|
||||
host := strings.ToLower(parsed.Host)
|
||||
if h, port, ok := splitHostPort(host); ok && isDefaultPort(scheme, port) {
|
||||
host = h
|
||||
}
|
||||
if scheme == "" {
|
||||
return host
|
||||
}
|
||||
return scheme + "://" + host
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package normalize
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestURLCollapsesEquivalentForms(t *testing.T) {
|
||||
same := "http://acme.test/page"
|
||||
for _, raw := range []string{
|
||||
"http://acme.test/page",
|
||||
"http://ACME.test/page",
|
||||
"HTTP://acme.test/page",
|
||||
"http://acme.test:80/page",
|
||||
"http://acme.test/page/",
|
||||
"http://acme.test/page#section",
|
||||
"http://acme.test/page?utm_source=news&utm_campaign=q4",
|
||||
} {
|
||||
require.Equal(t, same, URL(raw), "%q addresses the same resource", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestURLKeepsDifferencesThatMatter(t *testing.T) {
|
||||
// path case is significant on most servers, so it is left alone
|
||||
require.NotEqual(t, URL("http://acme.test/page"), URL("http://acme.test/PAGE"))
|
||||
// a non-default port is part of the target
|
||||
require.NotEqual(t, URL("http://acme.test/page"), URL("http://acme.test:8080/page"))
|
||||
// so is the scheme
|
||||
require.NotEqual(t, URL("http://acme.test/page"), URL("https://acme.test/page"))
|
||||
// and a functional query parameter
|
||||
require.NotEqual(t, URL("http://acme.test/page"), URL("http://acme.test/page?id=1"))
|
||||
}
|
||||
|
||||
func TestURLKeepsFunctionalParamsWhenStrippingTracking(t *testing.T) {
|
||||
got := URL("http://acme.test/search?q=admin&utm_source=news&page=2")
|
||||
require.Contains(t, got, "q=admin")
|
||||
require.Contains(t, got, "page=2")
|
||||
require.NotContains(t, got, "utm_source")
|
||||
}
|
||||
|
||||
func TestURLKeepsDefaultPortForOtherSchemes(t *testing.T) {
|
||||
// 443 is only the default for https, so on http it is a real port
|
||||
require.Contains(t, URL("http://acme.test:443/page"), ":443")
|
||||
require.NotContains(t, URL("https://acme.test:443/page"), ":443")
|
||||
}
|
||||
|
||||
func TestURLKeepsRootPath(t *testing.T) {
|
||||
// "/" is the path at the root, not a trailing slash to trim
|
||||
require.Equal(t, URL("http://acme.test/"), URL("http://acme.test/"))
|
||||
require.NotEmpty(t, URL("http://acme.test/"))
|
||||
}
|
||||
|
||||
func TestURLHandlesIPv6(t *testing.T) {
|
||||
require.Equal(t, URL("http://[::1]:8080/page"), URL("http://[::1]:8080/page/"))
|
||||
require.Contains(t, URL("http://[::1]:8080/page"), "[::1]:8080")
|
||||
// the default port still goes
|
||||
require.NotContains(t, URL("http://[::1]:80/page"), ":80")
|
||||
}
|
||||
|
||||
// The provider accepts bare hosts, ip:port and other non-URL values. Guessing
|
||||
// at those would lose targets, so they come back untouched.
|
||||
func TestURLLeavesUnparsableInputAlone(t *testing.T) {
|
||||
for _, raw := range []string{"", " ", "not a url", "acme.test", "10.0.0.1:8080"} {
|
||||
require.Equal(t, raw, URL(raw))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOriginIgnoresPathQueryAndDefaultPort(t *testing.T) {
|
||||
same := "https://example.com"
|
||||
for _, raw := range []string{
|
||||
"https://example.com/a",
|
||||
"https://EXAMPLE.com/b?q=1#frag",
|
||||
"https://example.com:443/c",
|
||||
} {
|
||||
require.Equal(t, same, Origin(raw), "%q is the same origin", raw)
|
||||
}
|
||||
require.NotEqual(t, Origin("https://example.com/a"), Origin("http://example.com/a"))
|
||||
require.NotEqual(t, Origin("https://example.com/a"), Origin("https://other.test/a"))
|
||||
require.NotEqual(t, Origin("https://example.com/a"), Origin("https://example.com:8443/a"))
|
||||
require.Equal(t, "example.com", Origin("example.com"))
|
||||
require.Empty(t, Origin(""))
|
||||
require.Empty(t, Origin("not a url"))
|
||||
}
|
||||
|
||||
func TestIsTrackingParam(t *testing.T) {
|
||||
require.True(t, IsTrackingParam("utm_source"))
|
||||
require.True(t, IsTrackingParam("UTM_SOURCE"))
|
||||
require.True(t, IsTrackingParam("fbclid"))
|
||||
require.False(t, IsTrackingParam("id"))
|
||||
require.False(t, IsTrackingParam("ref"), "a generic name can be functional")
|
||||
}
|
||||
+136
-45
@@ -20,6 +20,10 @@ import (
|
||||
"github.com/projectdiscovery/hmap/filekv"
|
||||
"github.com/projectdiscovery/hmap/store/hybrid"
|
||||
"github.com/projectdiscovery/mapcidr/asn"
|
||||
"github.com/projectdiscovery/nuclei/v3/internal/configuration"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/input/normalize"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/input/targetprofile"
|
||||
providerTypes "github.com/projectdiscovery/nuclei/v3/pkg/input/types"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
|
||||
@@ -39,15 +43,23 @@ const DefaultMaxDedupeItemsCount = 10000
|
||||
// ListInputProvider is a hmap/filekv backed nuclei ListInputProvider provider
|
||||
// it supports list type of input ex: urls,file,stdin,uncover,etc. (i.e just url not complete request/response)
|
||||
type ListInputProvider struct {
|
||||
ipOptions *ipOptions
|
||||
inputCount int64
|
||||
excludedCount int64
|
||||
dupeCount int64
|
||||
skippedCount int64
|
||||
hostMap *hybrid.HybridMap
|
||||
excludedHosts map[string]struct{}
|
||||
hostMapStream *filekv.FileDB
|
||||
hostMapStreamOnce sync.Once
|
||||
ipOptions *ipOptions
|
||||
inputCount int64
|
||||
excludedCount int64
|
||||
dupeCount int64
|
||||
skippedCount int64
|
||||
patternSkippedCount int64
|
||||
hostMap *hybrid.HybridMap
|
||||
excludedHosts map[string]struct{}
|
||||
hostMapStream *filekv.FileDB
|
||||
hostMapStreamOnce sync.Once
|
||||
profiles *targetprofile.Registry
|
||||
// normalizeURLs collapses targets that address the same resource onto one
|
||||
// form before they are deduplicated.
|
||||
normalizeURLs bool
|
||||
// patterns caps how many targets of one structural shape are kept. Nil or
|
||||
// disabled keeps every target.
|
||||
patterns *normalize.Fingerprinter
|
||||
sync.Once
|
||||
}
|
||||
|
||||
@@ -71,13 +83,16 @@ func New(opts *Options) (*ListInputProvider, error) {
|
||||
}
|
||||
|
||||
input := &ListInputProvider{
|
||||
hostMap: hm,
|
||||
hostMap: hm,
|
||||
normalizeURLs: !options.DisableURLNormalization,
|
||||
patterns: normalize.NewFingerprinter(options.MaxURLsPerPattern),
|
||||
ipOptions: &ipOptions{
|
||||
ScanAllIPs: options.ScanAllIPs,
|
||||
IPV4: sliceutil.Contains(options.IPVersion, "4"),
|
||||
IPV6: sliceutil.Contains(options.IPVersion, "6"),
|
||||
},
|
||||
excludedHosts: make(map[string]struct{}),
|
||||
profiles: targetprofile.NewRegistry(configuration.TemplatesDirectory(options, config.DefaultConfig)),
|
||||
}
|
||||
if options.Stream {
|
||||
fkvOptions := filekv.DefaultOptions
|
||||
@@ -105,6 +120,9 @@ func New(opts *Options) (*ListInputProvider, error) {
|
||||
if input.skippedCount > 0 {
|
||||
gologger.Info().Msgf("Number of hosts skipped from input due to exclusion: %d", input.skippedCount)
|
||||
}
|
||||
if input.patternSkippedCount > 0 {
|
||||
gologger.Info().Msgf("Supplied input was limited by max urls per pattern (%d removed).", input.patternSkippedCount)
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
@@ -122,9 +140,14 @@ func (i *ListInputProvider) Iterate(callback func(value *contextargs.MetaInput)
|
||||
}
|
||||
})
|
||||
}
|
||||
callbackFunc := func(k, _ []byte) error {
|
||||
callbackFunc := func(k, v []byte) error {
|
||||
metaInput := contextargs.NewMetaInput()
|
||||
if err := metaInput.Unmarshal(string(k)); err != nil {
|
||||
// v holds the target as supplied when the key is a collapsed form
|
||||
raw := string(k)
|
||||
if len(v) > 0 {
|
||||
raw = string(v)
|
||||
}
|
||||
if err := metaInput.Unmarshal(raw); err != nil {
|
||||
return err
|
||||
}
|
||||
if !callback(metaInput) {
|
||||
@@ -139,8 +162,18 @@ func (i *ListInputProvider) Iterate(callback func(value *contextargs.MetaInput)
|
||||
}
|
||||
}
|
||||
|
||||
// TargetProfiles returns the per-target template selections read from input.
|
||||
func (i *ListInputProvider) TargetProfiles() *targetprofile.Registry {
|
||||
return i.profiles
|
||||
}
|
||||
|
||||
// Set normalizes and stores passed input values
|
||||
func (i *ListInputProvider) Set(executionId string, value string) {
|
||||
i.set(executionId, value, nil)
|
||||
}
|
||||
|
||||
// set stores value bound to selection; a nil selection runs every template.
|
||||
func (i *ListInputProvider) set(executionId string, value string, selection *targetprofile.Selection) {
|
||||
URL := strings.TrimSpace(value)
|
||||
if URL == "" {
|
||||
return
|
||||
@@ -156,7 +189,7 @@ func (i *ListInputProvider) Set(executionId string, value string) {
|
||||
})
|
||||
metaInput := contextargs.NewMetaInput()
|
||||
metaInput.Input = URL
|
||||
i.setItem(metaInput)
|
||||
i.setItem(metaInput, selection)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -164,7 +197,7 @@ func (i *ListInputProvider) Set(executionId string, value string) {
|
||||
if iputil.IsIP(urlx.Hostname()) {
|
||||
metaInput := contextargs.NewMetaInput()
|
||||
metaInput.Input = URL
|
||||
i.setItem(metaInput)
|
||||
i.setItem(metaInput, selection)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -192,7 +225,7 @@ func (i *ListInputProvider) Set(executionId string, value string) {
|
||||
metaInput := contextargs.NewMetaInput()
|
||||
metaInput.Input = URL
|
||||
metaInput.CustomIP = ip
|
||||
i.setItem(metaInput)
|
||||
i.setItem(metaInput, selection)
|
||||
}
|
||||
return
|
||||
} else {
|
||||
@@ -230,10 +263,10 @@ func (i *ListInputProvider) Set(executionId string, value string) {
|
||||
if ip != "" {
|
||||
metaInput.Input = URL
|
||||
metaInput.CustomIP = ip
|
||||
i.setItem(metaInput)
|
||||
i.setItem(metaInput, selection)
|
||||
} else {
|
||||
metaInput.Input = URL
|
||||
i.setItem(metaInput)
|
||||
i.setItem(metaInput, selection)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -281,23 +314,18 @@ func (i *ListInputProvider) initializeInputSources(opts *Options) error {
|
||||
|
||||
// Handle targets flags
|
||||
for _, target := range options.Targets {
|
||||
switch {
|
||||
case iputil.IsCIDR(target):
|
||||
ips := expand.CIDR(target)
|
||||
i.addTargets(options.ExecutionId, ips)
|
||||
case asn.IsASN(target):
|
||||
ips := expand.ASN(target)
|
||||
i.addTargets(options.ExecutionId, ips)
|
||||
default:
|
||||
i.Set(options.ExecutionId, target)
|
||||
if err := i.addInput(options.ExecutionId, target); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Handle stdin
|
||||
if options.Stdin {
|
||||
i.scanInputFromReader(
|
||||
if err := i.scanInputFromReader(
|
||||
options.ExecutionId,
|
||||
readerutil.TimeoutReader{Reader: os.Stdin, Timeout: time.Duration(options.InputReadTimeout)})
|
||||
readerutil.TimeoutReader{Reader: os.Stdin, Timeout: time.Duration(options.InputReadTimeout)}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Handle target file
|
||||
@@ -310,8 +338,11 @@ func (i *ListInputProvider) initializeInputSources(opts *Options) error {
|
||||
}
|
||||
}
|
||||
if input != nil {
|
||||
i.scanInputFromReader(options.ExecutionId, input)
|
||||
err := i.scanInputFromReader(options.ExecutionId, input)
|
||||
_ = input.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if options.Uncover && options.UncoverQuery != nil {
|
||||
@@ -356,21 +387,38 @@ func (i *ListInputProvider) initializeInputSources(opts *Options) error {
|
||||
}
|
||||
|
||||
// scanInputFromReader scans a line of input from reader and passes it for storage
|
||||
func (i *ListInputProvider) scanInputFromReader(executionId string, reader io.Reader) {
|
||||
func (i *ListInputProvider) scanInputFromReader(executionId string, reader io.Reader) error {
|
||||
scanner := bufio.NewScanner(reader)
|
||||
for scanner.Scan() {
|
||||
item := scanner.Text()
|
||||
switch {
|
||||
case iputil.IsCIDR(item):
|
||||
ips := expand.CIDR(item)
|
||||
i.addTargets(executionId, ips)
|
||||
case asn.IsASN(item):
|
||||
ips := expand.ASN(item)
|
||||
i.addTargets(executionId, ips)
|
||||
default:
|
||||
i.Set(executionId, item)
|
||||
if err := i.addInput(executionId, scanner.Text()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// addInput stores a plain target or a target line, expanding CIDR and ASN
|
||||
// targets. An invalid target line fails the run: skipping it would drop the
|
||||
// target, and ignoring its selection would scan it with every template.
|
||||
func (i *ListInputProvider) addInput(executionId string, item string) error {
|
||||
var selection *targetprofile.Selection
|
||||
if targetprofile.IsLine(item) {
|
||||
target, parsed, err := i.profiles.ParseLine(item)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "could not parse target line")
|
||||
}
|
||||
item, selection = target, parsed
|
||||
}
|
||||
|
||||
switch {
|
||||
case iputil.IsCIDR(item):
|
||||
i.addTargets(executionId, expand.CIDR(item), selection)
|
||||
case asn.IsASN(item):
|
||||
i.addTargets(executionId, expand.ASN(item), selection)
|
||||
default:
|
||||
i.set(executionId, item, selection)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isExcluded checks if a URL is in the exclusion list
|
||||
@@ -486,24 +534,67 @@ func (i *ListInputProvider) Del(executionId string, value string) {
|
||||
}
|
||||
|
||||
// setItem in the kv store
|
||||
func (i *ListInputProvider) setItem(metaInput *contextargs.MetaInput) {
|
||||
func (i *ListInputProvider) setItem(metaInput *contextargs.MetaInput, selection *targetprofile.Selection) {
|
||||
key, err := metaInput.MarshalString()
|
||||
if err != nil {
|
||||
gologger.Warning().Msgf("%s\n", err)
|
||||
return
|
||||
}
|
||||
if _, ok := i.hostMap.Get(key); ok {
|
||||
// A crawled list routinely holds the same resource several times over,
|
||||
// differing only by host case, a fragment, a default port, a trailing slash
|
||||
// or a tracking parameter. Those collapse onto one entry, but the target
|
||||
// that is scanned stays exactly as supplied: stripping a parameter from the
|
||||
// request would shrink the attack surface, not just the duplicate count.
|
||||
// Duplicates are removed before the pattern cap, so a repeated URL does not
|
||||
// use up a slot that a later distinct URL still needs.
|
||||
dedupKey := key
|
||||
if i.normalizeURLs {
|
||||
if normalized := i.normalizedKey(metaInput); normalized != "" {
|
||||
dedupKey = normalized
|
||||
}
|
||||
}
|
||||
if _, ok := i.hostMap.Get(dedupKey); ok {
|
||||
i.dupeCount++
|
||||
if i.profiles != nil {
|
||||
i.profiles.Merge(metaInput.Input, selection)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Past the per-pattern cap the shape is already covered by the targets kept
|
||||
// for it, so scanning another of the same shape buys nothing.
|
||||
if i.patterns != nil && !i.patterns.Accept(metaInput.Input) {
|
||||
i.patternSkippedCount++
|
||||
return
|
||||
}
|
||||
|
||||
if i.profiles != nil {
|
||||
i.profiles.Bind(metaInput.Input, selection)
|
||||
}
|
||||
i.inputCount++ // tracks target count
|
||||
_ = i.hostMap.Set(key, nil)
|
||||
// the entry is keyed by the collapsed form and carries the original, which
|
||||
// is what Iterate hands back to the engine
|
||||
_ = i.hostMap.Set(dedupKey, []byte(key))
|
||||
if i.hostMapStream != nil {
|
||||
i.setHostMapStream(key)
|
||||
}
|
||||
}
|
||||
|
||||
// normalizedKey returns the deduplication key for a target, or "" when the
|
||||
// value is not a URL and has no collapsed form.
|
||||
func (i *ListInputProvider) normalizedKey(metaInput *contextargs.MetaInput) string {
|
||||
collapsed := normalize.URL(metaInput.Input)
|
||||
if collapsed == metaInput.Input {
|
||||
return ""
|
||||
}
|
||||
clone := *metaInput
|
||||
clone.Input = collapsed
|
||||
key, err := clone.MarshalString()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
const removeTargetsChunkSize = 5000
|
||||
|
||||
// delItem removes all hostMap entries matching any of the given targets in a single scan.
|
||||
@@ -565,9 +656,9 @@ func (i *ListInputProvider) setHostMapStream(data string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (i *ListInputProvider) addTargets(executionId string, targets []string) {
|
||||
func (i *ListInputProvider) addTargets(executionId string, targets []string, selection *targetprofile.Selection) {
|
||||
for _, target := range targets {
|
||||
i.Set(executionId, target)
|
||||
i.set(executionId, target, selection)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/miekg/dns"
|
||||
"github.com/projectdiscovery/hmap/store/hybrid"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/input/normalize"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/types"
|
||||
@@ -17,6 +18,76 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSetItemDedupDoesNotConsumePatternCap(t *testing.T) {
|
||||
hm, err := hybrid.New(hybrid.DefaultDiskOptions)
|
||||
require.NoError(t, err)
|
||||
input := &ListInputProvider{
|
||||
hostMap: hm,
|
||||
normalizeURLs: true,
|
||||
patterns: normalize.NewFingerprinter(1),
|
||||
}
|
||||
t.Cleanup(input.Close)
|
||||
add := func(raw string) {
|
||||
meta := contextargs.NewMetaInput()
|
||||
meta.Input = raw
|
||||
input.setItem(meta, nil)
|
||||
}
|
||||
add("http://acme.test/user/1")
|
||||
add("http://ACME.test/user/1")
|
||||
add("http://acme.test/user/2")
|
||||
add("http://acme.test/admin")
|
||||
|
||||
require.Equal(t, int64(1), input.dupeCount)
|
||||
require.Equal(t, int64(1), input.patternSkippedCount)
|
||||
require.Equal(t, int64(0), input.skippedCount)
|
||||
require.Equal(t, int64(2), input.inputCount)
|
||||
}
|
||||
|
||||
func TestSetItemKeepsTheSuppliedTarget(t *testing.T) {
|
||||
hm, err := hybrid.New(hybrid.DefaultDiskOptions)
|
||||
require.NoError(t, err)
|
||||
input := &ListInputProvider{
|
||||
hostMap: hm,
|
||||
normalizeURLs: true,
|
||||
patterns: normalize.NewFingerprinter(0),
|
||||
}
|
||||
t.Cleanup(input.Close)
|
||||
add := func(raw string) {
|
||||
meta := contextargs.NewMetaInput()
|
||||
meta.Input = raw
|
||||
input.setItem(meta, nil)
|
||||
}
|
||||
add("http://ACME.test/page/")
|
||||
add("http://acme.test/page?utm_source=news")
|
||||
|
||||
require.Equal(t, int64(1), input.dupeCount)
|
||||
require.Equal(t, int64(1), input.inputCount)
|
||||
var got []string
|
||||
input.Iterate(func(value *contextargs.MetaInput) bool {
|
||||
got = append(got, value.Input)
|
||||
return true
|
||||
})
|
||||
require.Equal(t, []string{"http://ACME.test/page/"}, got)
|
||||
}
|
||||
|
||||
func TestSetItemWithoutNormalizationKeepsCaseVariants(t *testing.T) {
|
||||
hm, err := hybrid.New(hybrid.DefaultDiskOptions)
|
||||
require.NoError(t, err)
|
||||
input := &ListInputProvider{
|
||||
hostMap: hm,
|
||||
normalizeURLs: false,
|
||||
patterns: normalize.NewFingerprinter(0),
|
||||
}
|
||||
t.Cleanup(input.Close)
|
||||
for _, raw := range []string{"http://ACME.test/page", "http://acme.test/page"} {
|
||||
meta := contextargs.NewMetaInput()
|
||||
meta.Input = raw
|
||||
input.setItem(meta, nil)
|
||||
}
|
||||
require.Equal(t, int64(0), input.dupeCount)
|
||||
require.Equal(t, int64(2), input.inputCount)
|
||||
}
|
||||
|
||||
func Test_expandCIDR(t *testing.T) {
|
||||
tests := []struct {
|
||||
cidr string
|
||||
@@ -36,7 +107,7 @@ func Test_expandCIDR(t *testing.T) {
|
||||
input := &ListInputProvider{hostMap: hm}
|
||||
|
||||
ips := expand.CIDR(tt.cidr)
|
||||
input.addTargets("", ips)
|
||||
input.addTargets("", ips, nil)
|
||||
// scan
|
||||
got := []string{}
|
||||
input.hostMap.Scan(func(k, _ []byte) error {
|
||||
@@ -200,7 +271,7 @@ func Test_expandASNInputValue(t *testing.T) {
|
||||
input := &ListInputProvider{hostMap: hm}
|
||||
// get the IP addresses for ASN number
|
||||
ips := expand.ASN(tt.asn)
|
||||
input.addTargets("", ips)
|
||||
input.addTargets("", ips, nil)
|
||||
// scan the hmap
|
||||
got := []string{}
|
||||
input.hostMap.Scan(func(k, v []byte) error {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package list
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/hmap/store/hybrid"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/input/targetprofile"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestTargetLines(t *testing.T) {
|
||||
hm, err := hybrid.New(hybrid.DefaultDiskOptions)
|
||||
require.NoError(t, err)
|
||||
input := &ListInputProvider{hostMap: hm, ipOptions: &ipOptions{IPV4: true}, profiles: targetprofile.NewRegistry(t.TempDir())}
|
||||
defer input.Close()
|
||||
|
||||
lines := strings.Join([]string{
|
||||
`{"target": "192.0.2.0/30", "tags": ["tomcat"]}`,
|
||||
`{"target": "192.0.2.10", "tags": ["thinkphp"]}`,
|
||||
`{"target": "192.0.2.10", "tags": ["tomcat"]}`,
|
||||
`192.0.2.20`,
|
||||
}, "\n")
|
||||
require.NoError(t, input.scanInputFromReader("", strings.NewReader(lines)))
|
||||
|
||||
require.EqualValues(t, 6, input.Count(), "4 CIDR hosts, one merged duplicate, one plain target")
|
||||
for _, ip := range []string{"192.0.2.0", "192.0.2.1", "192.0.2.2", "192.0.2.3", "192.0.2.10"} {
|
||||
require.NotNil(t, input.profiles.For(&contextargs.MetaInput{Input: ip}), ip)
|
||||
}
|
||||
require.Nil(t, input.profiles.For(&contextargs.MetaInput{Input: "192.0.2.20"}))
|
||||
require.Nil(t, input.profiles.LoadFilter(), "a plain target needs every template")
|
||||
|
||||
err = input.scanInputFromReader("", strings.NewReader(`{"target": "192.0.2.30", "tagz": ["x"]}`))
|
||||
require.ErrorContains(t, err, "unknown keys tagz")
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package targetprofile
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/projectdiscovery/gologger"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/index"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
|
||||
)
|
||||
|
||||
// Registry maps targets to their template selection.
|
||||
//
|
||||
// Targets are bound while inputs are read, then Prepare is called with the
|
||||
// loaded templates before the scan starts. Selections are interned, so targets
|
||||
// sharing a selector share one memoized decision per template.
|
||||
type Registry struct {
|
||||
templatesDir string
|
||||
|
||||
mu sync.RWMutex
|
||||
targets map[string]*Selection
|
||||
selections map[string]*Selection
|
||||
rules map[string]*rule
|
||||
profiles map[string]selector
|
||||
unrestricted bool
|
||||
|
||||
metadata map[string]*index.Metadata
|
||||
}
|
||||
|
||||
var _ protocols.TargetScope = (*Registry)(nil)
|
||||
|
||||
// NewRegistry creates a registry resolving profiles and relative template
|
||||
// paths against templatesDir.
|
||||
func NewRegistry(templatesDir string) *Registry {
|
||||
return &Registry{
|
||||
templatesDir: templatesDir,
|
||||
targets: make(map[string]*Selection),
|
||||
selections: make(map[string]*Selection),
|
||||
rules: make(map[string]*rule),
|
||||
profiles: make(map[string]selector),
|
||||
}
|
||||
}
|
||||
|
||||
// ParseLine parses a target line into its target and selection.
|
||||
func (r *Registry) ParseLine(line string) (string, *Selection, error) {
|
||||
target, profile, sel, err := parseLine(line)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if profile != "" {
|
||||
base, err := r.profile(profile)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("target %q: %w", target, err)
|
||||
}
|
||||
sel = base.merge(sel)
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
compiled, err := r.internRule(sel)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("target %q: %w", target, err)
|
||||
}
|
||||
return target, r.internSelection([]*rule{compiled}), nil
|
||||
}
|
||||
|
||||
// Bind records the selection of a newly stored input; nil means unrestricted.
|
||||
func (r *Registry) Bind(input string, selection *Selection) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if selection == nil {
|
||||
r.unrestricted = true
|
||||
if _, ok := r.targets[input]; ok {
|
||||
r.targets[input] = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
current, ok := r.targets[input]
|
||||
switch {
|
||||
case !ok:
|
||||
r.targets[input] = selection
|
||||
case current != nil:
|
||||
r.targets[input] = r.union(current, selection)
|
||||
}
|
||||
}
|
||||
|
||||
// Merge records the selection of an input that was already stored, so a
|
||||
// target listed more than once runs the union of its selections and is
|
||||
// scanned once.
|
||||
func (r *Registry) Merge(input string, selection *Selection) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
current, ok := r.targets[input]
|
||||
switch {
|
||||
case !ok || current == nil:
|
||||
// already unrestricted
|
||||
case selection == nil:
|
||||
r.unrestricted = true
|
||||
r.targets[input] = nil
|
||||
default:
|
||||
r.targets[input] = r.union(current, selection)
|
||||
}
|
||||
}
|
||||
|
||||
// Scoped reports whether any target has a selection.
|
||||
func (r *Registry) Scoped() bool {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
return len(r.selections) > 0
|
||||
}
|
||||
|
||||
// LoadFilter returns the templates worth loading: those selected for at least
|
||||
// one target. It returns nil when some target is unrestricted, since that
|
||||
// target needs every template.
|
||||
func (r *Registry) LoadFilter() index.FilterFunc {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
if r.unrestricted || len(r.rules) == 0 {
|
||||
return nil
|
||||
}
|
||||
rules := make([]*rule, 0, len(r.rules))
|
||||
for _, compiled := range r.rules {
|
||||
rules = append(rules, compiled)
|
||||
}
|
||||
return func(m *index.Metadata) bool { return anyMatches(rules, m) }
|
||||
}
|
||||
|
||||
// Prepare indexes the metadata of the templates about to run.
|
||||
func (r *Registry) Prepare(tpls []*templates.Template) {
|
||||
metadata := make(map[string]*index.Metadata, len(tpls))
|
||||
for _, tpl := range tpls {
|
||||
metadata[tpl.Path] = index.NewMetadataFromTemplate(tpl.Path, tpl)
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
r.metadata = metadata
|
||||
for _, selection := range r.selections {
|
||||
selection.decisions.Clear()
|
||||
}
|
||||
}
|
||||
|
||||
// For returns the selection of input, or nil when every template applies.
|
||||
func (r *Registry) For(input *contextargs.MetaInput) protocols.TemplateSelection {
|
||||
r.mu.RLock()
|
||||
selection := r.targets[input.Input]
|
||||
r.mu.RUnlock()
|
||||
|
||||
// a nil *Selection must not become a non-nil interface
|
||||
if selection == nil {
|
||||
return nil
|
||||
}
|
||||
return selection
|
||||
}
|
||||
|
||||
func (r *Registry) profile(name string) (selector, error) {
|
||||
r.mu.RLock()
|
||||
sel, ok := r.profiles[name]
|
||||
r.mu.RUnlock()
|
||||
if ok {
|
||||
return sel, nil
|
||||
}
|
||||
|
||||
sel, ignored, err := loadProfile(name, r.templatesDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(ignored) > 0 {
|
||||
gologger.Warning().Msgf("Profile %q keys %s apply to the whole scan and are ignored per target", name, strings.Join(ignored, ", "))
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
r.profiles[name] = sel
|
||||
r.mu.Unlock()
|
||||
return sel, nil
|
||||
}
|
||||
|
||||
func (r *Registry) internRule(sel selector) (*rule, error) {
|
||||
key, err := canonicalKey(sel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if compiled, ok := r.rules[key]; ok {
|
||||
return compiled, nil
|
||||
}
|
||||
compiled, err := compileRule(sel, r.templatesDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.rules[key] = compiled
|
||||
return compiled, nil
|
||||
}
|
||||
|
||||
func (r *Registry) internSelection(rules []*rule) *Selection {
|
||||
keys := make([]string, len(rules))
|
||||
for i, compiled := range rules {
|
||||
keys[i] = compiled.key
|
||||
}
|
||||
key := strings.Join(keys, "\n")
|
||||
if selection, ok := r.selections[key]; ok {
|
||||
return selection
|
||||
}
|
||||
selection := &Selection{registry: r, rules: rules}
|
||||
r.selections[key] = selection
|
||||
return selection
|
||||
}
|
||||
|
||||
func (r *Registry) union(a, b *Selection) *Selection {
|
||||
if a == b {
|
||||
return a
|
||||
}
|
||||
rules := slices.Concat(a.rules, b.rules)
|
||||
slices.SortFunc(rules, func(x, y *rule) int { return strings.Compare(x.key, y.key) })
|
||||
rules = slices.CompactFunc(rules, func(x, y *rule) bool { return x.key == y.key })
|
||||
return r.internSelection(rules)
|
||||
}
|
||||
|
||||
// Selection is the template selection of one or more targets: a template is
|
||||
// selected when any of its rules selects it.
|
||||
type Selection struct {
|
||||
registry *Registry
|
||||
rules []*rule
|
||||
decisions sync.Map
|
||||
}
|
||||
|
||||
var _ protocols.TemplateSelection = (*Selection)(nil)
|
||||
|
||||
// Allows reports whether the template loaded from templatePath is selected.
|
||||
// Templates unknown to Prepare are allowed rather than silently dropped.
|
||||
func (s *Selection) Allows(templatePath string) bool {
|
||||
if allowed, ok := s.decisions.Load(templatePath); ok {
|
||||
return allowed.(bool)
|
||||
}
|
||||
|
||||
s.registry.mu.RLock()
|
||||
m := s.registry.metadata[templatePath]
|
||||
s.registry.mu.RUnlock()
|
||||
|
||||
allowed := m == nil || anyMatches(s.rules, m)
|
||||
s.decisions.Store(templatePath, allowed)
|
||||
return allowed
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package targetprofile
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/model"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/model/types/severity"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/model/types/stringslice"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/contextargs"
|
||||
"github.com/projectdiscovery/nuclei/v3/pkg/templates"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const templatesDir = "/templates"
|
||||
|
||||
func newTemplate(path, id string, sev severity.Severity, product string, tags ...string) *templates.Template {
|
||||
info := model.Info{
|
||||
Tags: stringslice.StringSlice{Value: tags},
|
||||
SeverityHolder: severity.Holder{Severity: sev},
|
||||
}
|
||||
if product != "" {
|
||||
info.Metadata = map[string]any{"product": product}
|
||||
}
|
||||
return &templates.Template{ID: id, Path: filepath.Join(templatesDir, path), Info: info}
|
||||
}
|
||||
|
||||
var (
|
||||
thinkphpRCE = newTemplate("http/cves/thinkphp-rce.yaml", "thinkphp-rce", severity.Critical, "thinkphp", "cve", "thinkphp", "rce")
|
||||
tomcatPanel = newTemplate("http/exposed-panels/tomcat-panel.yaml", "tomcat-panel", severity.Info, "tomcat", "panel", "tomcat")
|
||||
ghostcat = newTemplate("network/cves/CVE-2020-1938.yaml", "CVE-2020-1938", severity.Critical, "geode", "cve", "apache", "tomcat")
|
||||
gitConfig = newTemplate("http/exposures/git-config.yaml", "git-config", severity.Medium, "", "exposure", "git")
|
||||
allTemplates = []*templates.Template{thinkphpRCE, tomcatPanel, ghostcat, gitConfig}
|
||||
)
|
||||
|
||||
func selected(t *testing.T, r *Registry, input string) []string {
|
||||
t.Helper()
|
||||
selection := r.For(&contextargs.MetaInput{Input: input})
|
||||
var ids []string
|
||||
for _, tpl := range allTemplates {
|
||||
if selection == nil || selection.Allows(tpl.Path) {
|
||||
ids = append(ids, tpl.ID)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func bindLine(t *testing.T, r *Registry, line string) string {
|
||||
t.Helper()
|
||||
target, selection, err := r.ParseLine(line)
|
||||
require.NoError(t, err)
|
||||
r.Bind(target, selection)
|
||||
return target
|
||||
}
|
||||
|
||||
func TestParseLineRejectsInvalidLines(t *testing.T) {
|
||||
r := NewRegistry(templatesDir)
|
||||
for name, line := range map[string]string{
|
||||
"malformed json": `{"target": "a.com"`,
|
||||
"missing target": `{"tags": ["cve"]}`,
|
||||
"unknown key": `{"target": "a.com", "tag": ["cve"]}`,
|
||||
"invalid severity": `{"target": "a.com", "severity": ["urgent"]}`,
|
||||
"invalid type": `{"target": "a.com", "type": ["smtp"]}`,
|
||||
"non string value": `{"target": "a.com", "tags": [1]}`,
|
||||
"missing profile": `{"target": "a.com", "profile": "does-not-exist"}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, _, err := r.ParseLine(line)
|
||||
require.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProfileMustBeAnID(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "profiles"), 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "profiles", "php.yml"), []byte("tags: [thinkphp]\n"), 0o600))
|
||||
outside := filepath.Join(dir, "outside.yml")
|
||||
require.NoError(t, os.WriteFile(outside, []byte("tags: [tomcat]\n"), 0o600))
|
||||
|
||||
r := NewRegistry(dir)
|
||||
for _, profile := range []string{outside, "../outside.yml", "../outside", "profiles/php.yml", "php.yml", `profiles\php`, ".."} {
|
||||
t.Run(profile, func(t *testing.T) {
|
||||
line, err := json.Marshal(map[string]string{"target": "a.com", "profile": profile})
|
||||
require.NoError(t, err)
|
||||
_, _, err = r.ParseLine(string(line))
|
||||
require.ErrorContains(t, err, "profile ID")
|
||||
})
|
||||
}
|
||||
|
||||
_, selection, err := r.ParseLine(`{"target": "a.com", "profile": "php"}`)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"thinkphp"}, selection.rules[0].filter.Tags)
|
||||
}
|
||||
|
||||
func TestSelectionFilters(t *testing.T) {
|
||||
r := NewRegistry(templatesDir)
|
||||
tags := bindLine(t, r, `{"target": "https://a.com", "tags": ["thinkphp"]}`)
|
||||
commaTags := bindLine(t, r, `{"target": "https://b.com", "tags": "tomcat, exposure"}`)
|
||||
sev := bindLine(t, r, `{"target": "https://c.com", "severity": ["critical"], "exclude-id": ["CVE-2020-1938"]}`)
|
||||
paths := bindLine(t, r, `{"target": "https://d.com", "templates": ["http/"]}`)
|
||||
r.Prepare(allTemplates)
|
||||
|
||||
require.Equal(t, []string{"thinkphp-rce"}, selected(t, r, tags))
|
||||
require.Equal(t, []string{"tomcat-panel", "CVE-2020-1938", "git-config"}, selected(t, r, commaTags))
|
||||
require.Equal(t, []string{"thinkphp-rce"}, selected(t, r, sev))
|
||||
require.Equal(t, []string{"thinkphp-rce", "tomcat-panel", "git-config"}, selected(t, r, paths))
|
||||
require.Equal(t, []string{"thinkphp-rce", "tomcat-panel", "CVE-2020-1938", "git-config"}, selected(t, r, "https://unlisted.com"))
|
||||
}
|
||||
|
||||
func TestSelectionTech(t *testing.T) {
|
||||
r := NewRegistry(templatesDir)
|
||||
tomcat := bindLine(t, r, `{"target": "https://a.com", "tech": ["Apache Tomcat"]}`)
|
||||
php := bindLine(t, r, `{"target": "https://b.com", "tech": ["ThinkPHP"], "severity": ["critical", "medium"]}`)
|
||||
r.Prepare(allTemplates)
|
||||
|
||||
// product-bound templates follow the tech list, generic ones always run;
|
||||
// Ghostcat declares product "geode" but is kept by its tomcat tag
|
||||
require.Equal(t, []string{"tomcat-panel", "CVE-2020-1938", "git-config"}, selected(t, r, tomcat))
|
||||
require.Equal(t, []string{"thinkphp-rce", "git-config"}, selected(t, r, php))
|
||||
}
|
||||
|
||||
func TestProfileWithInlineOverride(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "profiles"), 0o755))
|
||||
profile := "code: true\ntags:\n - thinkphp\nseverity:\n - info\n"
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "profiles", "php.yml"), []byte(profile), 0o600))
|
||||
|
||||
r := NewRegistry(dir)
|
||||
target, selection, err := r.ParseLine(`{"target": "https://a.com", "profile": "php", "severity": "critical"}`)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, selection.rules, 1)
|
||||
require.Equal(t, []string{"thinkphp"}, selection.rules[0].filter.Tags)
|
||||
require.Equal(t, []severity.Severity{severity.Critical}, selection.rules[0].filter.Severities)
|
||||
require.Equal(t, "https://a.com", target)
|
||||
}
|
||||
|
||||
func TestDuplicateTargetsMerge(t *testing.T) {
|
||||
r := NewRegistry(templatesDir)
|
||||
_, php, err := r.ParseLine(`{"target": "https://a.com", "tags": ["thinkphp"]}`)
|
||||
require.NoError(t, err)
|
||||
_, tomcat, err := r.ParseLine(`{"target": "https://a.com", "tags": ["tomcat"]}`)
|
||||
require.NoError(t, err)
|
||||
|
||||
r.Bind("https://a.com", php)
|
||||
r.Merge("https://a.com", tomcat)
|
||||
r.Merge("https://a.com", php)
|
||||
r.Bind("https://b.com", php)
|
||||
r.Merge("https://b.com", nil)
|
||||
r.Bind("https://c.com", nil)
|
||||
r.Merge("https://c.com", php)
|
||||
r.Prepare(allTemplates)
|
||||
|
||||
require.Equal(t, []string{"thinkphp-rce", "tomcat-panel", "CVE-2020-1938"}, selected(t, r, "https://a.com"))
|
||||
require.Nil(t, r.For(&contextargs.MetaInput{Input: "https://b.com"}), "a plain duplicate lifts the restriction")
|
||||
require.Nil(t, r.For(&contextargs.MetaInput{Input: "https://c.com"}), "a plain target stays unrestricted")
|
||||
|
||||
_, again, err := r.ParseLine(`{"target": "https://z.com", "tags": ["thinkphp"]}`)
|
||||
require.NoError(t, err)
|
||||
require.Same(t, php, again, "identical selectors are interned")
|
||||
}
|
||||
|
||||
func TestLoadFilter(t *testing.T) {
|
||||
t.Run("union of selections", func(t *testing.T) {
|
||||
r := NewRegistry(templatesDir)
|
||||
bindLine(t, r, `{"target": "https://a.com", "tags": ["thinkphp"]}`)
|
||||
bindLine(t, r, `{"target": "https://b.com", "tech": ["tomcat"], "severity": ["info"]}`)
|
||||
filter := r.LoadFilter()
|
||||
require.NotNil(t, filter)
|
||||
|
||||
r.Prepare(allTemplates)
|
||||
var loaded []string
|
||||
for _, tpl := range allTemplates {
|
||||
if filter(r.metadata[tpl.Path]) {
|
||||
loaded = append(loaded, tpl.ID)
|
||||
}
|
||||
}
|
||||
require.Equal(t, []string{"thinkphp-rce", "tomcat-panel"}, loaded)
|
||||
})
|
||||
|
||||
t.Run("unrestricted target loads everything", func(t *testing.T) {
|
||||
r := NewRegistry(templatesDir)
|
||||
bindLine(t, r, `{"target": "https://a.com", "tags": ["thinkphp"]}`)
|
||||
r.Bind("https://b.com", nil)
|
||||
require.Nil(t, r.LoadFilter())
|
||||
})
|
||||
|
||||
t.Run("plain duplicate of a target line loads everything", func(t *testing.T) {
|
||||
r := NewRegistry(templatesDir)
|
||||
target := bindLine(t, r, `{"target": "https://a.com", "tags": ["thinkphp"]}`)
|
||||
r.Merge(target, nil)
|
||||
require.Nil(t, r.LoadFilter())
|
||||
})
|
||||
}
|
||||
|
||||
func TestTechTokens(t *testing.T) {
|
||||
for tech, want := range map[string][]string{
|
||||
"nginx": {"nginx"},
|
||||
"ThinkPHP": {"thinkphp"},
|
||||
"apache_tomcat": {"apache-tomcat"},
|
||||
"Apache Tomcat": {"apache-tomcat", "apache", "tomcat"},
|
||||
"apache:tomcat": {"apache-tomcat", "apache", "tomcat"},
|
||||
"Microsoft IIS": {"microsoft-iis", "microsoft", "iis"},
|
||||
" ": nil,
|
||||
} {
|
||||
require.Equal(t, want, techTokens(tech), tech)
|
||||
}
|
||||
}
|
||||
Loaded 100 of 301 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user