Compare commits

..
Author SHA1 Message Date
Sandeep Singh a8c88feb4a Merge pull request #7641 from projectdiscovery/dev
release prep
2026-08-08 17:50:01 +05:30
PDTeamX 5bbca25bea version update 2026-08-08 17:49:30 +05:30
Mzack9999 64bc83e004 Merge pull request #7635 from projectdiscovery/dependabot/github_actions/dev/workflows-84c3d95bcf
chore(deps): bump actions/stale from 10 to 11 in the workflows group
2026-08-05 14:07:54 +04:00
Mzack9999 37bba07662 Merge pull request #7636 from dwisiswant0/dwisiswant0/feat/templates/bind-javascript-execution-to-verified-content
feat(templates): bind js execution to verified content
2026-08-05 13:38:09 +04:00
Dwi Siswanto 9de96e4dda feat(templates): bind js execution to verified content
Verify signatures before protocol compilation so
unsigned init blocks cannot run, and enforce the
same check at request execution.

Bind cached results to the template, imported file
contents, and verifier key. Use the loaded import
snapshot for signing and verification, and apply
the executable-template re-signing rule to
JavaScript.

Signed-off-by: Dwi Siswanto <[email protected]>
2026-08-04 10:24:11 +07:00
dependabot[bot] 4a6f7c5a86 chore(deps): bump the modules group with 3 updates (#7634)
Bumps the modules group with 3 updates: [github.com/projectdiscovery/fastdialer](https://github.com/projectdiscovery/fastdialer), [github.com/projectdiscovery/retryablehttp-go](https://github.com/projectdiscovery/retryablehttp-go) and [github.com/projectdiscovery/networkpolicy](https://github.com/projectdiscovery/networkpolicy).


Updates `github.com/projectdiscovery/fastdialer` from 0.5.13 to 0.5.14
- [Release notes](https://github.com/projectdiscovery/fastdialer/releases)
- [Commits](https://github.com/projectdiscovery/fastdialer/compare/v0.5.13...v0.5.14)

Updates `github.com/projectdiscovery/retryablehttp-go` from 1.3.20 to 1.3.21
- [Release notes](https://github.com/projectdiscovery/retryablehttp-go/releases)
- [Commits](https://github.com/projectdiscovery/retryablehttp-go/compare/v1.3.20...v1.3.21)

Updates `github.com/projectdiscovery/networkpolicy` from 0.1.43 to 0.1.44
- [Release notes](https://github.com/projectdiscovery/networkpolicy/releases)
- [Commits](https://github.com/projectdiscovery/networkpolicy/compare/v0.1.43...v0.1.44)

---
updated-dependencies:
- dependency-name: github.com/projectdiscovery/fastdialer
  dependency-version: 0.5.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: modules
- dependency-name: github.com/projectdiscovery/retryablehttp-go
  dependency-version: 1.3.21
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: modules
- dependency-name: github.com/projectdiscovery/networkpolicy
  dependency-version: 0.1.44
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: modules
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-03 06:18:57 +00:00
Mzack9999 890147c20e Merge pull request #7632 from projectdiscovery/chore/bump-kin-openapi
chore(deps): bump modules with known advisories
2026-08-03 10:09:12 +04:00
dependabot[bot] 89c2054bc1 chore(deps): bump actions/stale from 10 to 11 in the workflows group
Bumps the workflows group with 1 update: [actions/stale](https://github.com/actions/stale).


Updates `actions/stale` from 10 to 11
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/v10...v11)

---
updated-dependencies:
- dependency-name: actions/stale
  dependency-version: '11'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: workflows
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-08-03 05:55:36 +00:00
Doğan Can Bakır 938b19d470 chore(deps): bump modules with known advisories
govulncheck on dev reports x/text and goldmark as reachable, and compress and
otel as imported. Remaining findings are the unmaintained x/crypto/openpgp
advisory, which has no fixed version and is reached only through go-github's
init, and stdlib patches that the build toolchain supplies.
2026-07-31 16:05:01 +03:00
Mzack9999 ba05210f16 Merge pull request #7616 from projectdiscovery/dwisiswant0/fix/loader/compile-requests-during-validation
fix(loader): compile requests during validation
2026-07-27 16:09:49 +04:00
Mzack9999 e557207b41 add nuclei/http js client (#7568)
* add http

* harden ntlm

* fix bounds
2026-07-27 15:47:02 +04:00
Mzack9999 4c7e1687ad rawhttp bump (#7593) 2026-07-27 15:46:12 +04:00
Dwi Siswanto 479c8187a5 fix(loader): compile requests during validation
Do not let parsed template cache hits bypass
protocol compilation, making `-validate` reject
templates with runtime compilation errors.

Fixes #7602

Signed-off-by: Dwi Siswanto <[email protected]>
2026-07-27 16:42:19 +07:00
ghost 7936ec36d8 chore(js): update memoized functions 🤖 2026-07-27 09:07:18 +00:00
Mzack9999 e6e558dc23 Improve MSSQL JS fingerprint via TDS prelogin (#7577)
* mssql fingerprint

* review fixes

* fix typo

* fix test
2026-07-27 13:07:03 +04:00
ghost 9d9012ce29 docs: update syntax & JSON schema 🤖 2026-07-27 08:14:48 +00:00
Mzack9999 671b875d23 Improve MySQL JS fingerprint with native handshake parsing (#7575)
* Native fingerprint

* fix typo
2026-07-27 12:10:26 +04:00
dependabot[bot] 367dfdb436 chore(deps): bump the modules group with 5 updates (#7615)
Bumps the modules group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [github.com/projectdiscovery/rawhttp](https://github.com/projectdiscovery/rawhttp) | `0.1.90` | `0.1.91` |
| [github.com/projectdiscovery/retryablehttp-go](https://github.com/projectdiscovery/retryablehttp-go) | `1.3.19` | `1.3.20` |
| [github.com/projectdiscovery/networkpolicy](https://github.com/projectdiscovery/networkpolicy) | `0.1.42` | `0.1.43` |
| [github.com/projectdiscovery/wappalyzergo](https://github.com/projectdiscovery/wappalyzergo) | `0.2.90` | `0.2.91` |
| [github.com/projectdiscovery/cdncheck](https://github.com/projectdiscovery/cdncheck) | `1.2.45` | `1.2.46` |


Updates `github.com/projectdiscovery/rawhttp` from 0.1.90 to 0.1.91
- [Release notes](https://github.com/projectdiscovery/rawhttp/releases)
- [Commits](https://github.com/projectdiscovery/rawhttp/compare/v0.1.90...v0.1.91)

Updates `github.com/projectdiscovery/retryablehttp-go` from 1.3.19 to 1.3.20
- [Release notes](https://github.com/projectdiscovery/retryablehttp-go/releases)
- [Commits](https://github.com/projectdiscovery/retryablehttp-go/compare/v1.3.19...v1.3.20)

Updates `github.com/projectdiscovery/networkpolicy` from 0.1.42 to 0.1.43
- [Release notes](https://github.com/projectdiscovery/networkpolicy/releases)
- [Commits](https://github.com/projectdiscovery/networkpolicy/compare/v0.1.42...v0.1.43)

Updates `github.com/projectdiscovery/wappalyzergo` from 0.2.90 to 0.2.91
- [Release notes](https://github.com/projectdiscovery/wappalyzergo/releases)
- [Commits](https://github.com/projectdiscovery/wappalyzergo/compare/v0.2.90...v0.2.91)

Updates `github.com/projectdiscovery/cdncheck` from 1.2.45 to 1.2.46
- [Release notes](https://github.com/projectdiscovery/cdncheck/releases)
- [Commits](https://github.com/projectdiscovery/cdncheck/compare/v1.2.45...v1.2.46)

---
updated-dependencies:
- dependency-name: github.com/projectdiscovery/rawhttp
  dependency-version: 0.1.91
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: modules
- dependency-name: github.com/projectdiscovery/retryablehttp-go
  dependency-version: 1.3.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: modules
- dependency-name: github.com/projectdiscovery/networkpolicy
  dependency-version: 0.1.43
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: modules
- dependency-name: github.com/projectdiscovery/wappalyzergo
  dependency-version: 0.2.91
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: modules
- dependency-name: github.com/projectdiscovery/cdncheck
  dependency-version: 1.2.46
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: modules
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-27 06:18:27 +00:00
ghost ad1447b329 chore(js): update memoized functions 🤖 2026-07-26 15:51:55 +00:00
Mzack9999 1f39f73e93 extend js smb for #4707 (#7565)
* extend smb

* enum services

* unify session

* bridge files

* document matrix

* enum processes

* test session

* test enums

* test bridge

* fix ci

* address review

* fix lint
2026-07-26 19:51:32 +04:00
Mzack9999 437897d054 Print config directories in -hc and verbose modes (#7578)
* config directories

* lint review
2026-07-26 17:57:29 +07:00
Mzack9999 31636218ee Merge pull request #7582 from projectdiscovery/5121-cli-leaktest
Add CLI runner goroutine leak test
2026-07-26 14:43:03 +04:00
Mzack9999 eb1bb88ce5 Merge pull request #7601 from DPS0340/fix/buildrequest-nil-request
fix: avoid panic in BuildRequest when request response has no request
2026-07-26 14:42:06 +04:00
Jiho Lee 6f5a8ec5d4 fix: avoid panic in BuildRequest when request response has no request 2026-07-26 17:16:46 +09:00
ghost dd78cd5959 docs: update syntax & JSON schema 🤖 2026-07-26 08:04:11 +00:00
Mzack9999 4d4b88f6ca Merge pull request #7608 from projectdiscovery/dwisiswant0/feat/lib/reuse-metadata-cache-across-thread-safe-scans
feat(lib): reuse metadata cache across thread-safe scans
2026-07-26 11:59:53 +04:00
Mzack9999 e2e3574f59 Merge pull request #7609 from projectdiscovery/dependabot/go_modules/go_modules-715adc688b
chore(deps): bump github.com/getkin/kin-openapi from 0.132.0 to 0.144.0 in the go_modules group across 1 directory
2026-07-26 11:55:53 +04:00
Mzack9999 250c2a5fd7 Merge pull request #7586 from projectdiscovery/5440-http-tls-metadata
http tlsmeta
2026-07-26 11:53:07 +04:00
Mzack9999 06ff996374 serve errors 2026-07-26 11:04:52 +04:00
Mzack9999 62d6adde14 flake tests 2026-07-26 10:43:30 +04:00
Dwi Siswanto 16e4a7acc9 fix(openapi): exclusive bound checks
kin-openapi now represents exclusive minimum and
maximum as `ExclusiveBound` values. Use `IsTrue()`
to preserve their boolean semantics when
generating numeric examples.

Signed-off-by: Dwi Siswanto <[email protected]>
2026-07-26 13:03:16 +07:00
dependabot[bot] c6ef995875 chore(deps): bump github.com/getkin/kin-openapi
Bumps the go_modules group with 1 update in the / directory: [github.com/getkin/kin-openapi](https://github.com/getkin/kin-openapi).


Updates `github.com/getkin/kin-openapi` from 0.132.0 to 0.144.0
- [Release notes](https://github.com/getkin/kin-openapi/releases)
- [Commits](https://github.com/getkin/kin-openapi/compare/v0.132.0...v0.144.0)

---
updated-dependencies:
- dependency-name: github.com/getkin/kin-openapi
  dependency-version: 0.144.0
  dependency-type: direct:production
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-07-26 04:31:18 +00:00
Mzack9999 89d43000cf cli: wire -max-time soft termination via goflags (#7589)
* max time

* goflags bump
2026-07-26 11:29:23 +07:00
Mzack9999 63bfe19f40 docs: missing CLI flags in README (#7596) 2026-07-26 11:27:33 +07:00
Dwi Siswanto f239d6e7c6 feat(lib): reuse metadata cache across thread-safe scans
Keep one engine-owned metadata index across SDK
calls and persist it at shutdown instead of
loading and saving a new index for each execution.

Track dirty state and parser validation so
compatible metadata can skip redundant filtering
parses w/o trusting lax entries in strict mode.
Defensively copy cached values, invalidate stale
entries synchronously, and remove the unused
ephemeral engine allocation.

Signed-off-by: Dwi Siswanto <[email protected]>
2026-07-26 11:11:10 +07:00
Dwi Siswanto 6e990151b4 test(lib): reuse metadata index in BenchmarkExecuteNucleiWithOptsCtx
Signed-off-by: Dwi Siswanto <[email protected]>
2026-07-26 11:10:47 +07:00
Dwi Siswanto 6b66f91147 test(lib): add BenchmarkExecuteNucleiWithOptsCtx
Signed-off-by: Dwi Siswanto <[email protected]>
2026-07-26 11:10:07 +07:00
Mzack9999 60f73840e5 typos whitelist 2026-07-25 12:26:06 +04:00
Mzack9999 6ecdb94740 Merge pull request #7587 from projectdiscovery/5651-sdk-result-callback
sdk: per-execution result callback for thread-safe engine
2026-07-25 12:23:27 +04:00
dependabot[bot] 7cab28d225 chore(deps): bump google.golang.org/grpc (#7594)
Bumps the go_modules group with 1 update in the / directory: [google.golang.org/grpc](https://github.com/grpc/grpc-go).


Updates `google.golang.org/grpc` from 1.79.3 to 1.82.1
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.79.3...v1.82.1)

---
updated-dependencies:
- dependency-name: google.golang.org/grpc
  dependency-version: 1.82.1
  dependency-type: direct:production
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-25 03:15:52 +00:00
Mzack9999 da2e6910d2 sdk callback 2026-07-24 17:22:54 +04:00
Mzack9999 a12f7fb110 http tlsmeta 2026-07-24 16:47:11 +04:00
Mzack9999 35a87ba411 cli leaktest 2026-07-24 15:40:24 +04:00
ghost bcf20899ec docs: update syntax & JSON schema 🤖 2026-07-24 06:08:04 +00:00
Mzack9999 44e6f5445a dnssec types (#7564) 2026-07-24 10:06:17 +04:00
Dwi SiswantoandMzack9999 6d81cc3b62 fix(index): limit include-tags to matching tag exclusions (#7536)
* fix(index): limit include-tags to matching tag exclusions

IncludeTags in the metadata index was treated as
a forced include, so `-itags` could bypass
explicit ID/path filters and other include
criteria, while also let a template thru when it
had both an included tag and a different excluded
tag.

Match the parsed-template filter instead: an
included tag only cancels exclusion for that same
tag. Other exclusions and normal include filters
still apply, while IncludeTemplates remains the
explicit path override.

Fixes #7534

Signed-off-by: Dwi Siswanto <[email protected]>

* Skip include-tags functional parity until release

---------

Signed-off-by: Dwi Siswanto <[email protected]>
Co-authored-by: Mzack9999 <[email protected]>
2026-07-23 19:23:01 +07:00
Mzack9999 2710ba232b Improving lua script with args and values (#6561)
* improving lua script with args and values

* fixing comments

* keys to string

* fixing context

* fix(test): drop log() calls inflating redis-lua-script result count

The two log() calls each emitted a [JS] stdout line that the integration
harness counted as a result, yielding 3 results vs the expected 1. The
matcher reads `response` from the final expression value (not stdout), so
returning the get RunLuaScript call directly keeps the assertion intact
with a single result.

* fix flaky

* harden sdk tests

Avoid shared templates-dir update races across parallel go test packages and ignore known leveldb/ratelimit/memguardian goroutines in goleak.

* fix sdk templates

Keep goleak ignores, drop DisableUpdateCheck so CI can install templates, and serialize UpdateIfOutdated across processes.
2026-07-22 23:41:23 +04:00
stringsbuilderandMzack9999 ce470c32f8 refactor: replace Split in loops with more efficient SplitSeq (#7278)
* refactor: replace Split in loops with more efficient SplitSeq

Signed-off-by: stringsbuilder <[email protected]>

* minor refactor

* strict checksum

* fix typo

* harden sdk tests

Avoid shared templates-dir update races across parallel go test packages and ignore known leveldb/ratelimit/memguardian goroutines in goleak.

* bind splitseq temps

Keep SplitSeq inputs in locals so iterator substrings cannot outlive a temporary string expression.

* fix sdk templates

Keep goleak ignores, drop DisableUpdateCheck so CI can install templates, and serialize UpdateIfOutdated across processes.

---------

Signed-off-by: stringsbuilder <[email protected]>
Co-authored-by: Mzack9999 <[email protected]>
2026-07-22 23:18:56 +04:00
ghost 17092671a8 docs: update syntax & JSON schema 🤖 2026-07-22 14:24:39 +00:00
Dwi SiswantoandMzack9999 679ee5ef3a feat(protocols): add duration fields to other events (#7428)
* feat(protocols): add duration fields to other events

Add matcher and extractor duration data[1] for
DNS, SSL, WHOIS, TCP, WebSocket, and Headless
requests.

Track step durations for TCP inputs, WebSocket
message exchanges, and Headless actions. The
`duration` field holds the most recently measured
operation.

Expose request ID-prefixed duration values through
the existing template context path, and add
protocol tests for duration extraction, aliases,
and request ID handling.

[1]: The data exposed is `duration`, `duration_N`,
    `<request-id>_duration`, and
    `<request-id>_duration_<N>`.

Closes #5413
Closes #7422

Signed-off-by: Dwi Siswanto <[email protected]>

* test(protocols): added deterministic 10ms test-server delays

Signed-off-by: Dwi Siswanto <[email protected]>

* test: added a shared integration-test delay constant

Signed-off-by: Dwi Siswanto <[email protected]>

---------

Signed-off-by: Dwi Siswanto <[email protected]>
Co-authored-by: Mzack9999 <[email protected]>
2026-07-22 18:22:58 +04:00
james bobandMzack9999 be21943ee4 fix: stop busy-spinning hang monitor (#7559)
* fix(monitor): stop busy-spinning hang monitor

* test(integration): serialize dsl matcher variable case

* drop serial

---------

Co-authored-by: Mzack9999 <[email protected]>
2026-07-22 17:39:03 +04:00
SynvoyaandSynvoya f447c23a66 Fix case-insensitive word matcher missing dynamic (rendered) values (#7523)
MatchWords lowercased the corpus and literal words but not the rendered template word, so a case-insensitive word matcher backed by a {{var}} that resolved to an uppercase value never matched (false negative). Lowercase the rendered word when CaseInsensitive is set.

Co-authored-by: Synvoya <[email protected]>
2026-07-22 16:59:30 +04:00
james bob 6d7a0fa435 fix(progress): avoid panic for disabled stats interval (#7557) 2026-07-21 19:11:36 +07:00
dependabot[bot] 4e422a9308 chore(deps): bump the modules group with 3 updates (#7554)
Bumps the modules group with 3 updates: [github.com/projectdiscovery/retryablehttp-go](https://github.com/projectdiscovery/retryablehttp-go), [github.com/projectdiscovery/wappalyzergo](https://github.com/projectdiscovery/wappalyzergo) and [github.com/projectdiscovery/cdncheck](https://github.com/projectdiscovery/cdncheck).


Updates `github.com/projectdiscovery/retryablehttp-go` from 1.3.18 to 1.3.19
- [Release notes](https://github.com/projectdiscovery/retryablehttp-go/releases)
- [Commits](https://github.com/projectdiscovery/retryablehttp-go/compare/v1.3.18...v1.3.19)

Updates `github.com/projectdiscovery/wappalyzergo` from 0.2.89 to 0.2.90
- [Release notes](https://github.com/projectdiscovery/wappalyzergo/releases)
- [Commits](https://github.com/projectdiscovery/wappalyzergo/compare/v0.2.89...v0.2.90)

Updates `github.com/projectdiscovery/cdncheck` from 1.2.44 to 1.2.45
- [Release notes](https://github.com/projectdiscovery/cdncheck/releases)
- [Commits](https://github.com/projectdiscovery/cdncheck/compare/v1.2.44...v1.2.45)

---
updated-dependencies:
- dependency-name: github.com/projectdiscovery/retryablehttp-go
  dependency-version: 1.3.19
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: modules
- dependency-name: github.com/projectdiscovery/wappalyzergo
  dependency-version: 0.2.90
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: modules
- dependency-name: github.com/projectdiscovery/cdncheck
  dependency-version: 1.2.45
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: modules
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-20 14:01:43 +07:00
dependabot[bot] a360efc658 chore(deps): bump github.com/go-jose/go-jose/v4 (#7555)
Bumps the go_modules group with 1 update in the / directory: [github.com/go-jose/go-jose/v4](https://github.com/go-jose/go-jose).


Updates `github.com/go-jose/go-jose/v4` from 4.1.3 to 4.1.4
- [Release notes](https://github.com/go-jose/go-jose/releases)
- [Commits](https://github.com/go-jose/go-jose/compare/v4.1.3...v4.1.4)

---
updated-dependencies:
- dependency-name: github.com/go-jose/go-jose/v4
  dependency-version: 4.1.4
  dependency-type: indirect
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-20 06:47:38 +00:00
dependabot[bot] 435ae4c042 chore(deps): bump crate-ci/typos (#7529)
Bumps the workflows group with 1 update in the / directory: [crate-ci/typos](https://github.com/crate-ci/typos).


Updates `crate-ci/typos` from 1.47.2 to 1.48.0
- [Release notes](https://github.com/crate-ci/typos/releases)
- [Changelog](https://github.com/crate-ci/typos/blob/master/CHANGELOG.md)
- [Commits](https://github.com/crate-ci/typos/compare/v1.47.2...v1.48.0)

---
updated-dependencies:
- dependency-name: crate-ci/typos
  dependency-version: 1.48.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: workflows
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-20 06:18:35 +00:00
Zain Nadeem 0888e6244d fix(input): guard raw request body newline trimming (#7525) 2026-07-18 05:36:23 +07:00
james bobandjames-yusuke c928dcc652 fix(fuzz): preserve form parameters with shared prefixes (#7515)
Co-authored-by: james-yusuke <[email protected]>
2026-07-17 01:13:08 +07:00
Arpit JainandMzack9999 8171606055 Guard against a negative regex extractor group to avoid an index-out-of-range panic (#7531)
* Guard against a negative regex extractor group to avoid an index-out-of-range panic

A community template with group: -1 crashes the scan since the protocol path
has no recover. ExtractRegex indexes match[e.RegexGroup], and the len(match) <
groupPlusOne guard never catches a negative group, so match[-1] panics with
index out of range [-1]. A negative group is meaningless, so return no results
early instead.

Signed-off-by: Arpit Jain <[email protected]>

* Harden extractors

---------

Signed-off-by: Arpit Jain <[email protected]>
Co-authored-by: Mzack9999 <[email protected]>
2026-07-16 12:51:13 +04:00
Dwi Siswanto a1291fa1ab feat(headless): render element locators before lookup (#7549)
Render locator arguments thru the action renderer
for every element lookup mode (selector, regex,
XPath, JavaScript, and search locators). Keep
rendered values local and reuse selectors instead
of rendering them twice.

Render `waitvisible` locators on the owning page
so Interactsh URLs remain attached to the request.
Use a local URL source in tests to avoid network
access.

Closes #7542

Signed-off-by: Dwi Siswanto <[email protected]>
2026-07-16 11:18:48 +04:00
Dwi Siswanto bcd580703d fix(signer): avoid deprecated ECDSA coordinate access (#7537)
Build the signer fragment from `PublicKey.Bytes()`
instead of reading the raw ECDSA X coordinate.

Keep the existing fragment format by hashing the
trimmed X-coordinate bytes, and propagate key
encoding errors thru signing & verification.

Signed-off-by: Dwi Siswanto <[email protected]>
2026-07-15 18:21:22 +04:00
dependabot[bot] 9c47e6c122 chore(deps): bump the go_modules group across 1 directory with 2 updates (#7533)
Bumps the go_modules group with 1 update in the / directory: [google.golang.org/grpc](https://github.com/grpc/grpc-go).


Updates `google.golang.org/grpc` from 1.72.2 to 1.79.3
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.72.2...v1.79.3)

Updates `github.com/go-jose/go-jose/v4` from 4.0.5 to 4.1.3
- [Release notes](https://github.com/go-jose/go-jose/releases)
- [Commits](https://github.com/go-jose/go-jose/compare/v4.0.5...v4.1.3)

---
updated-dependencies:
- dependency-name: google.golang.org/grpc
  dependency-version: 1.79.3
  dependency-type: direct:production
  dependency-group: go_modules
- dependency-name: github.com/go-jose/go-jose/v4
  dependency-version: 4.1.3
  dependency-type: indirect
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-13 06:53:15 +00:00
dependabot[bot] 23e7624f04 chore(deps): bump the modules group with 5 updates (#7532)
Bumps the modules group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [github.com/projectdiscovery/fastdialer](https://github.com/projectdiscovery/fastdialer) | `0.5.12` | `0.5.13` |
| [github.com/projectdiscovery/retryablehttp-go](https://github.com/projectdiscovery/retryablehttp-go) | `1.3.17` | `1.3.18` |
| [github.com/projectdiscovery/httpx](https://github.com/projectdiscovery/httpx) | `1.9.0` | `1.10.0` |
| [github.com/projectdiscovery/wappalyzergo](https://github.com/projectdiscovery/wappalyzergo) | `0.2.88` | `0.2.89` |
| [github.com/projectdiscovery/cdncheck](https://github.com/projectdiscovery/cdncheck) | `1.2.43` | `1.2.44` |


Updates `github.com/projectdiscovery/fastdialer` from 0.5.12 to 0.5.13
- [Release notes](https://github.com/projectdiscovery/fastdialer/releases)
- [Commits](https://github.com/projectdiscovery/fastdialer/compare/v0.5.12...v0.5.13)

Updates `github.com/projectdiscovery/retryablehttp-go` from 1.3.17 to 1.3.18
- [Release notes](https://github.com/projectdiscovery/retryablehttp-go/releases)
- [Commits](https://github.com/projectdiscovery/retryablehttp-go/compare/v1.3.17...v1.3.18)

Updates `github.com/projectdiscovery/httpx` from 1.9.0 to 1.10.0
- [Release notes](https://github.com/projectdiscovery/httpx/releases)
- [Commits](https://github.com/projectdiscovery/httpx/compare/v1.9.0...v1.10.0)

Updates `github.com/projectdiscovery/wappalyzergo` from 0.2.88 to 0.2.89
- [Release notes](https://github.com/projectdiscovery/wappalyzergo/releases)
- [Commits](https://github.com/projectdiscovery/wappalyzergo/compare/v0.2.88...v0.2.89)

Updates `github.com/projectdiscovery/cdncheck` from 1.2.43 to 1.2.44
- [Release notes](https://github.com/projectdiscovery/cdncheck/releases)
- [Commits](https://github.com/projectdiscovery/cdncheck/compare/v1.2.43...v1.2.44)

---
updated-dependencies:
- dependency-name: github.com/projectdiscovery/fastdialer
  dependency-version: 0.5.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: modules
- dependency-name: github.com/projectdiscovery/retryablehttp-go
  dependency-version: 1.3.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: modules
- dependency-name: github.com/projectdiscovery/httpx
  dependency-version: 1.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: modules
- dependency-name: github.com/projectdiscovery/wappalyzergo
  dependency-version: 0.2.89
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: modules
- dependency-name: github.com/projectdiscovery/cdncheck
  dependency-version: 1.2.44
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: modules
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-13 06:21:42 +00:00
Mzack9999 85723be139 gRPC JavaScript library (#7527)
* grpc library

* stabilize test
2026-07-08 01:55:14 +07:00
Sandeep Singh 57db383502 Merge pull request #7522 from projectdiscovery/dev
v3.11.0 (security hardening for javascript protocol)
2026-07-06 11:41:09 +05:30
Dogan Can Bakir f9cf234c12 Merge pull request #7474 from projectdiscovery/dev
release nuclei v3.10.0
2026-06-30 14:12:28 +03:00
Dogan Can Bakir ffbb05b254 Merge pull request #7444 from projectdiscovery/dev
release nuclei v3.9.0
2026-06-10 12:59:08 +03:00
Sandeep Singh aa52d85991 Merge pull request #7344 from projectdiscovery/dev
v3.8.0 (prep)
2026-04-17 01:04:38 +05:30
Dogan Can Bakir d7dea30cd5 Merge pull request #7034 from projectdiscovery/dev
v3.7.1
2026-03-05 13:52:27 +03:00
Dogan Can Bakir 4a8b83f977 Merge pull request #6783 from projectdiscovery/dev
release nuclei v3.7.0
2026-01-28 16:45:46 +07:00
277 changed files with 12495 additions and 12167 deletions

No files matched your search

+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
issues: write
pull-requests: write
steps:
- uses: actions/stale@v10
- uses: actions/stale@v11
with:
days-before-stale: 90
days-before-close: 7
+1 -1
View File
@@ -17,4 +17,4 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: crate-ci/[email protected]7.2
- uses: crate-ci/[email protected]8.0
+6
View File
@@ -140,6 +140,7 @@ Flags:
TARGET:
-u, -target string[] target URLs/hosts to scan
-l, -list string path to file containing a list of target URLs/hosts to scan (one per line)
-targets-inline string inline multiline target list (for use in template profiles)
-eh, -exclude-hosts string[] hosts to exclude to scan from the input list (ip, cidr, hostname)
-resume string resume scan from and save to specified file (clustering will be disabled)
-sa, -scan-all-ips scan all the IP's associated with dns record
@@ -149,6 +150,8 @@ TARGET-FORMAT:
-im, -input-mode string mode of input file (list, burp, jsonl, yaml, openapi, swagger) (default "list")
-ro, -required-only use only required fields in input format when generating requests
-sfv, -skip-format-validation skip format validation (like missing vars) when parsing input file
-vtt, -vars-text-templating enable text templating for vars in input file (only for yaml input mode)
-vfp, -var-file-paths string[] list of yaml file contained vars to inject into yaml input
TEMPLATES:
-nt, -new-templates run only new templates added in latest nuclei-templates release
@@ -205,6 +208,7 @@ OUTPUT:
-se, -sarif-export string file to export results in SARIF format
-je, -json-export string file to export results in JSON format
-jle, -jsonl-export string file to export results in JSONL(ine) format
-pe, -pdf-export string file to export results in PDF format
-rd, -redact string[] redact given list of keys from query parameter, request header and body
CONFIGURATIONS:
@@ -277,6 +281,7 @@ UNCOVER:
RATE-LIMIT:
-rl, -rate-limit int maximum number of requests to send per second (default 150)
-rld, -rate-limit-duration value maximum number of requests to send per second (default 1s)
-per-host-rate-limit enable per-host rate limiting (global rate limit becomes unlimited when enabled)
-rlm, -rate-limit-minute int maximum number of requests to send per minute (DEPRECATED)
-bs, -bulk-size int maximum number of hosts to be analyzed in parallel per template (default 25)
-c, -concurrency int maximum number of templates to be executed in parallel (default 25)
@@ -301,6 +306,7 @@ OPTIMIZATIONS:
-ss, -scan-strategy value strategy to use while scanning(auto/host-spray/template-spray) (default auto)
-irt, -input-read-timeout value timeout on input read (default 3m0s)
-nh, -no-httpx disable httpx probing for non-url input
-preflight-portscan run preflight resolve + TCP portscan and filter targets before scanning (disabled by default)
-no-stdin disable stdin processing
HEADLESS:
+6
View File
@@ -140,6 +140,7 @@ Flags:
TARGET:
-u, -target string[] target URLs/hosts to scan
-l, -list string path to file containing a list of target URLs/hosts to scan (one per line)
-targets-inline string inline multiline target list (for use in template profiles)
-eh, -exclude-hosts string[] hosts to exclude to scan from the input list (ip, cidr, hostname)
-resume string resume scan from and save to specified file (clustering will be disabled)
-sa, -scan-all-ips scan all the IP's associated with dns record
@@ -149,6 +150,8 @@ TARGET-FORMAT:
-im, -input-mode string mode of input file (list, burp, jsonl, yaml, openapi, swagger) (default "list")
-ro, -required-only use only required fields in input format when generating requests
-sfv, -skip-format-validation skip format validation (like missing vars) when parsing input file
-vtt, -vars-text-templating enable text templating for vars in input file (only for yaml input mode)
-vfp, -var-file-paths string[] list of yaml file contained vars to inject into yaml input
TEMPLATES:
-nt, -new-templates run only new templates added in latest nuclei-templates release
@@ -205,6 +208,7 @@ OUTPUT:
-se, -sarif-export string file to export results in SARIF format
-je, -json-export string file to export results in JSON format
-jle, -jsonl-export string file to export results in JSONL(ine) format
-pe, -pdf-export string file to export results in PDF format
-rd, -redact string[] redact given list of keys from query parameter, request header and body
CONFIGURATIONS:
@@ -277,6 +281,7 @@ UNCOVER:
RATE-LIMIT:
-rl, -rate-limit int maximum number of requests to send per second (default 150)
-rld, -rate-limit-duration value maximum number of requests to send per second (default 1s)
-per-host-rate-limit enable per-host rate limiting (global rate limit becomes unlimited when enabled)
-rlm, -rate-limit-minute int maximum number of requests to send per minute (DEPRECATED)
-bs, -bulk-size int maximum number of hosts to be analyzed in parallel per template (default 25)
-c, -concurrency int maximum number of templates to be executed in parallel (default 25)
@@ -301,6 +306,7 @@ OPTIMIZATIONS:
-ss, -scan-strategy value strategy to use while scanning(auto/host-spray/template-spray) (default auto)
-irt, -input-read-timeout value timeout on input read (default 3m0s)
-nh, -no-httpx disable httpx probing for non-url input
-preflight-portscan run preflight resolve + TCP portscan and filter targets before scanning (disabled by default)
-no-stdin disable stdin processing
HEADLESS:
+6
View File
@@ -140,6 +140,7 @@ Flags:
TARGET:
-u, -target string[] target URLs/hosts to scan
-l, -list string path to file containing a list of target URLs/hosts to scan (one per line)
-targets-inline string inline multiline target list (for use in template profiles)
-eh, -exclude-hosts string[] hosts to exclude to scan from the input list (ip, cidr, hostname)
-resume string resume scan from and save to specified file (clustering will be disabled)
-sa, -scan-all-ips scan all the IP's associated with dns record
@@ -149,6 +150,8 @@ TARGET-FORMAT:
-im, -input-mode string mode of input file (list, burp, jsonl, yaml, openapi, swagger) (default "list")
-ro, -required-only use only required fields in input format when generating requests
-sfv, -skip-format-validation skip format validation (like missing vars) when parsing input file
-vtt, -vars-text-templating enable text templating for vars in input file (only for yaml input mode)
-vfp, -var-file-paths string[] list of yaml file contained vars to inject into yaml input
TEMPLATES:
-nt, -new-templates run only new templates added in latest nuclei-templates release
@@ -205,6 +208,7 @@ OUTPUT:
-se, -sarif-export string file to export results in SARIF format
-je, -json-export string file to export results in JSON format
-jle, -jsonl-export string file to export results in JSONL(ine) format
-pe, -pdf-export string file to export results in PDF format
-rd, -redact string[] redact given list of keys from query parameter, request header and body
CONFIGURATIONS:
@@ -277,6 +281,7 @@ UNCOVER:
RATE-LIMIT:
-rl, -rate-limit int maximum number of requests to send per second (default 150)
-rld, -rate-limit-duration value maximum number of requests to send per second (default 1s)
-per-host-rate-limit enable per-host rate limiting (global rate limit becomes unlimited when enabled)
-rlm, -rate-limit-minute int maximum number of requests to send per minute (DEPRECATED)
-bs, -bulk-size int maximum number of hosts to be analyzed in parallel per template (default 25)
-c, -concurrency int maximum number of templates to be executed in parallel (default 25)
@@ -301,6 +306,7 @@ OPTIMIZATIONS:
-ss, -scan-strategy value strategy to use while scanning(auto/host-spray/template-spray) (default auto)
-irt, -input-read-timeout value timeout on input read (default 3m0s)
-nh, -no-httpx disable httpx probing for non-url input
-preflight-portscan run preflight resolve + TCP portscan and filter targets before scanning (disabled by default)
-no-stdin disable stdin processing
HEADLESS:
+6
View File
@@ -140,6 +140,7 @@ Flags:
TARGET:
-u, -target string[] target URLs/hosts to scan
-l, -list string path to file containing a list of target URLs/hosts to scan (one per line)
-targets-inline string inline multiline target list (for use in template profiles)
-eh, -exclude-hosts string[] hosts to exclude to scan from the input list (ip, cidr, hostname)
-resume string resume scan from and save to specified file (clustering will be disabled)
-sa, -scan-all-ips scan all the IP's associated with dns record
@@ -149,6 +150,8 @@ TARGET-FORMAT:
-im, -input-mode string mode of input file (list, burp, jsonl, yaml, openapi, swagger) (default "list")
-ro, -required-only use only required fields in input format when generating requests
-sfv, -skip-format-validation skip format validation (like missing vars) when parsing input file
-vtt, -vars-text-templating enable text templating for vars in input file (only for yaml input mode)
-vfp, -var-file-paths string[] list of yaml file contained vars to inject into yaml input
TEMPLATES:
-nt, -new-templates run only new templates added in latest nuclei-templates release
@@ -205,6 +208,7 @@ OUTPUT:
-se, -sarif-export string file to export results in SARIF format
-je, -json-export string file to export results in JSON format
-jle, -jsonl-export string file to export results in JSONL(ine) format
-pe, -pdf-export string file to export results in PDF format
-rd, -redact string[] redact given list of keys from query parameter, request header and body
CONFIGURATIONS:
@@ -277,6 +281,7 @@ UNCOVER:
RATE-LIMIT:
-rl, -rate-limit int maximum number of requests to send per second (default 150)
-rld, -rate-limit-duration value maximum number of requests to send per second (default 1s)
-per-host-rate-limit enable per-host rate limiting (global rate limit becomes unlimited when enabled)
-rlm, -rate-limit-minute int maximum number of requests to send per minute (DEPRECATED)
-bs, -bulk-size int maximum number of hosts to be analyzed in parallel per template (default 25)
-c, -concurrency int maximum number of templates to be executed in parallel (default 25)
@@ -301,6 +306,7 @@ OPTIMIZATIONS:
-ss, -scan-strategy value strategy to use while scanning(auto/host-spray/template-spray) (default auto)
-irt, -input-read-timeout value timeout on input read (default 3m0s)
-nh, -no-httpx disable httpx probing for non-url input
-preflight-portscan run preflight resolve + TCP portscan and filter targets before scanning (disabled by default)
-no-stdin disable stdin processing
HEADLESS:
+6
View File
@@ -140,6 +140,7 @@ Flags:
TARGET:
-u, -target string[] target URLs/hosts to scan
-l, -list string path to file containing a list of target URLs/hosts to scan (one per line)
-targets-inline string inline multiline target list (for use in template profiles)
-eh, -exclude-hosts string[] hosts to exclude to scan from the input list (ip, cidr, hostname)
-resume string resume scan from and save to specified file (clustering will be disabled)
-sa, -scan-all-ips scan all the IP's associated with dns record
@@ -149,6 +150,8 @@ TARGET-FORMAT:
-im, -input-mode string mode of input file (list, burp, jsonl, yaml, openapi, swagger) (default "list")
-ro, -required-only use only required fields in input format when generating requests
-sfv, -skip-format-validation skip format validation (like missing vars) when parsing input file
-vtt, -vars-text-templating enable text templating for vars in input file (only for yaml input mode)
-vfp, -var-file-paths string[] list of yaml file contained vars to inject into yaml input
TEMPLATES:
-nt, -new-templates run only new templates added in latest nuclei-templates release
@@ -205,6 +208,7 @@ OUTPUT:
-se, -sarif-export string file to export results in SARIF format
-je, -json-export string file to export results in JSON format
-jle, -jsonl-export string file to export results in JSONL(ine) format
-pe, -pdf-export string file to export results in PDF format
-rd, -redact string[] redact given list of keys from query parameter, request header and body
CONFIGURATIONS:
@@ -277,6 +281,7 @@ UNCOVER:
RATE-LIMIT:
-rl, -rate-limit int maximum number of requests to send per second (default 150)
-rld, -rate-limit-duration value maximum number of requests to send per second (default 1s)
-per-host-rate-limit enable per-host rate limiting (global rate limit becomes unlimited when enabled)
-rlm, -rate-limit-minute int maximum number of requests to send per minute (DEPRECATED)
-bs, -bulk-size int maximum number of hosts to be analyzed in parallel per template (default 25)
-c, -concurrency int maximum number of templates to be executed in parallel (default 25)
@@ -301,6 +306,7 @@ OPTIMIZATIONS:
-ss, -scan-strategy value strategy to use while scanning(auto/host-spray/template-spray) (default auto)
-irt, -input-read-timeout value timeout on input read (default 3m0s)
-nh, -no-httpx disable httpx probing for non-url input
-preflight-portscan run preflight resolve + TCP portscan and filter targets before scanning (disabled by default)
-no-stdin disable stdin processing
HEADLESS:
+6
View File
@@ -140,6 +140,7 @@ Flags:
TARGET:
-u, -target string[] target URLs/hosts to scan
-l, -list string path to file containing a list of target URLs/hosts to scan (one per line)
-targets-inline string inline multiline target list (for use in template profiles)
-eh, -exclude-hosts string[] hosts to exclude to scan from the input list (ip, cidr, hostname)
-resume string resume scan from and save to specified file (clustering will be disabled)
-sa, -scan-all-ips scan all the IP's associated with dns record
@@ -149,6 +150,8 @@ TARGET-FORMAT:
-im, -input-mode string mode of input file (list, burp, jsonl, yaml, openapi, swagger) (default "list")
-ro, -required-only use only required fields in input format when generating requests
-sfv, -skip-format-validation skip format validation (like missing vars) when parsing input file
-vtt, -vars-text-templating enable text templating for vars in input file (only for yaml input mode)
-vfp, -var-file-paths string[] list of yaml file contained vars to inject into yaml input
TEMPLATES:
-nt, -new-templates run only new templates added in latest nuclei-templates release
@@ -205,6 +208,7 @@ OUTPUT:
-se, -sarif-export string file to export results in SARIF format
-je, -json-export string file to export results in JSON format
-jle, -jsonl-export string file to export results in JSONL(ine) format
-pe, -pdf-export string file to export results in PDF format
-rd, -redact string[] redact given list of keys from query parameter, request header and body
CONFIGURATIONS:
@@ -277,6 +281,7 @@ UNCOVER:
RATE-LIMIT:
-rl, -rate-limit int maximum number of requests to send per second (default 150)
-rld, -rate-limit-duration value maximum number of requests to send per second (default 1s)
-per-host-rate-limit enable per-host rate limiting (global rate limit becomes unlimited when enabled)
-rlm, -rate-limit-minute int maximum number of requests to send per minute (DEPRECATED)
-bs, -bulk-size int maximum number of hosts to be analyzed in parallel per template (default 25)
-c, -concurrency int maximum number of templates to be executed in parallel (default 25)
@@ -301,6 +306,7 @@ OPTIMIZATIONS:
-ss, -scan-strategy value strategy to use while scanning(auto/host-spray/template-spray) (default auto)
-irt, -input-read-timeout value timeout on input read (default 3m0s)
-nh, -no-httpx disable httpx probing for non-url input
-preflight-portscan run preflight resolve + TCP portscan and filter targets before scanning (disabled by default)
-no-stdin disable stdin processing
HEADLESS:
+6
View File
@@ -140,6 +140,7 @@ Flags:
TARGET:
-u, -target string[] target URLs/hosts to scan
-l, -list string path to file containing a list of target URLs/hosts to scan (one per line)
-targets-inline string inline multiline target list (for use in template profiles)
-eh, -exclude-hosts string[] hosts to exclude to scan from the input list (ip, cidr, hostname)
-resume string resume scan from and save to specified file (clustering will be disabled)
-sa, -scan-all-ips scan all the IP's associated with dns record
@@ -149,6 +150,8 @@ TARGET-FORMAT:
-im, -input-mode string mode of input file (list, burp, jsonl, yaml, openapi, swagger) (default "list")
-ro, -required-only use only required fields in input format when generating requests
-sfv, -skip-format-validation skip format validation (like missing vars) when parsing input file
-vtt, -vars-text-templating enable text templating for vars in input file (only for yaml input mode)
-vfp, -var-file-paths string[] list of yaml file contained vars to inject into yaml input
TEMPLATES:
-nt, -new-templates run only new templates added in latest nuclei-templates release
@@ -205,6 +208,7 @@ OUTPUT:
-se, -sarif-export string file to export results in SARIF format
-je, -json-export string file to export results in JSON format
-jle, -jsonl-export string file to export results in JSONL(ine) format
-pe, -pdf-export string file to export results in PDF format
-rd, -redact string[] redact given list of keys from query parameter, request header and body
CONFIGURATIONS:
@@ -277,6 +281,7 @@ UNCOVER:
RATE-LIMIT:
-rl, -rate-limit int maximum number of requests to send per second (default 150)
-rld, -rate-limit-duration value maximum number of requests to send per second (default 1s)
-per-host-rate-limit enable per-host rate limiting (global rate limit becomes unlimited when enabled)
-rlm, -rate-limit-minute int maximum number of requests to send per minute (DEPRECATED)
-bs, -bulk-size int maximum number of hosts to be analyzed in parallel per template (default 25)
-c, -concurrency int maximum number of templates to be executed in parallel (default 25)
@@ -301,6 +306,7 @@ OPTIMIZATIONS:
-ss, -scan-strategy value strategy to use while scanning(auto/host-spray/template-spray) (default auto)
-irt, -input-read-timeout value timeout on input read (default 3m0s)
-nh, -no-httpx disable httpx probing for non-url input
-preflight-portscan run preflight resolve + TCP portscan and filter targets before scanning (disabled by default)
-no-stdin disable stdin processing
HEADLESS:
+6
View File
@@ -140,6 +140,7 @@ Flags:
TARGET:
-u, -target string[] target URLs/hosts to scan
-l, -list string path to file containing a list of target URLs/hosts to scan (one per line)
-targets-inline string inline multiline target list (for use in template profiles)
-eh, -exclude-hosts string[] hosts to exclude to scan from the input list (ip, cidr, hostname)
-resume string resume scan from and save to specified file (clustering will be disabled)
-sa, -scan-all-ips scan all the IP's associated with dns record
@@ -149,6 +150,8 @@ TARGET-FORMAT:
-im, -input-mode string mode of input file (list, burp, jsonl, yaml, openapi, swagger) (default "list")
-ro, -required-only use only required fields in input format when generating requests
-sfv, -skip-format-validation skip format validation (like missing vars) when parsing input file
-vtt, -vars-text-templating enable text templating for vars in input file (only for yaml input mode)
-vfp, -var-file-paths string[] list of yaml file contained vars to inject into yaml input
TEMPLATES:
-nt, -new-templates run only new templates added in latest nuclei-templates release
@@ -205,6 +208,7 @@ OUTPUT:
-se, -sarif-export string file to export results in SARIF format
-je, -json-export string file to export results in JSON format
-jle, -jsonl-export string file to export results in JSONL(ine) format
-pe, -pdf-export string file to export results in PDF format
-rd, -redact string[] redact given list of keys from query parameter, request header and body
CONFIGURATIONS:
@@ -277,6 +281,7 @@ UNCOVER:
RATE-LIMIT:
-rl, -rate-limit int maximum number of requests to send per second (default 150)
-rld, -rate-limit-duration value maximum number of requests to send per second (default 1s)
-per-host-rate-limit enable per-host rate limiting (global rate limit becomes unlimited when enabled)
-rlm, -rate-limit-minute int maximum number of requests to send per minute (DEPRECATED)
-bs, -bulk-size int maximum number of hosts to be analyzed in parallel per template (default 25)
-c, -concurrency int maximum number of templates to be executed in parallel (default 25)
@@ -301,6 +306,7 @@ OPTIMIZATIONS:
-ss, -scan-strategy value strategy to use while scanning(auto/host-spray/template-spray) (default auto)
-irt, -input-read-timeout value timeout on input read (default 3m0s)
-nh, -no-httpx disable httpx probing for non-url input
-preflight-portscan run preflight resolve + TCP portscan and filter targets before scanning (disabled by default)
-no-stdin disable stdin processing
HEADLESS:
+117
View File
@@ -1009,6 +1009,26 @@ Part Definitions:
- <code>all</code> - HTTP response body + headers
- <code>cookies_from_response</code> - HTTP response cookies in name:value format
- <code>headers_from_response</code> - HTTP response headers in name:value format
- <code>tls_version</code> - TLS version negotiated for the HTTP connection
- <code>cipher</code> - TLS cipher suite negotiated for the HTTP connection
- <code>sni</code> - SNI value used in the TLS handshake
- <code>subject_cn</code> - Leaf certificate subject common name
- <code>subject_dn</code> - Leaf certificate subject distinguished name
- <code>subject_an</code> - Leaf certificate subject alternative names
- <code>subject_org</code> - Leaf certificate subject organization
- <code>issuer_cn</code> - Leaf certificate issuer common name
- <code>issuer_dn</code> - Leaf certificate issuer distinguished name
- <code>issuer_org</code> - Leaf certificate issuer organization
- <code>serial</code> - Leaf certificate serial number
- <code>fingerprint_hash</code> - Leaf certificate fingerprint hashes (md5/sha1/sha256)
- <code>not_before</code> - Leaf certificate not-before timestamp
- <code>not_after</code> - Leaf certificate not-after timestamp
- <code>expired</code> - Whether the leaf certificate has expired
- <code>self_signed</code> - Whether the leaf certificate is self-signed
- <code>mismatched</code> - Whether the leaf certificate does not match the SNI hostname
- <code>domains</code> - Deduplicated domains from subject CN and SANs
- <code>wildcard_certificate</code> - Whether the leaf certificate is a wildcard certificate
- <code>emails</code> - Email addresses embedded in the leaf certificate
<hr />
@@ -2606,6 +2626,7 @@ Part Definitions:
- <code>host</code> - Host is the input to the template
- <code>matched</code> - Matched is the input which was matched upon
- <code>request</code> - Request contains the DNS request in text format
- <code>duration</code> - Protocol operation duration in seconds
- <code>type</code> - Type is the type of request made
- <code>rcode</code> - Rcode field returned for the DNS request
- <code>question</code> - Question contains the DNS question field
@@ -2898,6 +2919,16 @@ Enum Values:
- <code>ANY</code>
- <code>SRV</code>
- <code>RRSIG</code>
- <code>NSEC</code>
- <code>DNSKEY</code>
- <code>NSEC3</code>
- <code>NSEC3PARAM</code>
</div>
<hr />
@@ -3071,6 +3102,82 @@ NoRecursive specifies whether to not do recursive checks if folders are provided
<hr />
<div class="dd">
<code>smb-user</code> <i>string</i>
</div>
<div class="dt">
SMBUser authenticates to remote SMB shares when the file input is a UNC
or smb:// path (issue #6142). Guest/anon: empty password.
Examples:
```yaml
smb-user: auditor
```
</div>
<hr />
<div class="dd">
<code>smb-password</code> <i>string</i>
</div>
<div class="dt">
SMBPassword is the password for SMB file targets.
</div>
<hr />
<div class="dd">
<code>smb-domain</code> <i>string</i>
</div>
<div class="dt">
SMBDomain is the optional NTLM domain / workgroup.
</div>
<hr />
<div class="dd">
<code>smb-hash</code> <i>string</i>
</div>
<div class="dt">
SMBHash enables pass-the-hash (overrides smb-password when set).
</div>
<hr />
<div class="dd">
<code>smb-port</code> <i>int</i>
</div>
<div class="dt">
SMBPort overrides the default SMB port (445) for UNC targets.
</div>
<hr />
@@ -3109,6 +3216,7 @@ Part Definitions:
- <code>matched</code> - Matched is the input which was matched upon
- <code>type</code> - Type is the type of request made
- <code>request</code> - Network request made from the client
- <code>duration</code> - Latest measured operation duration in seconds
- <code>body,all,data</code> - Network response received from server (default)
- <code>raw</code> - Full Network protocol data
@@ -3501,6 +3609,7 @@ Part Definitions:
- <code>matched</code> - Matched is the input which was matched upon
- <code>type</code> - Type is the type of request made
- <code>req</code> - Headless request made from the client
- <code>duration</code> - Latest measured operation duration in seconds
- <code>resp,body,data</code> - Headless response received from client (default)
<hr />
@@ -3881,6 +3990,7 @@ Part Definitions:
- <code>matched</code> - Matched is the input which was matched upon
- <code>type</code> - Type is the type of request made
- <code>timestamp</code> - Timestamp is the time when the request was made
- <code>duration</code> - Protocol operation duration in seconds
- <code>response</code> - JSON SSL protocol handshake details
- <code>cipher</code> - Cipher is the encryption algorithm used
- <code>domains</code> - Domains are the list of domain names in the certificate
@@ -4080,6 +4190,7 @@ Part Definitions:
- <code>type</code> - Type is the type of request made
- <code>success</code> - Success specifies whether websocket connection was successful
- <code>request</code> - Websocket request made to the server
- <code>duration</code> - Latest measured operation duration in seconds
- <code>response</code> - Websocket response received from the server
- <code>host</code> - Host is the input to the template
- <code>matched</code> - Matched is the input which was matched upon
@@ -4254,8 +4365,14 @@ Appears in:
Part Definitions:
- <code>type</code> - Type is the type of request made
- <code>host</code> - Host is the input to the template
- <code>response</code> - WHOIS response data
- <code>duration</code> - Protocol operation duration in seconds
<hr />
<div class="dd">
+1
View File
@@ -25,6 +25,7 @@ extend-exclude = [
[default.extend-identifiers]
# Go identifiers that cannot be renamed without breaking API
MisMatched = "MisMatched"
IsMisMatchedCert = "IsMisMatchedCert" # tlsx clients API
NoopWriter = "NoopWriter"
AllowdTypes = "AllowdTypes"
# Deprecated alias kept for backward compatibility
+4 -13
View File
@@ -260,6 +260,9 @@ func readConfig() *goflags.FlagSet {
flagSet.SetDescription(`Nuclei is a fast, template based vulnerability scanner focusing
on extensive configurability, massive extensibility and ease of use.`)
// Shared PD flags (e.g. -max-time soft-kill via interrupt).
flagSet.AddCommonFlags()
/* TODO Important: The defined default values, especially for slice/array types are NOT DEFAULT VALUES, but rather implicit values to which the user input is appended.
This can be very confusing and should be addressed
*/
@@ -516,16 +519,6 @@ on extensive configurability, massive extensibility and ease of use.`)
flagSet.CreateGroup("Authentication", "Authentication",
flagSet.StringSliceVarP(&options.SecretsFile, "secret-file", "sf", nil, "path to config file containing secrets for nuclei authenticated scan", goflags.CommaSeparatedStringSliceOptions),
flagSet.BoolVarP(&options.PreFetchSecrets, "prefetch-secrets", "ps", false, "prefetch secrets from the secrets file"),
flagSet.StringVarP(&options.AuthLoginURL, "auth-login-url", "alu", "", "login page url for turnkey auto-login authenticated scan"),
flagSet.StringVarP(&options.AuthUsername, "auth-username", "au", "", "username for auto-login (-auth-login-url)"),
flagSet.StringVarP(&options.AuthPassword, "auth-password", "ap", "", "password for auto-login (-auth-login-url)"),
flagSet.StringVarP(&options.AuthUsernameField, "auth-username-field", "auf", "", "override the detected username form field name for auto-login"),
flagSet.StringVarP(&options.AuthPasswordField, "auth-password-field", "apf", "", "override the detected password form field name for auto-login"),
flagSet.BoolVarP(&options.AuthHeadless, "auth-headless", "ah", false, "drive auto-login through a real browser (JS/SPA/SSO login pages)"),
flagSet.StringVarP(&options.AuthRecording, "auth-recording", "ar", "", "path to a chrome devtools recorder json login flow to replay for auto-login"),
flagSet.BoolVarP(&options.AuthCapture, "auth-capture", "ac", false, "open a browser to log in manually once, then capture the session for the scan"),
flagSet.StringVarP(&options.AuthReauthStatusCodes, "auth-reauth-status-codes", "arsc", "", "comma-separated status codes (e.g. 401,403) that trigger an auto-login re-authentication during the scan"),
flagSet.StringVarP(&options.AuthRefreshInterval, "auth-refresh-interval", "ari", "", "re-run the auto-login once the captured session is older than this duration (e.g. 15m)"),
)
flagSet.SetCustomHelpText(`EXAMPLES:
@@ -783,9 +776,7 @@ func disableUpdatesCallback() {
// printVersion prints the nuclei version and exits.
func printVersion() {
options.Logger.Info().Msgf("Nuclei Engine Version: %s", config.Version)
options.Logger.Info().Msgf("Nuclei Config Directory: %s", config.DefaultConfig.GetConfigDir())
options.Logger.Info().Msgf("Nuclei Cache Directory: %s", config.DefaultConfig.GetCacheDir()) // cache dir contains resume files
options.Logger.Info().Msgf("PDCP Directory: %s", pdcp.PDCPDir)
runner.LogDirectoryInfo(options.Logger)
os.Exit(0)
}
+21 -5
View File
@@ -34,10 +34,15 @@ func TestMain(m *testing.M) {
panic(err)
}
dummyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
targetURL = dummyServer.URL
// Only start the shared httptest for benchmarks so unit tests (e.g. goleak)
// are not polluted by a long-lived accept loop.
var dummyServer *httptest.Server
if isBenchmarkRun() {
dummyServer = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
targetURL = dummyServer.URL
}
// Execute tests
@@ -45,13 +50,24 @@ func TestMain(m *testing.M) {
// Tear down
dummyServer.Close()
if dummyServer != nil {
dummyServer.Close()
}
_ = os.RemoveAll(projectPath)
_ = os.Unsetenv("DISABLE_STDOUT")
os.Exit(exitCode)
}
func isBenchmarkRun() bool {
for _, arg := range os.Args[1:] {
if strings.HasPrefix(arg, "-test.bench") {
return true
}
}
return false
}
// getUniqFilename generates a unique filename by appending .N if file exists
// Similar to wget's behavior: file.cpu.prof, file.cpu.1.prof, file.cpu.2.prof, etc.
func getUniqFilename(basePath string) string {
+90
View File
@@ -0,0 +1,90 @@
package main_test
import (
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
"time"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/gologger/levels"
"github.com/projectdiscovery/nuclei/v3/internal/runner"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
"github.com/rs/xid"
"github.com/stretchr/testify/require"
"github.com/tarunKoyalwar/goleak"
)
const cliLeakTestChildEnv = "NUCLEI_CLI_LEAKTEST_CHILD"
var cliKnownLeaks = []goleak.Option{
goleak.Pretty(),
goleak.IgnoreAnyFunction("net/http.(*http2ClientConn).readLoop"),
goleak.IgnoreAnyFunction("net/http.(*persistConn).readLoop"),
goleak.IgnoreAnyFunction("net/http.(*persistConn).writeLoop"),
goleak.IgnoreAnyContainingPkg("github.com/hashicorp/golang-lru/v2/expirable"),
goleak.IgnoreAnyContainingPkg("github.com/syndtr/goleveldb"),
goleak.IgnoreAnyContainingPkg("github.com/projectdiscovery/ratelimit"),
goleak.IgnoreAnyFunction("github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate.StartActiveMemGuardian.func1"),
}
// TestCLIRunnerGoroutineLeak runs a minimal CLI-path nuclei scan via internal/runner
// and asserts no unexpected goroutine leaks remain after Close.
//
// The body runs in a child process so shared package state from other tests
// does not pollute goleak (same approach as lib/tests SDK leak coverage).
func TestCLIRunnerGoroutineLeak(t *testing.T) {
if os.Getenv(cliLeakTestChildEnv) == "1" {
runCLIRunnerLeakTest(t)
return
}
cmd := exec.Command(os.Args[0], "-test.run=^TestCLIRunnerGoroutineLeak$", "-test.count=1", "-test.v")
cmd.Env = append(os.Environ(), cliLeakTestChildEnv+"=1")
out, err := cmd.CombinedOutput()
require.NoError(t, err, "CLI leak test child failed:\n%s", string(out))
}
func runCLIRunnerLeakTest(t *testing.T) {
gologger.DefaultLogger.SetMaxLevel(levels.LevelSilent)
_ = os.Setenv("DISABLE_STDOUT", "true")
t.Cleanup(func() { _ = os.Unsetenv("DISABLE_STDOUT") })
config.DefaultConfig.DisableUpdateCheck()
defer func() {
time.Sleep(2 * time.Second)
goleak.VerifyNone(t, cliKnownLeaks...)
}()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
_, thisFile, _, ok := runtime.Caller(0)
require.True(t, ok)
templatePath := filepath.Join(filepath.Dir(thisFile), "testdata", "leaktest", "basic-http.yaml")
options := getDefaultOptions()
options.Targets = []string{server.URL}
options.Templates = []string{templatePath}
options.NoInteractsh = true
options.DisableStdin = true
options.BulkSize = 1
options.TemplateThreads = 1
options.PayloadConcurrency = 1
options.ExecutionId = xid.New().String()
runner.ParseOptions(options)
nucleiRunner, err := runner.New(options)
require.NoError(t, err)
require.NotNil(t, nucleiRunner)
defer nucleiRunner.Close()
require.NoError(t, nucleiRunner.RunEnumeration())
}
+25
View File
@@ -0,0 +1,25 @@
package main
import (
"testing"
"time"
"github.com/projectdiscovery/goflags"
"github.com/stretchr/testify/require"
)
// Ensures nuclei's goflags dependency still exposes AddCommonFlags / -max-time,
// which is what cmd/nuclei wires via flagSet.AddCommonFlags().
func TestMaxTimeCommonFlagAvailable(t *testing.T) {
flagSet := goflags.NewFlagSet()
common := flagSet.AddCommonFlags()
require.NotNil(t, common)
require.NoError(t, flagSet.Parse("-max-time", "1h30m"))
require.Equal(t, 90*time.Minute, common.MaxTime)
flagSet = goflags.NewFlagSet()
common = flagSet.AddCommonFlags()
require.NoError(t, flagSet.Parse("-mt", "45s"))
require.Equal(t, 45*time.Second, common.MaxTime)
}
+16
View File
@@ -0,0 +1,16 @@
id: leaktest-basic-http
info:
name: CLI Leak Test Basic HTTP
author: pdteam
severity: info
http:
- method: GET
path:
- "{{BaseURL}}/"
matchers:
- type: status
status:
- 204
+49 -40
View File
@@ -1,6 +1,6 @@
module github.com/projectdiscovery/nuclei/v3
go 1.25.7
go 1.26
require (
github.com/andygrunwald/go-jira v1.16.1
@@ -13,16 +13,16 @@ require (
github.com/json-iterator/go v1.1.12 // indirect
github.com/julienschmidt/httprouter v1.3.0
github.com/logrusorgru/aurora v2.0.3+incompatible // indirect
github.com/miekg/dns v1.1.68
github.com/miekg/dns v1.1.72
github.com/olekukonko/tablewriter v1.0.8
github.com/pkg/errors v0.9.1
github.com/projectdiscovery/clistats v0.1.4
github.com/projectdiscovery/fastdialer v0.5.12
github.com/projectdiscovery/fastdialer v0.5.14
github.com/projectdiscovery/hmap v0.0.101
github.com/projectdiscovery/interactsh v1.3.1
github.com/projectdiscovery/rawhttp v0.1.90
github.com/projectdiscovery/rawhttp v0.1.91
github.com/projectdiscovery/retryabledns v1.0.115
github.com/projectdiscovery/retryablehttp-go v1.3.17
github.com/projectdiscovery/retryablehttp-go v1.3.21
github.com/projectdiscovery/yamldoc-go v1.0.6
github.com/remeh/sizedwaitgroup v1.0.0
github.com/rs/xid v1.6.0
@@ -32,9 +32,9 @@ require (
github.com/syndtr/goleveldb v1.0.0
github.com/weppos/publicsuffix-go v0.50.3 // indirect
go.uber.org/multierr v1.11.0
golang.org/x/net v0.55.0
golang.org/x/oauth2 v0.34.0
golang.org/x/text v0.37.0
golang.org/x/net v0.56.0
golang.org/x/oauth2 v0.36.0
golang.org/x/text v0.39.0
)
require (
@@ -65,12 +65,13 @@ require (
github.com/ditashi/jsbeautifier-go v0.0.0-20141206144643-2520a8026a9c
github.com/docker/go-units v0.5.0
github.com/fatih/structs v1.1.0
github.com/getkin/kin-openapi v0.132.0
github.com/fullstorydev/grpcurl v1.9.3
github.com/getkin/kin-openapi v0.144.0
github.com/go-git/go-git/v5 v5.19.1
github.com/go-ldap/ldap/v3 v3.4.12
github.com/go-pdf/fpdf v0.9.0
github.com/go-pg/pg/v10 v10.15.0
github.com/go-sql-driver/mysql v1.9.3
github.com/go-sql-driver/mysql v1.10.0
github.com/goccy/go-json v0.10.5
github.com/google/go-github/v30 v30.1.0
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
@@ -78,9 +79,10 @@ require (
github.com/h2non/filetype v1.1.3
github.com/hashicorp/golang-lru/v2 v2.0.7
github.com/jcmturner/gokrb5/v8 v8.4.4
github.com/jhump/protoreflect v1.17.0
github.com/kitabisa/go-ci v1.0.3
github.com/leslie-qiwa/flat v0.0.0-20230424180412-f9d1cf014baa
github.com/lib/pq v1.11.2
github.com/lib/pq v1.12.3
github.com/logrusorgru/aurora/v4 v4.0.0
github.com/mattn/go-sqlite3 v1.14.28
github.com/maypok86/otter/v2 v2.2.1
@@ -92,18 +94,17 @@ require (
github.com/projectdiscovery/dsl v0.8.20
github.com/projectdiscovery/fasttemplate v0.0.2
github.com/projectdiscovery/gcache v0.0.0-20241015120333-12546c6e3f4c
github.com/projectdiscovery/go-smb2 v0.0.0-20240129202741-052cc450c6cb
github.com/projectdiscovery/goflags v0.1.74
github.com/projectdiscovery/goflags v0.1.75
github.com/projectdiscovery/goja v0.0.0-20260618133720-acb73e419534
github.com/projectdiscovery/goja_nodejs v0.0.0-20260618132410-8519f75f703d
github.com/projectdiscovery/gologger v1.1.71
github.com/projectdiscovery/gostruct v0.0.2
github.com/projectdiscovery/govaluate v0.0.0-20260615100919-5ee2581bbf7e
github.com/projectdiscovery/gozero v0.1.1-0.20260530071156-fa1dad563d76
github.com/projectdiscovery/httpx v1.9.0
github.com/projectdiscovery/httpx v1.10.0
github.com/projectdiscovery/mapcidr v1.1.97
github.com/projectdiscovery/n3iwf v0.0.0-20230523120440-b8cd232ff1f5
github.com/projectdiscovery/networkpolicy v0.1.42
github.com/projectdiscovery/networkpolicy v0.1.44
github.com/projectdiscovery/ratelimit v0.0.88
github.com/projectdiscovery/rdap v0.9.0
github.com/projectdiscovery/sarif v0.1.0
@@ -111,7 +112,7 @@ require (
github.com/projectdiscovery/uncover v1.2.1
github.com/projectdiscovery/useragent v0.0.108
github.com/projectdiscovery/utils v0.11.1
github.com/projectdiscovery/wappalyzergo v0.2.88
github.com/projectdiscovery/wappalyzergo v0.2.91
github.com/redis/go-redis/v9 v9.11.0
github.com/rs/zerolog v1.34.0
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466
@@ -122,8 +123,9 @@ require (
github.com/zmap/zgrab2 v0.1.8
gitlab.com/gitlab-org/api/client-go v1.9.1
go.mongodb.org/mongo-driver v1.17.9
golang.org/x/sync v0.20.0
golang.org/x/term v0.43.0
golang.org/x/sync v0.21.0
golang.org/x/term v0.44.0
google.golang.org/grpc v1.82.1
gopkg.in/yaml.v3 v3.0.1
moul.io/http2curl v1.0.0
)
@@ -131,7 +133,7 @@ require (
require (
aead.dev/minisign v0.3.0 // indirect
dario.cat/mergo v1.0.2 // indirect
filippo.io/edwards25519 v1.1.1 // indirect
filippo.io/edwards25519 v1.2.0 // indirect
git.mills.io/prologic/smtpd v0.0.0-20210710122116-a525b76c287a // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect
@@ -143,7 +145,7 @@ require (
github.com/Mzack9999/go-http-digest-auth-client v0.6.1-0.20220414142836-eb8883508809 // indirect
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect
github.com/ProtonMail/go-crypto v1.1.6 // indirect
github.com/PuerkitoBio/goquery v1.11.0 // indirect
github.com/PuerkitoBio/goquery v1.12.0 // indirect
github.com/STARRY-S/zip v0.2.3 // indirect
github.com/VividCortex/ewma v1.2.0 // indirect
github.com/akrylysov/pogreb v0.10.2 // indirect
@@ -177,6 +179,7 @@ require (
github.com/bodgit/sevenzip v1.6.1 // indirect
github.com/bodgit/windows v1.0.1 // indirect
github.com/brianvoe/gofakeit/v7 v7.2.1 // indirect
github.com/bufbuild/protocompile v0.14.1 // indirect
github.com/buger/jsonparser v1.1.2 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
@@ -195,6 +198,7 @@ require (
github.com/cloudflare/cfssl v1.6.4 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 // indirect
github.com/cnf/structhash v0.0.0-20250313080605-df4c6cc74a9a // indirect
github.com/containerd/continuity v0.4.5 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
@@ -213,13 +217,15 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/envoyproxy/go-control-plane/envoy v1.37.0 // indirect
github.com/envoyproxy/protoc-gen-validate v1.3.3 // indirect
github.com/ericlagergren/decimal v0.0.0-20240411145413-00de7ca16731 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/felixge/fgprof v0.9.5 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/free5gc/util v1.0.5-0.20230511064842-2e120956883b // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gaissmai/bart v0.28.0 // indirect
github.com/gaissmai/bart v0.29.0 // indirect
github.com/geoffgarside/ber v1.2.0 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/gin-gonic/gin v1.9.1 // indirect
@@ -227,11 +233,12 @@ require (
github.com/go-fed/httpsig v1.1.0 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.9.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-openapi/jsonpointer v0.22.5 // indirect
github.com/go-openapi/swag/jsonname v0.25.5 // indirect
github.com/go-pg/zerochecker v0.2.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
@@ -241,6 +248,7 @@ require (
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/certificate-transparency-go v1.3.2 // indirect
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect
@@ -252,7 +260,7 @@ require (
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/hashicorp/go-version v1.8.0 // indirect
github.com/hbakhtiyor/strsim v0.0.0-20190107154042-4d2bbb273edf // indirect
github.com/hdm/jarm-go v0.0.7 // indirect
github.com/hdm/jarm-go v0.0.8 // indirect
github.com/iangcarroll/cookiemonster v1.6.0 // indirect
github.com/imdario/mergo v0.3.16 // indirect
github.com/indece-official/go-ebcdic v1.2.0 // indirect
@@ -264,12 +272,11 @@ require (
github.com/jcmturner/goidentity/v6 v6.0.1 // indirect
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/k14s/starlark-go v0.0.0-20200720175618-3a5c849cc368 // indirect
github.com/kaiakz/ubuffer v0.0.0-20200803053910-dd1083087166 // indirect
github.com/kataras/jwt v0.1.10 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/compress v1.18.7 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
@@ -296,13 +303,12 @@ require (
github.com/moby/term v0.5.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/nwaples/rardecode/v2 v2.2.2 // indirect
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
github.com/oasdiff/yaml v0.1.1 // indirect
github.com/oasdiff/yaml3 v0.0.14 // indirect
github.com/oiweiwei/go-smb2.fork v1.0.0 // indirect
github.com/oiweiwei/gokrb5.fork/v9 v9.0.6 // indirect
github.com/olekukonko/errors v1.1.0 // indirect
@@ -312,19 +318,20 @@ require (
github.com/opencontainers/runc v1.3.6 // indirect
github.com/openrdap/rdap v0.9.1 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pierrec/lz4/v4 v4.1.23 // indirect
github.com/pjbgf/sha1cd v0.6.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/projectdiscovery/asnmap v1.1.1 // indirect
github.com/projectdiscovery/blackrock v0.0.1 // indirect
github.com/projectdiscovery/cdncheck v1.2.43 // indirect
github.com/projectdiscovery/cdncheck v1.2.46 // indirect
github.com/projectdiscovery/freeport v0.0.7 // indirect
github.com/projectdiscovery/ldapserver v1.0.2-0.20240219154113-dcc758ebc0cb // indirect
github.com/projectdiscovery/machineid v0.0.0-20250715113114-c77eb3567582 // indirect
github.com/refraction-networking/utls v1.8.2 // indirect
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect
github.com/sashabaranov/go-openai v1.37.0 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/shirou/gopsutil v3.21.11+incompatible // indirect
@@ -334,6 +341,7 @@ require (
github.com/sorairolake/lzip-go v0.3.8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spiffe/go-spiffe/v2 v2.6.0 // indirect
github.com/stretchr/objx v0.5.3 // indirect
github.com/tidwall/btree v1.8.1 // indirect
github.com/tidwall/buntdb v1.3.2 // indirect
@@ -368,19 +376,20 @@ require (
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
github.com/ysmood/fetchup v0.2.3 // indirect
github.com/ysmood/got v0.40.0 // indirect
github.com/yuin/goldmark v1.7.13 // indirect
github.com/yuin/goldmark v1.7.17 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect
github.com/zcalusic/sysinfo v1.1.3 // indirect
github.com/zeebo/blake3 v0.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.0 // indirect
go.uber.org/zap/exp v0.3.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
go4.org v0.0.0-20230225012048-214862532bf5 // indirect
golang.org/x/arch v0.3.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
mellium.im/sasl v0.3.2 // indirect
software.sslmate.com/src/go-pkcs12 v0.7.2 // indirect
)
@@ -404,13 +413,13 @@ require (
go.etcd.io/bbolt v1.4.3 // indirect
go.uber.org/zap v1.27.0 // indirect
goftp.io/server/v2 v2.0.1 // indirect
golang.org/x/crypto v0.52.0 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/exp v0.0.0-20260527015227-08cc5374adb3
golang.org/x/mod v0.36.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/mod v0.37.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.45.0
google.golang.org/protobuf v1.36.11 // indirect
golang.org/x/tools v0.47.0
google.golang.org/protobuf v1.36.11
gopkg.in/alecthomas/kingpin.v2 v2.2.6 // indirect
gopkg.in/corvus-ch/zbase32.v1 v1.0.0 // indirect
)
+98 -76
View File
@@ -41,8 +41,8 @@ code.gitea.io/sdk/gitea v0.17.0/go.mod h1:ndkDk99BnfiUCCYEUhpNzi0lpmApXlwRFqClBl
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
filippo.io/edwards25519 v1.1.1 h1:YpjwWWlNmGIDyXOn8zLzqiD+9TyIlPhGFG96P39uBpw=
filippo.io/edwards25519 v1.1.1/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
git.mills.io/prologic/smtpd v0.0.0-20210710122116-a525b76c287a h1:3i+FJ7IpSZHL+VAjtpQeZCRhrpP0odl5XfoLBY4fxJ8=
git.mills.io/prologic/smtpd v0.0.0-20210710122116-a525b76c287a/go.mod h1:C7hXLmFmPYPjIDGfQl1clsmQ5TMEQfmzWTrJk475bUs=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 h1:Gt0j3wceWMwPmiazCa8MzMA0MfhmPIz0Qp0FJ6qcM0U=
@@ -97,8 +97,8 @@ github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAE
github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g=
github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
github.com/RedTeamPentesting/adauth v0.5.4-0.20260511073005-3d18e8a5a687 h1:HHkHNwakgAFX3qlMm88c090vIYNFWiO5+4WyAr0xJuM=
github.com/RedTeamPentesting/adauth v0.5.4-0.20260511073005-3d18e8a5a687/go.mod h1:l6FRaC2TliS/JMNWskO3J1tmrcKJyOaMFhWC6hHeWno=
github.com/RumbleDiscovery/rumble-tools v0.0.0-20201105153123-f2adbb3244d2/go.mod h1:jD2+mU+E2SZUuAOHZvZj4xP4frlOo+N/YrXDvASFhkE=
@@ -219,6 +219,8 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw=
github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c=
github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk=
github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
@@ -282,6 +284,8 @@ github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJ
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
github.com/cnf/structhash v0.0.0-20250313080605-df4c6cc74a9a h1:Ohw57yVY2dBTt+gsC6aZdteyxwlxfbtgkFEMTEkwgSw=
github.com/cnf/structhash v0.0.0-20250313080605-df4c6cc74a9a/go.mod h1:pCxVEbcm3AMg7ejXyorUXi6HQCzOIBf7zEDVPtw0/U4=
github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4=
@@ -337,7 +341,11 @@ github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FM
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=
github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
github.com/ericlagergren/decimal v0.0.0-20240411145413-00de7ca16731 h1:R/ZjJpjQKsZ6L/+Gf9WHbt31GG8NMVcpRqUE+1mMIyo=
github.com/ericlagergren/decimal v0.0.0-20240411145413-00de7ca16731/go.mod h1:M9R1FoZ3y//hwwnJtO51ypFGwm8ZfpxPT/ZLtO1mcgQ=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
@@ -357,14 +365,16 @@ github.com/free5gc/util v1.0.5-0.20230511064842-2e120956883b/go.mod h1:l2Jrml4vo
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
github.com/fullstorydev/grpcurl v1.9.3 h1:PC1Xi3w+JAvEE2Tg2Gf2RfVgPbf9+tbuQr1ZkyVU3jk=
github.com/fullstorydev/grpcurl v1.9.3/go.mod h1:/b4Wxe8bG6ndAjlfSUjwseQReUDUvBJiFEB7UllOlUE=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gaissmai/bart v0.28.0 h1:89yZLo8NmyqD0RYgJ3QO9HhqqGGw+oWhf90cZm69Lko=
github.com/gaissmai/bart v0.28.0/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
github.com/gaissmai/bart v0.29.0 h1:wO6HGE8g9YE0Wm0bCpYxwRzfQ4+fbJKOhL64e5ACGCI=
github.com/gaissmai/bart v0.29.0/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
github.com/geoffgarside/ber v1.2.0 h1:/loowoRcs/MWLYmGX9QtIAbA+V/FrnVLsMMPhwiRm64=
github.com/geoffgarside/ber v1.2.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc=
github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk=
github.com/getkin/kin-openapi v0.132.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58=
github.com/getkin/kin-openapi v0.144.0 h1:hIRcTH+KjLfkLpYU6bSSfdFpi0fZi1fp+hSPi4aQu9Y=
github.com/getkin/kin-openapi v0.144.0/go.mod h1:3BH9M9XDe/y9M5DSvEocVYAYq1w0qrhJHjC/vZi0AaY=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
@@ -388,6 +398,8 @@ github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhE
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
@@ -407,10 +419,12 @@ github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA=
github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0=
github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo=
github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
github.com/go-openapi/testify/v2 v2.4.0 h1:8nsPrHVCWkQ4p8h1EsRVymA2XABB4OT40gcvAu+voFM=
github.com/go-openapi/testify/v2 v2.4.0/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54=
github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw=
github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y=
github.com/go-pg/pg/v10 v10.15.0 h1:6DQwbaxJz/e4wvgzbxBkBLiL/Uuk87MGgHhkURtzx24=
@@ -429,11 +443,9 @@ github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA=
github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q=
github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goburrow/cache v0.1.4 h1:As4KzO3hgmzPlnaMniZU9+VmoNYseUhuELbxy9mRBfw=
@@ -492,6 +504,8 @@ github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
@@ -577,8 +591,9 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hbakhtiyor/strsim v0.0.0-20190107154042-4d2bbb273edf h1:umfGUaWdFP2s6457fz1+xXYIWDxdGc7HdkLS9aJ1skk=
github.com/hbakhtiyor/strsim v0.0.0-20190107154042-4d2bbb273edf/go.mod h1:V99KdStnMHZsvVOwIvhfcUzYgYkRZeQWUtumtL+SKxA=
github.com/hdm/jarm-go v0.0.7 h1:Eq0geenHrBSYuKrdVhrBdMMzOmA+CAMLzN2WrF3eL6A=
github.com/hdm/jarm-go v0.0.7/go.mod h1:kinGoS0+Sdn1Rr54OtanET5E5n7AlD6T6CrJAKDjJSQ=
github.com/hdm/jarm-go v0.0.8 h1:8yDIGrmhEJs+QW1voR3JU0Zaz+AUqs3CJzMAQKzGFIc=
github.com/hdm/jarm-go v0.0.8/go.mod h1:memdt/YMMW3811nvANDJsrtc8Fy2e1685aDWdI7Gwto=
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
@@ -613,10 +628,11 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94=
github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jlaffaye/ftp v0.0.0-20190624084859-c1312a7102bf/go.mod h1:lli8NYPQOFy3O++YmYbqVgOcQ1JPCwdOy+5zSjKJ9qY=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
@@ -647,8 +663,8 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o
github.com/kitabisa/go-ci v1.0.3 h1:JmIUIvcercRQc/9x/v02ydCCqU4MadSHaNaOF8T2pGA=
github.com/kitabisa/go-ci v1.0.3/go.mod h1:e3wBSzaJbcifXrr/Gw2ZBLn44MmeqP5WySwXyHlCK/U=
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/compress v1.18.7 h1:aUyZsS4kH3QTKurYhAOwAHxllVPnOthb3vPfnF1Ehjw=
github.com/klauspost/compress v1.18.7/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
@@ -672,8 +688,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/leslie-qiwa/flat v0.0.0-20230424180412-f9d1cf014baa h1:KQKuQDgA3DZX6C396lt3WDYB9Um1gLITLbvficVbqXk=
github.com/leslie-qiwa/flat v0.0.0-20230424180412-f9d1cf014baa/go.mod h1:HbwNE4XGwjgtUELkvQaAOjWrpianHYZdQVNqSdYW3UM=
github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs=
github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/libdns/libdns v1.1.1 h1:wPrHrXILoSHKWJKGd0EiAVmiJbFShguILTg9leS/P/U=
github.com/libdns/libdns v1.1.1/go.mod h1:4Bj9+5CQiNMVGf87wjX4CY3HQJypUHRuLvlsfsZqLWQ=
github.com/logrusorgru/aurora v2.0.3+incompatible h1:tOpm7WcpBTn4fjmVfgpQq0EfczGlG91VSDkswnjF5A8=
@@ -716,8 +732,8 @@ github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB
github.com/microsoft/go-mssqldb v1.9.2 h1:nY8TmFMQOHpm2qVWo6y4I2mAmVdZqlGiMGAYt64Ibbs=
github.com/microsoft/go-mssqldb v1.9.2/go.mod h1:GBbW9ASTiDC+mpgWDGKdm3FnFLTUsLYN3iFL90lQ+PA=
github.com/miekg/dns v1.1.35/go.mod h1:KNUDUusw/aVsxyTYZM1oqvCicbwhgbNgztCETuNZ7xM=
github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA=
github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0=
github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc=
github.com/minio/minio-go/v6 v6.0.46/go.mod h1:qD0lajrGW49lKZLtXKtCB4X/qkMf0a5tBvN2PaZg7Gg=
@@ -745,8 +761,6 @@ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lN
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE=
github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow=
github.com/mreiferson/go-httpclient v0.0.0-20160630210159-31f0106b4474/go.mod h1:OQA4XLvDbMgS8P0CevmM4m9Q3Jq4phKUzcocxuGJ5m8=
@@ -761,10 +775,10 @@ github.com/nwaples/rardecode/v2 v2.2.2 h1:/5oL8dzYivRM/tqX9VcTSWfbpwcbwKG1QtSJr3
github.com/nwaples/rardecode/v2 v2.2.2/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY=
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c=
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o=
github.com/oasdiff/yaml v0.1.1 h1:6nHx+pn9gBRM6YpBlFZFQGCCd1nuvqOBtTD3KKTgGxY=
github.com/oasdiff/yaml v0.1.1/go.mod h1:EYJNoyktvWMJ0Hmhx+6qTaqMOsalUaRGT8Sj1hNcegU=
github.com/oasdiff/yaml3 v0.0.14 h1:aLJee3hxBK2H5wdXd9iPcIXb93Nty1Ge0pT171eHtkw=
github.com/oasdiff/yaml3 v0.0.14/go.mod h1:csto2xfDjYccdUn/yw/bPjj/cYTdp6HtFA0J4TWG+gg=
github.com/oiweiwei/go-msrpc v1.2.12 h1:gaxnv1cyX3v9l+NNxyr4ONyvh/mnmh8Egel9r8zxNxE=
github.com/oiweiwei/go-msrpc v1.2.12/go.mod h1:T6/ENmAoD1nYCr3NW8PS8AjIX0tZEAL7yO0tsejtK18=
github.com/oiweiwei/go-smb2.fork v1.0.0 h1:xHq/eYPM8hQEO/nwCez8YwHWHC8mlcsgw/Neu52fPN4=
@@ -798,8 +812,6 @@ github.com/ory/dockertest/v3 v3.12.0 h1:3oV9d0sDzlSQfHtIaB5k6ghUCVMVLpAY8hwrqoCy
github.com/ory/dockertest/v3 v3.12.0/go.mod h1:aKNDTva3cp8dwOWwb9cWuX84aH5akkxXRvO7KCwWVjE=
github.com/pelletier/go-toml/v2 v2.0.8 h1:0ctb6s9mE31h0/lhu+J6OPmVeDxJn+kYnJc2jZR9tGQ=
github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNcZljzZR9VXg+4=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw=
github.com/pierrec/lz4/v4 v4.1.23 h1:oJE7T90aYBGtFNrI8+KbETnPymobAhzRrR8Mu8n1yfU=
github.com/pierrec/lz4/v4 v4.1.23/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU=
@@ -810,6 +822,8 @@ github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -821,24 +835,22 @@ github.com/projectdiscovery/asnmap v1.1.1 h1:ImJiKIaACOT7HPx4Pabb5dksolzaFYsD1kI
github.com/projectdiscovery/asnmap v1.1.1/go.mod h1:QT7jt9nQanj+Ucjr9BqGr1Q2veCCKSAVyUzLXfEcQ60=
github.com/projectdiscovery/blackrock v0.0.1 h1:lHQqhaaEFjgf5WkuItbpeCZv2DUIE45k0VbGJyft6LQ=
github.com/projectdiscovery/blackrock v0.0.1/go.mod h1:ANUtjDfaVrqB453bzToU+YB4cUbvBRpLvEwoWIwlTss=
github.com/projectdiscovery/cdncheck v1.2.43 h1:Adt5FyMIJy3hT2/X4QKoIcuRxY27ISgEryx8wb8zCRw=
github.com/projectdiscovery/cdncheck v1.2.43/go.mod h1:9oE9KKxCSHNvUf0UaMeqqUwWpC38FkNaTll0ScIBT3w=
github.com/projectdiscovery/cdncheck v1.2.46 h1:ELtTCaT5dLigd0FGmw6z+JuIpbFAr422hbgAFXMmKvY=
github.com/projectdiscovery/cdncheck v1.2.46/go.mod h1:9oE9KKxCSHNvUf0UaMeqqUwWpC38FkNaTll0ScIBT3w=
github.com/projectdiscovery/clistats v0.1.4 h1:kDnXoNxIdOvQElOF7k2Mt6XosGa5GbMKPtRXdPHMVzU=
github.com/projectdiscovery/clistats v0.1.4/go.mod h1:hjJYNcUubk9T3cuFvA+JkLhZGjzYW50fkC48dqUAtbU=
github.com/projectdiscovery/dsl v0.8.20 h1:CxWcKuoHFpOSS1kzqnbJuK5No/6qoRG8IzNDMnZ6c/M=
github.com/projectdiscovery/dsl v0.8.20/go.mod h1:e1oHi7mxAxF+UhBhD5gOk90Ga6LQqvFea2voMO1E5D0=
github.com/projectdiscovery/fastdialer v0.5.12 h1:VQpx/eJEn226doS38VoLTIsxKjXQ67XFngKL3rjhsuQ=
github.com/projectdiscovery/fastdialer v0.5.12/go.mod h1:OYgpbp84/hjp3nVyuKPf/TygvjSo0O9MyNU286/agWU=
github.com/projectdiscovery/fastdialer v0.5.14 h1:83hkWNnbWq8IxDM65h1CCmHf/GoVj1fpwbR+wACiBhA=
github.com/projectdiscovery/fastdialer v0.5.14/go.mod h1:+QaDuVAsvPk4VPSk9aVQCdZzHVbtI9+2BeeFhQUyebo=
github.com/projectdiscovery/fasttemplate v0.0.2 h1:h2cISk5xDhlJEinlBQS6RRx0vOlOirB2y3Yu4PJzpiA=
github.com/projectdiscovery/fasttemplate v0.0.2/go.mod h1:XYWWVMxnItd+r0GbjA1GCsUopMw1/XusuQxdyAIHMCw=
github.com/projectdiscovery/freeport v0.0.7 h1:Q6uXo/j8SaV/GlAHkEYQi8WQoPXyJWxyspx+aFmz9Qk=
github.com/projectdiscovery/freeport v0.0.7/go.mod h1:cOhWKvNBe9xM6dFJ3RrrLvJ5vXx2NQ36SecuwjenV2k=
github.com/projectdiscovery/gcache v0.0.0-20241015120333-12546c6e3f4c h1:s+lLAlrOrgwlPZQ9DFqNw+kia2nteKnJZ2Ek313yoUc=
github.com/projectdiscovery/gcache v0.0.0-20241015120333-12546c6e3f4c/go.mod h1:rN35/D3lVx2YDeENFFz06uj8j3XIqK1Ym9XcISF5fzg=
github.com/projectdiscovery/go-smb2 v0.0.0-20240129202741-052cc450c6cb h1:rutG906Drtbpz4DwU5mhGIeOhRcktDH4cGQitGUMAsg=
github.com/projectdiscovery/go-smb2 v0.0.0-20240129202741-052cc450c6cb/go.mod h1:FLjF1DmZ+POoGEiIQdWuYVwS++C/GwpX8YaCsTSm1RY=
github.com/projectdiscovery/goflags v0.1.74 h1:n85uTRj5qMosm0PFBfsvOL24I7TdWRcWq/1GynhXS7c=
github.com/projectdiscovery/goflags v0.1.74/go.mod h1:UMc9/7dFz2oln+10tv6cy+7WZKTHf9UGhaNkF95emh4=
github.com/projectdiscovery/goflags v0.1.75 h1:njEBnyueQaFa2ptWxbyl9zX0OClNdlN2AzZveNHiBOs=
github.com/projectdiscovery/goflags v0.1.75/go.mod h1:7nAP1r2Dqgn/rwmOE3EWbZWUCEJKNIhVSBGpuzJAIns=
github.com/projectdiscovery/goja v0.0.0-20260618133720-acb73e419534 h1:hYd1zQA/dxO2ASyQ6Re73TcJkW1LjLQvt4+86Hxefz8=
github.com/projectdiscovery/goja v0.0.0-20260618133720-acb73e419534/go.mod h1:SO0AP+uKfYeeoR6jyVH/PKRYJE/f5gJrPMAM00iGEMc=
github.com/projectdiscovery/goja_nodejs v0.0.0-20260618132410-8519f75f703d h1:fqqH9LHpN2WDz9QuxFrhKNxXSRtzk+Sa6jAhbB7tXcQ=
@@ -853,8 +865,8 @@ github.com/projectdiscovery/gozero v0.1.1-0.20260530071156-fa1dad563d76 h1:AN70b
github.com/projectdiscovery/gozero v0.1.1-0.20260530071156-fa1dad563d76/go.mod h1:cWHYnRXoYWHtTpOYyAp5laGYX8GH8ITUhgQaP8G/8FA=
github.com/projectdiscovery/hmap v0.0.101 h1:zXM6YtLmsn8Q0CUUw8QavhqWmiQYwaw+/U679Rr00pc=
github.com/projectdiscovery/hmap v0.0.101/go.mod h1:w6N9/a5H8kvyx53AhtPDUWe5Qq3D6NBDPA23glHpa/Q=
github.com/projectdiscovery/httpx v1.9.0 h1:5yn4ik/LqZ+v3MLgU7+CZJQyND9osW9NmZ3squylxsc=
github.com/projectdiscovery/httpx v1.9.0/go.mod h1:jGTRyUHddo2WyK4klWIwQXgGF1Lu39XVyzlue4H3pX8=
github.com/projectdiscovery/httpx v1.10.0 h1:sZvfeJDeX4eJfB5Oh29My4gOHmLfvpCt2wCqhargJrE=
github.com/projectdiscovery/httpx v1.10.0/go.mod h1:gfdNnFznLLlAgAgSQlYjUeEVvYzKrNV0jO5Qyo5w+FI=
github.com/projectdiscovery/interactsh v1.3.1 h1:5HzeVGVCAX/cjTguJ+7ClOmML5r97Ty7op9s+/F7BiM=
github.com/projectdiscovery/interactsh v1.3.1/go.mod h1:MXQ11EoBPROb4bEw+WP9e4DX4fMhrpS6EwfMfZomBsw=
github.com/projectdiscovery/ldapserver v1.0.2-0.20240219154113-dcc758ebc0cb h1:MGtI4oE12ruWv11ZlPXXd7hl/uAaQZrFvrIDYDeVMd8=
@@ -865,18 +877,18 @@ github.com/projectdiscovery/mapcidr v1.1.97 h1:7FkxNNVXp+m1rIu5Nv/2SrF9k4+LwP8Qu
github.com/projectdiscovery/mapcidr v1.1.97/go.mod h1:9dgTJh1SP02gYZdpzMjm6vtYFkEHQHoTyaVNvaeJ7lA=
github.com/projectdiscovery/n3iwf v0.0.0-20230523120440-b8cd232ff1f5 h1:L/e8z8yw1pfT6bg35NiN7yd1XKtJap5Nk6lMwQ0RNi8=
github.com/projectdiscovery/n3iwf v0.0.0-20230523120440-b8cd232ff1f5/go.mod h1:pGW2ncnTxTxHtP9wzcIJAB+3/NMp6IiuQWd2NK7K+oc=
github.com/projectdiscovery/networkpolicy v0.1.42 h1:/d5vriH8fDMoEaFCSD1nuCWs/ki3dft+CDqQnYrKyLg=
github.com/projectdiscovery/networkpolicy v0.1.42/go.mod h1:9ULLaMbdv9UnT0C5rmuK4nIwYs0o776xMnkPUb8TtaE=
github.com/projectdiscovery/networkpolicy v0.1.44 h1:+fnpYNQVH9mYZY7VrsTqdL4YJGVZSA8XLAlRHBMl5QE=
github.com/projectdiscovery/networkpolicy v0.1.44/go.mod h1:q1KeQiHchXdElScEMWc5mShWNRNoJXI5koRnVaX6Qh8=
github.com/projectdiscovery/ratelimit v0.0.88 h1:AcurW9aLRzlEyPe9kSjnOpr3XzLMWTpiWAlW/w73ALU=
github.com/projectdiscovery/ratelimit v0.0.88/go.mod h1:CU1s+68UUG2mctSl2wi32/DHLJA6TMg+4rxgP59LfVk=
github.com/projectdiscovery/rawhttp v0.1.90 h1:LOSZ6PUH08tnKmWsIwvwv1Z/4zkiYKYOSZ6n+8RFKtw=
github.com/projectdiscovery/rawhttp v0.1.90/go.mod h1:VZYAM25UI/wVB3URZ95ZaftgOnsbphxyAw/XnQRRz4Y=
github.com/projectdiscovery/rawhttp v0.1.91 h1:6EIhZxCkBn27kJAl47FieCFCe4H2ZdbhoBVj0J6ZCVY=
github.com/projectdiscovery/rawhttp v0.1.91/go.mod h1:VZYAM25UI/wVB3URZ95ZaftgOnsbphxyAw/XnQRRz4Y=
github.com/projectdiscovery/rdap v0.9.0 h1:wPhHx5pQ2QI+WGhyNb2PjhTl0NtB39Nk7YFZ9cp8ZGA=
github.com/projectdiscovery/rdap v0.9.0/go.mod h1:zk4yrJFQ2Hy36Aqk+DvotYQxYAeALaCJ5ORySkff36Q=
github.com/projectdiscovery/retryabledns v1.0.115 h1:RKV63FNIznFHUoawg/1hs53pVH3wqPtFhwstCuxVSoA=
github.com/projectdiscovery/retryabledns v1.0.115/go.mod h1:+fEMWoPigw+M0lGNKY7AZ+g8FIgj+4sONjsinMmeL3k=
github.com/projectdiscovery/retryablehttp-go v1.3.17 h1:wxtD3Cw97czaz1Y+EKQYKkWtBwINikop7J4e1sgBTac=
github.com/projectdiscovery/retryablehttp-go v1.3.17/go.mod h1:lcOGgfvP38rnA6bLskzj1dP8n5dw24peLjuKUlHfxVU=
github.com/projectdiscovery/retryablehttp-go v1.3.21 h1:HytR9e8AfhIF/4xUo6szqg213a7pOq5tE3kpNZCyYwI=
github.com/projectdiscovery/retryablehttp-go v1.3.21/go.mod h1:0SCELxSKpkqHTy9pcZDZ2ry+5djp4geKpEURJ1uSB24=
github.com/projectdiscovery/sarif v0.1.0 h1:O541T+a448nSJsmIMnXXSOeDQEzpnCAYvRfe0eG5h74=
github.com/projectdiscovery/sarif v0.1.0/go.mod h1:LBC+reM3bkI3qIIhE0rZaINaYX6VG+En6u2hHa5mA7E=
github.com/projectdiscovery/stringsutil v0.0.2 h1:uzmw3IVLJSMW1kEg8eCStG/cGbYYZAja8BH3LqqJXMA=
@@ -889,8 +901,8 @@ github.com/projectdiscovery/useragent v0.0.108 h1:fb+uLuFJvC+MHZjCtxQJxtvp1X6A8n
github.com/projectdiscovery/useragent v0.0.108/go.mod h1:XdNRrlvtDmYfVL1Oybat4uMe+W6cLwsK9S18ond17CI=
github.com/projectdiscovery/utils v0.11.1 h1:PWj1KjIASxt8icxommH72C0TQqNOvGkcSODRkiq0SQw=
github.com/projectdiscovery/utils v0.11.1/go.mod h1:yktGrHGk2CTjNiccXovnvGrLHX9sV2bqz9nSnbA3V8M=
github.com/projectdiscovery/wappalyzergo v0.2.88 h1:E1wZvuvArnmygt8hlF156vnEYBWAc4jdpr/b/4Fi0cY=
github.com/projectdiscovery/wappalyzergo v0.2.88/go.mod h1:gMH0o5lBp65sKMwHx/tuUdOtW2RjodC6Ti+9QDsYMkY=
github.com/projectdiscovery/wappalyzergo v0.2.91 h1:pjbEOCJKmxfEG5xK3WnUNY4SuYjPoYUuAUxa3F1QN1Y=
github.com/projectdiscovery/wappalyzergo v0.2.91/go.mod h1:gMH0o5lBp65sKMwHx/tuUdOtW2RjodC6Ti+9QDsYMkY=
github.com/projectdiscovery/yamldoc-go v1.0.6 h1:GCEdIRlQjDux28xTXKszM7n3jlMf152d5nqVpVoetas=
github.com/projectdiscovery/yamldoc-go v1.0.6/go.mod h1:R5lWrNzP+7Oyn77NDVPnBsxx2/FyQZBBkIAaSaCQFxw=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
@@ -937,6 +949,8 @@ github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA=
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ=
github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
github.com/sashabaranov/go-openai v1.37.0 h1:hQQowgYm4OXJ1Z/wTrE+XZaO20BYsL0R3uRPSpfNZkY=
github.com/sashabaranov/go-openai v1.37.0/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c=
@@ -978,6 +992,8 @@ github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
@@ -1113,8 +1129,8 @@ github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark v1.7.17 h1:p36OVWwRb246iHxA/U4p8OPEpOTESm4n+g+8t0EE5uA=
github.com/yuin/goldmark v1.7.17/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
@@ -1156,16 +1172,16 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
@@ -1206,8 +1222,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -1245,8 +1261,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -1296,8 +1312,8 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -1307,8 +1323,8 @@ golang.org/x/oauth2 v0.0.0-20210514164344-f6687ab2804c/go.mod h1:KelEdhl1UZF7XfJ
golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc=
golang.org/x/oauth2 v0.5.0/go.mod h1:9/XBHVqLaWO3/BRHs5jbpYCnOZVjj5V0ndyaAM7KB4I=
golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -1325,8 +1341,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -1393,8 +1409,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
@@ -1408,8 +1424,8 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -1427,8 +1443,8 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -1480,12 +1496,14 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
@@ -1538,6 +1556,8 @@ google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7Fc
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
@@ -1550,6 +1570,8 @@ google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKa
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
-556
View File
@@ -1,556 +0,0 @@
package fuzzplayground
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"html"
"net/http"
"strings"
"sync"
)
// This file turns the playground into a small but realistic "modern app" auth
// test bench. It serves a range of login styles seen in the wild — classic
// server-rendered forms, JS/SPA-rendered forms, username-first multi-step flows,
// SPA logins that stash a JWT in web storage, CSRF-protected forms, a
// disabled-until-valid submit button (a real regression class), and an
// OAuth-style redirect dance — plus protected endpoints (whoami) and an
// authenticated, fuzzable endpoint. It lets us exercise the auto-login /
// recording / capture engine and authenticated scanning entirely in-repo,
// without external apps.
//
// Credentials are fixed and shared across every style.
const (
AuthUsername = "[email protected]"
AuthPassword = "nuclei-rocks"
authSessionCookie = "PSESSION"
)
// authStore is a tiny in-memory session/CSRF store for the auth playground.
type authStore struct {
mu sync.Mutex
sessions map[string]string // session token -> username
csrf map[string]struct{}
}
func newAuthStore() *authStore {
// Pre-seed the SPA-token style's fixed session so the JWT it stashes in web
// storage also authenticates protected APIs (bearer path).
return &authStore{
sessions: map[string]string{"spa-static-session": AuthUsername},
csrf: map[string]struct{}{},
}
}
func randToken() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// jwtFor returns a JWT-shaped token (eyJ...) so token-regex extraction has a
// realistic target. It is not a real signed JWT; the playground treats the
// session token embedded in it as the source of truth.
func jwtFor(session string) string {
// header.payload.signature where payload carries the opaque session id
return "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9." +
hex.EncodeToString([]byte(session)) + ".c2lnbmF0dXJl"
}
func (s *authStore) issue(username string) (session, jwt string) {
session = randToken()
s.mu.Lock()
s.sessions[session] = username
s.mu.Unlock()
return session, jwtFor(session)
}
func (s *authStore) userFor(session string) (string, bool) {
s.mu.Lock()
defer s.mu.Unlock()
u, ok := s.sessions[session]
return u, ok
}
func (s *authStore) issueCSRF() string {
tok := randToken()
s.mu.Lock()
s.csrf[tok] = struct{}{}
s.mu.Unlock()
return tok
}
func (s *authStore) consumeCSRF(tok string) bool {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.csrf[tok]; ok {
delete(s.csrf, tok)
return true
}
return false
}
// authenticatedUser resolves the caller's identity from either the session
// cookie or a bearer token (the JWT carries the hex-encoded session id).
func (s *authStore) authenticatedUser(r *http.Request) (string, bool) {
if c, err := r.Cookie(authSessionCookie); err == nil && c.Value != "" {
if u, ok := s.userFor(c.Value); ok {
return u, true
}
}
auth := r.Header.Get("Authorization")
if strings.HasPrefix(auth, "Bearer ") {
if sess := sessionFromJWT(strings.TrimPrefix(auth, "Bearer ")); sess != "" {
if u, ok := s.userFor(sess); ok {
return u, true
}
}
}
return "", false
}
// sessionFromJWT extracts the opaque session id embedded in a playground JWT.
func sessionFromJWT(token string) string {
parts := strings.Split(token, ".")
if len(parts) != 3 {
return ""
}
raw, err := hex.DecodeString(parts[1])
if err != nil {
return ""
}
return string(raw)
}
func setSessionCookie(w http.ResponseWriter, session string) {
http.SetCookie(w, &http.Cookie{
Name: authSessionCookie,
Value: session,
Path: "/",
HttpOnly: true,
})
}
// registerAuthRoutes wires every login style and the protected endpoints onto a
// per-server auth store.
func registerAuthRoutes(mux *http.ServeMux) {
st := newAuthStore()
// 1. Classic server-rendered form login (works with the static HTTP engine).
mux.HandleFunc("GET /auth/form-login", func(w http.ResponseWriter, _ *http.Request) {
writeHTML(w, http.StatusOK, formLoginPage("", ""))
})
mux.HandleFunc("POST /auth/form-login", func(w http.ResponseWriter, r *http.Request) {
if r.FormValue("username") == AuthUsername && r.FormValue("password") == AuthPassword {
session, _ := st.issue(AuthUsername)
setSessionCookie(w, session)
http.Redirect(w, r, "/auth/dashboard", http.StatusFound)
return
}
writeHTML(w, http.StatusOK, formLoginPage(r.FormValue("username"), "Invalid credentials"))
})
// 2. CSRF-protected server-rendered form: a hidden token must be echoed back.
mux.HandleFunc("GET /auth/csrf-login", func(w http.ResponseWriter, _ *http.Request) {
writeHTML(w, http.StatusOK, csrfLoginPage(st.issueCSRF(), ""))
})
mux.HandleFunc("POST /auth/csrf-login", func(w http.ResponseWriter, r *http.Request) {
if !st.consumeCSRF(r.FormValue("csrf_token")) {
writeHTML(w, http.StatusForbidden, csrfLoginPage(st.issueCSRF(), "Invalid CSRF token"))
return
}
if r.FormValue("username") == AuthUsername && r.FormValue("password") == AuthPassword {
session, _ := st.issue(AuthUsername)
setSessionCookie(w, session)
http.Redirect(w, r, "/auth/dashboard", http.StatusFound)
return
}
writeHTML(w, http.StatusOK, csrfLoginPage(st.issueCSRF(), "Invalid credentials"))
})
// 3. JS/SPA-rendered form: the raw HTML has no <form>; JS builds it and posts
// via fetch. Only a real browser can log in here.
mux.HandleFunc("GET /auth/spa-login", func(w http.ResponseWriter, _ *http.Request) {
writeHTML(w, http.StatusOK, spaLoginPage)
})
mux.HandleFunc("POST /auth/api/login", func(w http.ResponseWriter, r *http.Request) {
var body struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad request"})
return
}
if body.Username == AuthUsername && body.Password == AuthPassword {
session, jwt := st.issue(AuthUsername)
setSessionCookie(w, session)
writeJSON(w, http.StatusOK, map[string]string{"token": jwt})
return
}
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
})
// 4. Username-first multi-step flow (Google/Microsoft-style): password field
// is revealed only after the "Next" button.
mux.HandleFunc("GET /auth/multistep-login", func(w http.ResponseWriter, _ *http.Request) {
writeHTML(w, http.StatusOK, multiStepLoginPage)
})
// 5. Disabled-until-valid submit button — a real regression class: the submit
// stays disabled until JS marks the form valid, so a naive immediate click
// hangs. The engine must settle/fall back to Enter.
mux.HandleFunc("GET /auth/strict-login", func(w http.ResponseWriter, _ *http.Request) {
writeHTML(w, http.StatusOK, strictLoginPage)
})
// 6. SPA login that stores a JWT in localStorage (no cookie at all).
mux.HandleFunc("GET /auth/spa-token-login", func(w http.ResponseWriter, _ *http.Request) {
writeHTML(w, http.StatusOK, spaTokenLoginPage)
})
// 7. OAuth-style redirect flow: authorize -> (auto consent) -> callback sets
// the session and redirects to the dashboard.
mux.HandleFunc("GET /auth/oauth/authorize", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/auth/oauth/callback?code="+randToken(), http.StatusFound)
})
mux.HandleFunc("GET /auth/oauth/callback", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("code") == "" {
writeString(w, http.StatusBadRequest, "missing code")
return
}
session, _ := st.issue(AuthUsername)
setSessionCookie(w, session)
http.Redirect(w, r, "/auth/dashboard", http.StatusFound)
})
// 8. Multi-form page: a decoy search form (no password) precedes the real
// login form. The detector must score forms and pick the credential one
// rather than blindly taking the first <form>.
mux.HandleFunc("GET /auth/multiform-login", func(w http.ResponseWriter, _ *http.Request) {
writeHTML(w, http.StatusOK, multiFormLoginPage)
})
// 9. JS-set cookie: login succeeds via XHR that returns the session id in the
// body (no Set-Cookie), and client JS writes document.cookie. The headless
// engine must capture cookies from the browser jar, not just Set-Cookie.
mux.HandleFunc("GET /auth/jscookie-login", func(w http.ResponseWriter, _ *http.Request) {
writeHTML(w, http.StatusOK, jsCookieLoginPage)
})
mux.HandleFunc("POST /auth/api/login-jsbody", func(w http.ResponseWriter, r *http.Request) {
var body struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad request"})
return
}
if body.Username == AuthUsername && body.Password == AuthPassword {
session, _ := st.issue(AuthUsername)
// Deliberately no Set-Cookie: the client sets the cookie via JS.
writeJSON(w, http.StatusOK, map[string]string{"session": session})
return
}
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
})
// 10. Delayed render: the login form is injected after an async tick, so an
// engine that probes for the password field immediately would miss it and
// must wait for it to appear.
mux.HandleFunc("GET /auth/delayed-login", func(w http.ResponseWriter, _ *http.Request) {
writeHTML(w, http.StatusOK, delayedLoginPage)
})
// 11. Header-token login: a server-rendered form whose POST returns the
// session token in a response header (no cookie), the token-in-header
// API style. The engine must read the token from the header.
mux.HandleFunc("GET /auth/header-token-login", func(w http.ResponseWriter, _ *http.Request) {
writeHTML(w, http.StatusOK, headerTokenLoginPage(""))
})
mux.HandleFunc("POST /auth/header-token-login", func(w http.ResponseWriter, r *http.Request) {
if r.FormValue("username") == AuthUsername && r.FormValue("password") == AuthPassword {
_, jwt := st.issue(AuthUsername)
// Token in a header, deliberately no Set-Cookie.
w.Header().Set("X-Auth-Token", jwt)
writeHTML(w, http.StatusOK, `<html><head><title>Dashboard</title></head><body><h1>Welcome, signed in</h1></body></html>`)
return
}
writeHTML(w, http.StatusOK, headerTokenLoginPage("Invalid credentials"))
})
// 12. SPA whose XHR login returns the token only in a response header (no
// cookie, no body/storage token). Exercises the headless engine's
// passive response-header interception.
mux.HandleFunc("GET /auth/spa-header-token-login", func(w http.ResponseWriter, _ *http.Request) {
writeHTML(w, http.StatusOK, spaHeaderTokenLoginPage)
})
mux.HandleFunc("POST /auth/api/login-header", func(w http.ResponseWriter, r *http.Request) {
var body struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "bad request"})
return
}
if body.Username == AuthUsername && body.Password == AuthPassword {
_, jwt := st.issue(AuthUsername)
w.Header().Set("X-Auth-Token", jwt)
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
return
}
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid credentials"})
})
// Protected landing page: the login-success heuristic relies on the final
// page having no password field, which this page satisfies.
mux.HandleFunc("GET /auth/dashboard", func(w http.ResponseWriter, r *http.Request) {
user, ok := st.authenticatedUser(r)
if !ok {
http.Redirect(w, r, "/auth/form-login", http.StatusFound)
return
}
writeHTML(w, http.StatusOK, fmt.Sprintf(`<html><head><title>Dashboard</title></head>
<body><h1>Welcome, %s</h1><a href="/auth/logout">Logout</a></body></html>`, html.EscapeString(user)))
})
// whoami: protected API used to assert a captured session authenticates.
mux.HandleFunc("GET /auth/whoami", func(w http.ResponseWriter, r *http.Request) {
user, ok := st.authenticatedUser(r)
if !ok {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
return
}
writeJSON(w, http.StatusOK, map[string]string{"user": user})
})
// Authenticated, fuzzable endpoint: only reachable with a valid session, and
// reflects q without sanitization (reflected XSS) so authenticated fuzzing
// has a genuine target behind the login wall.
mux.HandleFunc("GET /auth/api/search", func(w http.ResponseWriter, r *http.Request) {
if _, ok := st.authenticatedUser(r); !ok {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "unauthorized"})
return
}
q := r.URL.Query().Get("q")
writeHTML(w, http.StatusOK, fmt.Sprintf("<html><body><div>results for: %s</div></body></html>", q))
})
mux.HandleFunc("GET /auth/logout", func(w http.ResponseWriter, r *http.Request) {
if ck, err := r.Cookie(authSessionCookie); err == nil {
st.mu.Lock()
delete(st.sessions, ck.Value)
st.mu.Unlock()
}
setSessionCookie(w, "")
http.Redirect(w, r, "/auth/form-login", http.StatusFound)
})
}
// --- Login page templates --------------------------------------------------
func formLoginPage(username, errMsg string) string {
var banner string
if errMsg != "" {
banner = fmt.Sprintf(`<p class="error">%s</p>`, html.EscapeString(errMsg))
}
return fmt.Sprintf(`<html><head><title>Login</title></head><body>
<h1>Sign in</h1>%s
<form method="post" action="/auth/form-login">
<input type="text" name="username" placeholder="Email" value="%s">
<input type="password" name="password" placeholder="Password">
<button type="submit">Sign in</button>
</form></body></html>`, banner, html.EscapeString(username))
}
func csrfLoginPage(csrf, errMsg string) string {
var banner string
if errMsg != "" {
banner = fmt.Sprintf(`<p class="error">%s</p>`, html.EscapeString(errMsg))
}
return fmt.Sprintf(`<html><head><title>Login</title></head><body>
<h1>Sign in</h1>%s
<form method="post" action="/auth/csrf-login">
<input type="hidden" name="csrf_token" value="%s">
<input type="text" name="username" placeholder="Email">
<input type="password" name="password" placeholder="Password">
<button type="submit">Sign in</button>
</form></body></html>`, banner, html.EscapeString(csrf))
}
// spaLoginPage builds the form entirely in JS (no <form> in the raw HTML) and
// submits it via fetch, then redirects on success.
const spaLoginPage = `<html><head><title>SPA Login</title></head><body>
<div id="app"></div>
<script>
var app = document.getElementById('app');
var email = document.createElement('input'); email.id = 'email'; email.type = 'email';
var pass = document.createElement('input'); pass.id = 'password'; pass.type = 'password';
var btn = document.createElement('button'); btn.id = 'submit'; btn.textContent = 'Sign in';
app.appendChild(email); app.appendChild(pass); app.appendChild(btn);
btn.addEventListener('click', function () {
fetch('/auth/api/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({username: email.value, password: pass.value})
}).then(function (r) { return r.json(); }).then(function (d) {
if (d.token) { document.body.innerHTML = '<h1>Welcome, signed in</h1>'; }
else { document.body.innerHTML += '<p>login failed</p>'; }
});
});
</script></body></html>`
// multiStepLoginPage hides the password field until "Next" is clicked.
const multiStepLoginPage = `<html><head><title>Multi-step Login</title></head><body>
<form id="f" method="post" action="/auth/form-login">
<input type="text" name="username" id="username" placeholder="Email">
<button type="button" id="next">Next</button>
<div id="pwwrap" style="display:none">
<input type="password" name="password" id="password" placeholder="Password">
<button type="submit" id="submit">Sign in</button>
</div>
</form>
<script>
document.getElementById('next').addEventListener('click', function () {
document.getElementById('pwwrap').style.display = 'block';
});
</script></body></html>`
// strictLoginPage keeps the submit button disabled until both fields are
// non-empty, reproducing the disabled-until-valid regression class.
const strictLoginPage = `<html><head><title>Strict Login</title></head><body>
<form method="post" action="/auth/form-login">
<input type="text" name="username" id="username" placeholder="Email">
<input type="password" name="password" id="password" placeholder="Password">
<button type="submit" id="submit" disabled>Sign in</button>
</form>
<script>
var u = document.getElementById('username'), p = document.getElementById('password'), b = document.getElementById('submit');
function validate() { b.disabled = !(u.value.length > 0 && p.value.length > 0); }
u.addEventListener('input', validate); p.addEventListener('input', validate);
</script></body></html>`
// spaTokenLoginPage validates client-side and stores a JWT in localStorage with
// no cookie, the canonical token-in-web-storage case.
var spaTokenLoginPage = fmt.Sprintf(`<html><head><title>SPA Token Login</title></head><body>
<form id="f">
<input type="email" name="username" id="email" placeholder="Email">
<input type="password" name="password" id="password" placeholder="Password">
<button type="submit" id="submit">Sign in</button>
</form>
<script>
document.getElementById('f').addEventListener('submit', function (e) {
e.preventDefault();
var u = document.getElementById('email').value;
var p = document.getElementById('password').value;
if (u === %q && p === %q) {
window.localStorage.setItem('access_token', %q);
window.sessionStorage.setItem('uid', '1');
document.body.innerHTML = '<h1>Welcome, signed in</h1>';
} else {
document.body.innerHTML += '<p>login failed</p>';
}
});
</script></body></html>`, AuthUsername, AuthPassword, jwtFor("spa-static-session"))
// multiFormLoginPage places a decoy search form (no password field) before the
// real login form to exercise best-form selection over first-form selection.
const multiFormLoginPage = `<html><head><title>Multi-form Login</title></head><body>
<form id="search" method="get" action="/auth/api/search">
<input type="text" name="q" placeholder="Search">
<button type="submit">Search</button>
</form>
<hr>
<form id="login" method="post" action="/auth/form-login">
<input type="text" name="username" placeholder="Email">
<input type="password" name="password" placeholder="Password">
<button type="submit">Sign in</button>
</form></body></html>`
// jsCookieLoginPage logs in via XHR and sets the session cookie from JS
// (document.cookie) rather than relying on a Set-Cookie response header.
const jsCookieLoginPage = `<html><head><title>JS Cookie Login</title></head><body>
<form id="f">
<input type="email" name="username" id="email" placeholder="Email">
<input type="password" name="password" id="password" placeholder="Password">
<button type="submit" id="submit">Sign in</button>
</form>
<script>
document.getElementById('f').addEventListener('submit', function (e) {
e.preventDefault();
fetch('/auth/api/login-jsbody', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
username: document.getElementById('email').value,
password: document.getElementById('password').value
})
}).then(function (r) { return r.json(); }).then(function (d) {
if (d.session) {
document.cookie = 'PSESSION=' + d.session + '; path=/';
document.body.innerHTML = '<h1>Welcome, signed in</h1>';
} else {
document.body.innerHTML += '<p>login failed</p>';
}
});
});
</script></body></html>`
// headerTokenLoginPage is a classic server-rendered form whose POST returns the
// session token in a response header rather than a cookie or body.
func headerTokenLoginPage(errMsg string) string {
var banner string
if errMsg != "" {
banner = fmt.Sprintf(`<p class="error">%s</p>`, html.EscapeString(errMsg))
}
return fmt.Sprintf(`<html><head><title>Header Token Login</title></head><body>
<h1>Sign in</h1>%s
<form method="post" action="/auth/header-token-login">
<input type="text" name="username" placeholder="Email">
<input type="password" name="password" placeholder="Password">
<button type="submit">Sign in</button>
</form></body></html>`, banner)
}
// spaHeaderTokenLoginPage logs in via XHR; the token comes back only in a
// response header (the page never reads or stores it), so the session is
// recoverable only by observing response headers.
const spaHeaderTokenLoginPage = `<html><head><title>SPA Header Token Login</title></head><body>
<form id="f">
<input type="email" name="username" id="email" placeholder="Email">
<input type="password" name="password" id="password" placeholder="Password">
<button type="submit" id="submit">Sign in</button>
</form>
<script>
document.getElementById('f').addEventListener('submit', function (e) {
e.preventDefault();
fetch('/auth/api/login-header', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
username: document.getElementById('email').value,
password: document.getElementById('password').value
})
}).then(function (r) { return r.json(); }).then(function (d) {
if (d.ok) { document.body.innerHTML = '<h1>Welcome, signed in</h1>'; }
else { document.body.innerHTML += '<p>login failed</p>'; }
});
});
</script></body></html>`
// delayedLoginPage injects the login form only after an async tick, simulating a
// SPA that renders its form after bootstrapping.
const delayedLoginPage = `<html><head><title>Delayed Login</title></head><body>
<div id="app"><p>loading...</p></div>
<script>
setTimeout(function () {
document.getElementById('app').innerHTML =
'<form method="post" action="/auth/form-login">' +
'<input type="text" name="username" placeholder="Email">' +
'<input type="password" name="password" placeholder="Password">' +
'<button type="submit">Sign in</button></form>';
}, 700);
</script></body></html>`
-253
View File
@@ -10,7 +10,6 @@ import (
"net/http"
"net/url"
"os/exec"
"regexp"
"strconv"
"strings"
@@ -40,9 +39,6 @@ func GetPlaygroundServer() *PlaygroundServer {
mux.HandleFunc("POST /user", patchUnsanitizedUserHandler)
mux.HandleFunc("GET /blog/posts", getPostsHandler)
registerAnalyzerRoutes(mux)
registerAuthRoutes(mux)
handler := recoverPlaygroundRequest(logPlaygroundRequest(mux))
return &PlaygroundServer{handler: handler}
}
@@ -69,239 +65,6 @@ func (s *PlaygroundServer) Close() error {
return s.server.Close()
}
// registerAnalyzerRoutes wires a dedicated, analyzer-friendly test bench used by
// the fuzzer "analyzer" templates. Each endpoint is genuinely vulnerable but
// responds purely in-band and deterministically (no external network egress),
// so the corresponding analyzer's generic probes reliably trigger detection in
// CI. The query parameter is always "q" to keep the templates uniform.
func registerAnalyzerRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /analyzer/sqli", analyzerSQLiHandler)
mux.HandleFunc("GET /analyzer/ssti", analyzerSSTIHandler)
mux.HandleFunc("GET /analyzer/lfi", analyzerLFIHandler)
mux.HandleFunc("GET /analyzer/cmdi", analyzerCMDiHandler)
mux.HandleFunc("GET /analyzer/ssrf", analyzerSSRFHandler)
mux.HandleFunc("GET /analyzer/redirect", analyzerRedirectHandler)
mux.HandleFunc("GET /analyzer/crlf", analyzerCRLFHandler)
mux.HandleFunc("GET /analyzer/cors", analyzerCORSHandler)
mux.HandleFunc("GET /analyzer/host-header", analyzerHostHeaderHandler)
// Benign counterparts: these reflect or echo input but are NOT vulnerable,
// so the analyzers must NOT raise a finding against them (false-positive
// guard at the CLI level).
mux.HandleFunc("GET /analyzer/safe/reflect", analyzerSafeReflectHandler)
mux.HandleFunc("GET /analyzer/safe/redirect", analyzerSafeRedirectHandler)
mux.HandleFunc("GET /analyzer/safe/cors", analyzerSafeCORSHandler)
mux.HandleFunc("GET /analyzer/safe/headers", analyzerSafeHeadersHandler)
mux.HandleFunc("GET /analyzer/safe/host", analyzerSafeHostHandler)
// Non-query positions: prove the analyzers fuzz path / header / cookie / body
// components through the real pipeline, not just query parameters.
mux.HandleFunc("GET /analyzer/path/sqli/{id}", analyzerPathSQLiHandler)
mux.HandleFunc("GET /analyzer/header/sqli", analyzerHeaderSQLiHandler)
mux.HandleFunc("POST /analyzer/body/sqli", analyzerBodySQLiHandler)
mux.HandleFunc("GET /analyzer/cookie/ssti", analyzerCookieSSTIHandler)
}
// reArithmeticTemplate emulates a real template engine: it matches an arithmetic
// multiplication wrapped in a delimiter pair (EL ${}, Jinja {{}}, #{}, *{},
// Razor @(), ERB <%= %>, Smarty {}) and replaces the WHOLE delimited expression
// with its product, preserving any surrounding text (e.g. the analyzer's
// sentinels), exactly as a vulnerable engine would.
var reArithmeticTemplate = regexp.MustCompile(`(?:\$\{|\{\{|#\{|\*\{|@\(|<%=|\{)\s*(\d+)\s*\*\s*(\d+)\s*(?:\}\}|%>|\}|\))`)
// analyzerCmdiSeparators are shell metacharacter sequences followed by the `id`
// command that the cmdi analyzer injects; presence of any indicates the input
// reached a shell.
var analyzerCmdiSeparators = []string{";id", "|id", "||id", "&&id", "&id", "`id`", "$(id)", "\nid"}
// analyzerSQLiHandler is vulnerable to error-based SQLi via the real sqlite DB:
// a quote in q breaks the query and surfaces a genuine "unrecognized token"
// sqlite error, which the sqli_error analyzer fingerprints.
func analyzerSQLiHandler(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
posts, err := getUnsanitizedPostsByLang(db, q)
if err != nil {
writeString(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, posts)
}
// analyzerSSTIHandler evaluates arithmetic template expressions in q.
func analyzerSSTIHandler(w http.ResponseWriter, r *http.Request) {
out := reArithmeticTemplate.ReplaceAllStringFunc(r.URL.Query().Get("q"), func(m string) string {
sub := reArithmeticTemplate.FindStringSubmatch(m)
a, _ := strconv.Atoi(sub[1])
b, _ := strconv.Atoi(sub[2])
return strconv.Itoa(a * b)
})
writeHTML(w, http.StatusOK, fmt.Sprintf(bodyTemplate, out))
}
// analyzerLFIHandler returns file contents for path-traversal payloads in q.
func analyzerLFIHandler(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
switch {
case strings.Contains(q, "etc/passwd"):
writeString(w, http.StatusOK, "root:x:0:0:root:/root:/bin/bash\ndaemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin\n")
case strings.Contains(strings.ToLower(q), "win.ini"):
writeString(w, http.StatusOK, "; for 16-bit app support\r\n[fonts]\r\n")
default:
writeString(w, http.StatusOK, "file not found")
}
}
// analyzerCMDiHandler simulates a shell that concatenates q: an injected
// separator followed by `id` yields command output.
func analyzerCMDiHandler(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
for _, sep := range analyzerCmdiSeparators {
if strings.Contains(q, sep) {
writeString(w, http.StatusOK, "uid=0(root) gid=0(root) groups=0(root)")
return
}
}
writeString(w, http.StatusOK, fmt.Sprintf("ping output for %s", q))
}
// analyzerSSRFHandler simulates an in-band SSRF: requesting a cloud metadata
// endpoint returns the (mock) instance identity document. It does NOT perform a
// real outbound request, keeping the test hermetic.
func analyzerSSRFHandler(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
if strings.Contains(q, "169.254.169.254") || strings.Contains(q, "metadata.google.internal") {
writeString(w, http.StatusOK, `{"accountId":"123456789012","imageId":"ami-0abcd1234ef567890","instanceId":"i-0abcd1234ef567890","region":"us-east-1"}`)
return
}
writeString(w, http.StatusOK, "fetched: nothing interesting")
}
// analyzerRedirectHandler reflects q straight into the Location header.
func analyzerRedirectHandler(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, r.URL.Query().Get("q"), http.StatusFound)
}
// analyzerCRLFHandler naively builds response headers from q, splitting on
// newlines — a textbook response-splitting bug.
func analyzerCRLFHandler(w http.ResponseWriter, r *http.Request) {
for _, line := range strings.Split(r.URL.Query().Get("q"), "\n") {
line = strings.TrimRight(line, "\r")
idx := strings.Index(line, ": ")
if idx <= 0 || strings.ContainsAny(line[:idx], " \t") {
continue
}
name, val := line[:idx], line[idx+2:]
if strings.EqualFold(name, "Set-Cookie") {
w.Header().Add("Set-Cookie", val)
} else {
w.Header().Set(name, val)
}
}
writeString(w, http.StatusOK, "ok")
}
// analyzerCORSHandler reflects an arbitrary Origin and allows credentials.
func analyzerCORSHandler(w http.ResponseWriter, r *http.Request) {
if origin := r.Header.Get("Origin"); origin != "" {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
}
writeString(w, http.StatusOK, "ok")
}
// analyzerHostHeaderHandler reflects the (attacker-controlled) X-Forwarded-Host
// into an absolute link in the body, without performing any outbound request.
func analyzerHostHeaderHandler(w http.ResponseWriter, r *http.Request) {
host := r.Header.Get("X-Forwarded-Host")
if host == "" {
host = r.Host
}
writeHTML(w, http.StatusOK, fmt.Sprintf(`<a href="https://%s/reset?token=abc">reset</a>`, host))
}
// --- Benign handlers (must not trigger any analyzer) -----------------------
// analyzerSafeReflectHandler reflects q verbatim with no evaluation, no DB, no
// command execution and no file access; it is the benign counterpart for the
// ssti, sqli, cmdi, lfi and ssrf analyzers.
func analyzerSafeReflectHandler(w http.ResponseWriter, r *http.Request) {
writeHTML(w, http.StatusOK, fmt.Sprintf(bodyTemplate, "you searched for: "+r.URL.Query().Get("q")))
}
// analyzerSafeRedirectHandler always redirects to a fixed trusted location,
// ignoring user input.
func analyzerSafeRedirectHandler(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/home", http.StatusFound)
}
// analyzerSafeCORSHandler only ever allows a single trusted origin.
func analyzerSafeCORSHandler(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "https://trusted.example.com")
writeString(w, http.StatusOK, "ok")
}
// analyzerSafeHeadersHandler returns static headers and never reflects input.
func analyzerSafeHeadersHandler(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("X-Static", "constant")
writeString(w, http.StatusOK, "ok")
}
// analyzerSafeHostHandler always builds links from a fixed, trusted host.
func analyzerSafeHostHandler(w http.ResponseWriter, _ *http.Request) {
writeHTML(w, http.StatusOK, `<a href="https://app.example.com/reset?token=abc">reset</a>`)
}
// --- Non-query position handlers -------------------------------------------
// sqliFromValue runs the value through the real sqlite query so a quote yields a
// genuine "unrecognized token" error that the sqli_error analyzer fingerprints.
func sqliFromValue(w http.ResponseWriter, value string) {
posts, err := getUnsanitizedPostsByLang(db, value)
if err != nil {
writeString(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, posts)
}
// analyzerPathSQLiHandler is error-based SQLi on a path segment ({id}).
func analyzerPathSQLiHandler(w http.ResponseWriter, r *http.Request) {
sqliFromValue(w, r.PathValue("id"))
}
// analyzerHeaderSQLiHandler is error-based SQLi on the X-Search request header.
func analyzerHeaderSQLiHandler(w http.ResponseWriter, r *http.Request) {
sqliFromValue(w, r.Header.Get("X-Search"))
}
// analyzerBodySQLiHandler is error-based SQLi on the JSON body "name" field.
func analyzerBodySQLiHandler(w http.ResponseWriter, r *http.Request) {
var payload struct {
Name string `json:"name"`
}
body, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(body, &payload)
sqliFromValue(w, payload.Name)
}
// analyzerCookieSSTIHandler evaluates arithmetic template expressions found in
// the "lang" cookie. SSTI (not SQLi) is used here because Go's cookie value
// sanitization strips the quotes SQLi relies on, whereas SSTI payload
// characters ({ } * $) are cookie-legal.
func analyzerCookieSSTIHandler(w http.ResponseWriter, r *http.Request) {
val := "en"
if c, err := r.Cookie("lang"); err == nil {
val = c.Value
}
out := reArithmeticTemplate.ReplaceAllStringFunc(val, func(m string) string {
sub := reArithmeticTemplate.FindStringSubmatch(m)
a, _ := strconv.Atoi(sub[1])
b, _ := strconv.Atoi(sub[2])
return strconv.Itoa(a * b)
})
writeHTML(w, http.StatusOK, fmt.Sprintf(bodyTemplate, "lang="+out))
}
var bodyTemplate = `<html>
<head>
<title>Fuzz Playground</title>
@@ -325,16 +88,6 @@ func indexHandler(w http.ResponseWriter, _ *http.Request) {
<li><a href="/user/75/profile">User Profile Page SQLI (path parameter)</a></li>
<li><a href="/user">POST on /user SQLI (body parameter)</a></li>
<li><a href="/blog/posts">SQLI in cookie lang parameter value (eg. lang=en)</a></li>
<li><a href="/analyzer/sqli?q=en">Analyzer bench: SQLi (query)</a></li>
<li><a href="/analyzer/ssti?q=test">Analyzer bench: SSTI (query)</a></li>
<li><a href="/analyzer/lfi?q=home.txt">Analyzer bench: LFI (query)</a></li>
<li><a href="/analyzer/cmdi?q=127.0.0.1">Analyzer bench: CMDi (query)</a></li>
<li><a href="/analyzer/ssrf?q=https://example.com">Analyzer bench: SSRF (query)</a></li>
<li><a href="/analyzer/redirect?q=/dashboard">Analyzer bench: Open Redirect (query)</a></li>
<li><a href="/analyzer/crlf?q=/home">Analyzer bench: CRLF (query)</a></li>
<li><a href="/analyzer/cors?q=x">Analyzer bench: CORS (query)</a></li>
<li><a href="/analyzer/host-header?q=x">Analyzer bench: Host Header Injection (query)</a></li>
</ul>
`))
@@ -531,12 +284,6 @@ func writeJSON(w http.ResponseWriter, statusCode int, value interface{}) {
_ = json.NewEncoder(w).Encode(value)
}
func writeString(w http.ResponseWriter, statusCode int, value string) {
w.Header().Set("Content-Type", "text/plain; charset=UTF-8")
w.WriteHeader(statusCode)
_, _ = io.WriteString(w, value)
}
func recoverPlaygroundRequest(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
+35
View File
@@ -0,0 +1,35 @@
package runner
import (
"fmt"
"io"
"strings"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
pdcpauth "github.com/projectdiscovery/utils/auth/pdcp"
)
// AppendDirectoryInfo writes nuclei config/cache/PDCP directory paths.
func AppendDirectoryInfo(w io.Writer) {
_, _ = fmt.Fprintf(w, "Nuclei Config Directory: %s\n", config.DefaultConfig.GetConfigDir())
_, _ = fmt.Fprintf(w, "Nuclei Cache Directory: %s\n", config.DefaultConfig.GetCacheDir())
_, _ = fmt.Fprintf(w, "PDCP Directory: %s\n", pdcpauth.PDCPDir)
}
// DirectoryInfo returns nuclei config/cache/PDCP directory paths as a string.
func DirectoryInfo() string {
var b strings.Builder
AppendDirectoryInfo(&b)
return b.String()
}
// LogDirectoryInfo logs nuclei config/cache/PDCP directory paths at info level.
func LogDirectoryInfo(logger *gologger.Logger) {
if logger == nil {
return
}
logger.Info().Msgf("Nuclei Config Directory: %s", config.DefaultConfig.GetConfigDir())
logger.Info().Msgf("Nuclei Cache Directory: %s", config.DefaultConfig.GetCacheDir())
logger.Info().Msgf("PDCP Directory: %s", pdcpauth.PDCPDir)
}
+25
View File
@@ -0,0 +1,25 @@
package runner
import (
"fmt"
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/config"
pdcpauth "github.com/projectdiscovery/utils/auth/pdcp"
)
func TestDirectoryInfoContainsKnownLabels(t *testing.T) {
info := DirectoryInfo()
cfg := config.DefaultConfig
require.Equal(t, strings.Join([]string{
fmt.Sprintf("Nuclei Config Directory: %s", cfg.GetConfigDir()),
fmt.Sprintf("Nuclei Cache Directory: %s", cfg.GetCacheDir()),
fmt.Sprintf("PDCP Directory: %s", pdcpauth.PDCPDir),
"",
}, "\n"), info)
require.Equal(t, 3, strings.Count(info, "\n"))
}
+1
View File
@@ -20,6 +20,7 @@ func DoHealthCheck(options *types.Options) string {
fmt.Fprintf(&test, "Architecture: %s\n", runtime.GOARCH)
fmt.Fprintf(&test, "Go Version: %s\n", runtime.Version())
fmt.Fprintf(&test, "Compiler: %s\n", runtime.Compiler)
AppendDirectoryInfo(&test)
var testResult string
cfg := config.DefaultConfig
-207
View File
@@ -1,16 +1,11 @@
package runner
import (
"bufio"
"context"
"fmt"
"net/url"
"os"
"strconv"
"strings"
"github.com/projectdiscovery/nuclei/v3/pkg/authprovider/authx"
"github.com/projectdiscovery/nuclei/v3/pkg/authprovider/autologin"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/disk"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/loader"
@@ -69,208 +64,6 @@ func GetAuthTmplStore(opts *types.Options, catalog catalog.Catalog, execOpts *pr
return store, nil
}
// captureOnceStoreFromOptions launches a visible browser at -auth-login-url,
// lets the user log in manually (pressing Enter in the terminal when done) and
// builds an in-memory static auth store from the captured session (cookies and
// an optional token), scoped to the login host. Unlike auto-login this captures
// a point-in-time session with no automated re-authentication.
func captureOnceStoreFromOptions(opts *types.Options) (*authx.Authx, error) {
u, err := url.Parse(opts.AuthLoginURL)
if err != nil {
return nil, errkit.Wrap(err, "invalid -auth-login-url")
}
if u.Host == "" {
return nil, errkit.New("capture-once: -auth-login-url is required and must include a host")
}
rt := buildAutoLoginRuntimeOptions(opts)
cfg := autologin.Config{
LoginURL: opts.AuthLoginURL,
Proxy: rt.Proxy,
CDPEndpoint: rt.CDPEndpoint,
UseInstalledChrome: rt.UseInstalledChrome,
UserAgent: rt.UserAgent,
CustomHeaders: rt.CustomHeaders,
}
ready := func() error {
fmt.Fprintf(os.Stderr, "[auth-capture] Log in to %s in the opened browser window, then press Enter here to capture the session...", opts.AuthLoginURL)
reader := bufio.NewReader(os.Stdin)
_, rerr := reader.ReadString('\n')
return rerr
}
session, err := autologin.CaptureOnce(context.Background(), cfg, ready)
if err != nil {
return nil, err
}
return captureSessionToStore(session, u.Host)
}
// captureSessionToStore converts a captured login session into a static auth
// store scoped to host: cookies as a Cookie secret, an extracted token as a
// BearerToken secret, and any web storage as a WebStorage secret (replayed into
// headless scan pages). It is split out from captureOnceStoreFromOptions so the
// session->store mapping is unit-testable without driving a browser.
func captureSessionToStore(session *autologin.Session, host string) (*authx.Authx, error) {
if session == nil {
return nil, errkit.New("capture-once: nil session")
}
var secrets []authx.Secret
if len(session.Cookies) > 0 {
cookies := make([]authx.Cookie, 0, len(session.Cookies))
for _, c := range session.Cookies {
cookies = append(cookies, authx.Cookie{Key: c.Name, Value: c.Value})
}
secrets = append(secrets, authx.Secret{
Type: string(authx.CookiesAuth),
Domains: []string{host},
Cookies: cookies,
})
}
if session.Token != "" {
secrets = append(secrets, authx.Secret{
Type: string(authx.BearerTokenAuth),
Domains: []string{host},
Token: session.Token,
})
}
if len(session.LocalStorage) > 0 || len(session.SessionStorage) > 0 {
// Carry captured web storage so a token-in-web-storage SPA session
// replays into headless scan pages (HTTP scans ignore this secret).
secrets = append(secrets, authx.Secret{
Type: string(authx.WebStorageAuth),
Domains: []string{host},
LocalStorage: session.LocalStorage,
SessionStorage: session.SessionStorage,
})
}
if len(secrets) == 0 {
return nil, errkit.New("capture-once: captured session had no replayable cookies, token or web storage")
}
for i := range secrets {
if err := secrets[i].Validate(); err != nil {
return nil, errkit.Wrap(err, "capture-once: invalid captured secret")
}
}
return &authx.Authx{ID: "cli-capture-once", Secrets: secrets}, nil
}
// buildAutoLoginRuntimeOptions maps scan-level options into the auto-login
// runtime options so a (headless) auto-login uses the same identity and network
// path as the scan: user-agent and custom headers (-H), proxy, CDP endpoint and
// Chrome settings.
func buildAutoLoginRuntimeOptions(opts *types.Options) *authx.AutoLoginRuntimeOptions {
// Mirror the proxy precedence used everywhere else in the codebase (HTTP
// proxy first, SOCKS as fallback) so a SOCKS-proxied scan does not silently
// bypass the proxy during auto-login.
proxy := opts.AliveHttpProxy
if proxy == "" {
proxy = opts.AliveSocksProxy
}
rt := &authx.AutoLoginRuntimeOptions{
Proxy: proxy,
CDPEndpoint: opts.CDPEndpoint,
UseInstalledChrome: opts.UseInstalledChrome,
ShowBrowser: opts.ShowBrowser,
}
for _, header := range opts.CustomHeaders {
parts := strings.SplitN(header, ":", 2)
if len(parts) != 2 {
continue
}
key := strings.TrimSpace(parts[0])
value := strings.TrimSpace(parts[1])
if key == "" || value == "" {
continue
}
if strings.EqualFold(key, "User-Agent") {
rt.UserAgent = value
continue
}
if rt.CustomHeaders == nil {
rt.CustomHeaders = map[string]string{}
}
rt.CustomHeaders[key] = value
}
return rt
}
// autoLoginStoreFromOptions assembles an in-memory Authx store containing a
// single auto-login dynamic secret built from the -auth-login-url flag set. The
// captured session is scoped to the login URL's host.
func autoLoginStoreFromOptions(opts *types.Options) (*authx.Authx, error) {
// The login URL may be supplied directly (-auth-login-url) or derived from a
// recording's first navigate step; resolve the host scope from whichever is
// available.
loginURL := opts.AuthLoginURL
if loginURL == "" && opts.AuthRecording != "" {
steps, err := autologin.StepsFromRecordingFile(opts.AuthRecording, opts.AuthUsername, opts.AuthPassword)
if err != nil {
return nil, err
}
loginURL = autologin.FirstNavigateURL(steps)
}
u, err := url.Parse(loginURL)
if err != nil {
return nil, errkit.Wrap(err, "invalid auto-login url")
}
if u.Host == "" {
return nil, errkit.New("auto-login: could not determine host (set -auth-login-url or provide a recording with a navigate step)")
}
reauthCodes, err := parseStatusCodes(opts.AuthReauthStatusCodes)
if err != nil {
return nil, err
}
return &authx.Authx{
ID: "cli-auto-login",
Dynamic: []authx.Dynamic{
{
Secret: &authx.Secret{Domains: []string{u.Host}},
// Session lifecycle: re-run the login on the configured expiry
// status codes and/or once the session ages past the interval, so
// a long scan does not silently continue with a dead session.
RefreshInterval: opts.AuthRefreshInterval,
ReauthStatusCodes: reauthCodes,
AutoLogin: &authx.AutoLoginConfig{
LoginURL: loginURL,
Username: opts.AuthUsername,
Password: opts.AuthPassword,
UsernameField: opts.AuthUsernameField,
PasswordField: opts.AuthPasswordField,
Headless: opts.AuthHeadless,
Recording: opts.AuthRecording,
},
},
},
}, nil
}
// parseStatusCodes parses a comma-separated list of HTTP status codes (e.g.
// "401,403") into a slice of ints. An empty string yields a nil slice.
func parseStatusCodes(raw string) ([]int, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
var codes []int
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
code, err := strconv.Atoi(part)
if err != nil {
return nil, errkit.Wrapf(err, "invalid -auth-reauth-status-codes value %q", part)
}
if code < 100 || code > 599 {
return nil, errkit.New("invalid -auth-reauth-status-codes value %q: must be a valid HTTP status code", part)
}
codes = append(codes, code)
}
return codes, nil
}
// GetLazyAuthFetchCallback returns a lazy fetch callback for auth secrets
func GetLazyAuthFetchCallback(opts *AuthLazyFetchOptions) authx.LazyFetchSecret {
return func(d *authx.Dynamic) error {
-195
View File
@@ -1,195 +0,0 @@
package runner
import (
"net/http"
"os"
"path/filepath"
"testing"
"github.com/projectdiscovery/nuclei/v3/pkg/authprovider/authx"
"github.com/projectdiscovery/nuclei/v3/pkg/authprovider/autologin"
"github.com/projectdiscovery/nuclei/v3/pkg/types"
"github.com/stretchr/testify/require"
)
func TestCaptureSessionToStore(t *testing.T) {
session := &autologin.Session{
Cookies: []*http.Cookie{{Name: "session", Value: "sess-dave"}},
Token: "eyJ.payload.sig",
LocalStorage: map[string]string{"jwt": "eyJ.payload.sig"},
SessionStorage: map[string]string{"csrf": "tok"},
}
store, err := captureSessionToStore(session, "app.example.com")
require.NoError(t, err)
require.Len(t, store.Secrets, 3, "cookies, token and web storage must each map to a secret")
byType := map[string]authx.Secret{}
for _, s := range store.Secrets {
byType[s.Type] = s
require.Equal(t, []string{"app.example.com"}, s.Domains, "every secret is scoped to the login host")
require.NoError(t, s.Validate())
}
require.Equal(t, "session", byType[string(authx.CookiesAuth)].Cookies[0].Key)
require.Equal(t, "eyJ.payload.sig", byType[string(authx.BearerTokenAuth)].Token)
ws := byType[string(authx.WebStorageAuth)]
require.Equal(t, "eyJ.payload.sig", ws.LocalStorage["jwt"])
require.Equal(t, "tok", ws.SessionStorage["csrf"])
// The web storage secret must surface as a BrowserStorageProvider so the
// headless engine can seed it into scanned pages.
bsp, ok := ws.GetStrategy().(authx.BrowserStorageProvider)
require.True(t, ok)
local, sess := bsp.WebStorage()
require.Equal(t, "eyJ.payload.sig", local["jwt"])
require.Equal(t, "tok", sess["csrf"])
}
func TestCaptureSessionToStore_StorageOnly(t *testing.T) {
// A pure token-in-localStorage SPA: no cookies, no extracted token, only
// web storage — it must still produce a usable store.
session := &autologin.Session{LocalStorage: map[string]string{"jwt": "x"}}
store, err := captureSessionToStore(session, "spa.example.com")
require.NoError(t, err)
require.Len(t, store.Secrets, 1)
require.Equal(t, string(authx.WebStorageAuth), store.Secrets[0].Type)
}
func TestCaptureSessionToStore_Empty(t *testing.T) {
_, err := captureSessionToStore(&autologin.Session{}, "app.example.com")
require.Error(t, err, "an empty session must not produce a store")
}
func TestAutoLoginStoreFromOptions_RecordingDerivesHost(t *testing.T) {
recording := `{"steps": [
{"type": "navigate", "url": "https://recorded.example.com/signin"},
{"type": "change", "value": "bob", "selectors": [["#user"]]}
]}`
path := filepath.Join(t.TempDir(), "flow.json")
require.NoError(t, os.WriteFile(path, []byte(recording), 0o600))
// No -auth-login-url: the host scope must come from the recording.
opts := &types.Options{AuthRecording: path, AuthUsername: "bob", AuthPassword: "p"}
store, err := autoLoginStoreFromOptions(opts)
require.NoError(t, err)
require.Len(t, store.Dynamic, 1)
require.Equal(t, []string{"recorded.example.com"}, store.Dynamic[0].Domains)
require.Equal(t, path, store.Dynamic[0].AutoLogin.Recording)
}
func TestAutoLoginStoreFromOptions(t *testing.T) {
opts := &types.Options{
AuthLoginURL: "https://app.example.com:8443/login",
AuthUsername: "alice",
AuthPassword: "s3cr3t",
AuthUsernameField: "email",
AuthHeadless: true,
}
store, err := autoLoginStoreFromOptions(opts)
require.NoError(t, err)
require.Len(t, store.Dynamic, 1)
dyn := store.Dynamic[0]
require.NotNil(t, dyn.Secret)
require.Equal(t, []string{"app.example.com:8443"}, dyn.Domains, "session should be scoped to the login host")
require.NotNil(t, dyn.AutoLogin)
require.Equal(t, "https://app.example.com:8443/login", dyn.AutoLogin.LoginURL)
require.Equal(t, "alice", dyn.AutoLogin.Username)
require.Equal(t, "email", dyn.AutoLogin.UsernameField)
require.True(t, dyn.AutoLogin.Headless)
// The assembled store must validate as a real auto-login dynamic.
require.NoError(t, dyn.Validate())
}
func TestAutoLoginStoreFromOptions_InvalidURL(t *testing.T) {
_, err := autoLoginStoreFromOptions(&types.Options{AuthLoginURL: "://nohost"})
require.Error(t, err)
}
func TestAutoLoginStoreFromOptions_SessionLifecycle(t *testing.T) {
opts := &types.Options{
AuthLoginURL: "https://app.example.com/login",
AuthUsername: "alice",
AuthPassword: "s3cr3t",
AuthReauthStatusCodes: "401, 403",
AuthRefreshInterval: "15m",
}
store, err := autoLoginStoreFromOptions(opts)
require.NoError(t, err)
dyn := store.Dynamic[0]
require.Equal(t, []int{401, 403}, dyn.ReauthStatusCodes, "reauth status codes must be threaded onto the dynamic")
require.Equal(t, "15m", dyn.RefreshInterval)
// The lifecycle-configured store must validate (refresh-interval is parsed).
require.NoError(t, dyn.Validate())
}
func TestAutoLoginStoreFromOptions_InvalidReauthCodes(t *testing.T) {
_, err := autoLoginStoreFromOptions(&types.Options{
AuthLoginURL: "https://app.example.com/login",
AuthPassword: "s3cr3t",
AuthReauthStatusCodes: "401,nope",
})
require.Error(t, err, "non-numeric reauth status code must be rejected")
}
func TestParseStatusCodes(t *testing.T) {
cases := []struct {
in string
want []int
wantErr bool
}{
{"", nil, false},
{" ", nil, false},
{"401", []int{401}, false},
{"401,403, 419", []int{401, 403, 419}, false},
{"401,,403", []int{401, 403}, false},
{"abc", nil, true},
{"99", nil, true},
{"600", nil, true},
}
for _, tc := range cases {
got, err := parseStatusCodes(tc.in)
if tc.wantErr {
require.Error(t, err, "input %q", tc.in)
continue
}
require.NoError(t, err, "input %q", tc.in)
require.Equal(t, tc.want, got, "input %q", tc.in)
}
}
func TestBuildAutoLoginRuntimeOptions(t *testing.T) {
opts := &types.Options{
CustomHeaders: []string{"User-Agent: NucleiScan/1.0", "X-Env: staging", "malformed-header"},
AliveHttpProxy: "http://127.0.0.1:8080",
CDPEndpoint: "ws://127.0.0.1:9222",
UseInstalledChrome: true,
ShowBrowser: true,
}
rt := buildAutoLoginRuntimeOptions(opts)
require.Equal(t, "NucleiScan/1.0", rt.UserAgent, "User-Agent should be split out of custom headers")
require.Equal(t, "staging", rt.CustomHeaders["X-Env"])
require.NotContains(t, rt.CustomHeaders, "User-Agent", "UA must not be duplicated into custom headers")
require.Equal(t, "http://127.0.0.1:8080", rt.Proxy)
require.Equal(t, "ws://127.0.0.1:9222", rt.CDPEndpoint)
require.True(t, rt.UseInstalledChrome)
require.True(t, rt.ShowBrowser)
}
func TestBuildAutoLoginRuntimeOptions_SocksProxyFallback(t *testing.T) {
// With only a SOCKS proxy configured, the auto-login must still pick it up
// (regression: previously only AliveHttpProxy was threaded).
rt := buildAutoLoginRuntimeOptions(&types.Options{
AliveSocksProxy: "socks5://127.0.0.1:1080",
})
require.Equal(t, "socks5://127.0.0.1:1080", rt.Proxy)
// HTTP proxy takes precedence when both are set.
rt = buildAutoLoginRuntimeOptions(&types.Options{
AliveHttpProxy: "http://127.0.0.1:8080",
AliveSocksProxy: "socks5://127.0.0.1:1080",
})
require.Equal(t, "http://127.0.0.1:8080", rt.Proxy)
}
+1 -1
View File
@@ -49,7 +49,7 @@ func TestParseHeadlessOptionalArguments(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
strsl := goflags.StringSlice{}
for _, v := range strings.Split(tt.input, ",") {
for v := range strings.SplitSeq(tt.input, ",") {
//nolint
strsl.Set(v)
}
+2 -3
View File
@@ -543,9 +543,8 @@ func splitPorts(s string) []string {
if s == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
var out []string
for p := range strings.SplitSeq(s, ",") {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
+20 -77
View File
@@ -108,7 +108,6 @@ type Runner struct {
httpApiEndpoint *httpapi.Server
fuzzStats *fuzzStats.Tracker
dastServer *server.DASTServer
authProvider authprovider.AuthProvider
}
// New creates a new client for running the enumeration process.
@@ -123,6 +122,10 @@ func New(options *types.Options) (*Runner, error) {
os.Exit(0)
}
if options.Verbose || options.VerboseVerbose {
LogDirectoryInfo(runner.Logger)
}
// Version check by default
if config.DefaultConfig.CanCheckForUpdates() {
if err := installer.NucleiVersionCheck(); err != nil {
@@ -490,11 +493,6 @@ func (r *Runner) Close() {
if r.browser != nil {
r.browser.Close()
}
// Drop any in-memory auto-login session so nuclei does not retain live
// session material after the scan (server-side sessions expire on their own).
if r.authProvider != nil {
r.authProvider.Close()
}
if r.tmpDir != "" {
_ = os.RemoveAll(r.tmpDir)
}
@@ -635,79 +633,24 @@ func (r *Runner) RunEnumeration() error {
executorOpts.ExportReqURLPattern = true
}
if (len(r.options.SecretsFile) > 0 || r.options.AuthLoginURL != "" || r.options.AuthRecording != "" || r.options.AuthCapture) && !r.options.Validate {
autoLoginOpts := buildAutoLoginRuntimeOptions(r.options)
var providers []authprovider.AuthProvider
if len(r.options.SecretsFile) > 0 {
// Clone options so GetAuthTmplStore can modify them without affecting the original
authOptions := r.options.Copy()
authTmplStore, err := GetAuthTmplStore(authOptions, r.catalog, executorOpts)
if err != nil {
return errors.Wrap(err, "failed to load dynamic auth templates")
}
authOpts := &authprovider.AuthProviderOptions{SecretsFiles: r.options.SecretsFile}
authOpts.LazyFetchSecret = GetLazyAuthFetchCallback(&AuthLazyFetchOptions{
TemplateStore: authTmplStore,
ExecOpts: executorOpts,
})
authOpts.AutoLoginOptions = autoLoginOpts
fileProvider, err := authprovider.NewAuthProvider(authOpts)
if err != nil {
return errors.Wrap(err, "could not create auth provider")
}
providers = append(providers, fileProvider)
if len(r.options.SecretsFile) > 0 && !r.options.Validate {
// Clone options so GetAuthTmplStore can modify them without affecting the original
authOptions := r.options.Copy()
authTmplStore, err := GetAuthTmplStore(authOptions, r.catalog, executorOpts)
if err != nil {
return errors.Wrap(err, "failed to load dynamic auth templates")
}
// Turnkey auto-login from CLI flags (-auth-login-url): build an in-memory
// auto-login secret scoped to the login host, no secrets file needed.
if r.options.AuthLoginURL != "" || r.options.AuthRecording != "" {
engine := "http"
if r.options.AuthHeadless || r.options.AuthRecording != "" {
engine = "headless"
}
target := r.options.AuthLoginURL
if target == "" {
target = r.options.AuthRecording
}
r.Logger.Info().Msgf("Auto-login enabled (%s engine) for %s", engine, target)
store, err := autoLoginStoreFromOptions(r.options)
if err != nil {
return errors.Wrap(err, "could not build auto-login auth provider")
}
cliProvider, err := authprovider.NewStoreAuthProvider(store, nil, autoLoginOpts)
if err != nil {
return errors.Wrap(err, "could not create auto-login auth provider")
}
providers = append(providers, cliProvider)
authOpts := &authprovider.AuthProviderOptions{SecretsFiles: r.options.SecretsFile}
authOpts.LazyFetchSecret = GetLazyAuthFetchCallback(&AuthLazyFetchOptions{
TemplateStore: authTmplStore,
ExecOpts: executorOpts,
})
// initialize auth provider
provider, err := authprovider.NewAuthProvider(authOpts)
if err != nil {
return errors.Wrap(err, "could not create auth provider")
}
// Capture-once: open a visible browser for a one-time manual login and
// build a static session store from the captured cookies/token.
if r.options.AuthCapture {
if r.options.AuthLoginURL == "" {
return errors.New("-auth-capture requires -auth-login-url (the page to open for manual login)")
}
r.Logger.Info().Msgf("Capture-once login enabled for %s", r.options.AuthLoginURL)
store, err := captureOnceStoreFromOptions(r.options)
if err != nil {
return errors.Wrap(err, "could not capture login session")
}
captureProvider, err := authprovider.NewStoreAuthProvider(store, nil, autoLoginOpts)
if err != nil {
return errors.Wrap(err, "could not create capture-once auth provider")
}
providers = append(providers, captureProvider)
}
switch len(providers) {
case 1:
executorOpts.AuthProvider = providers[0]
default:
executorOpts.AuthProvider = authprovider.NewMultiAuthProvider(providers...)
}
// Retained so Runner.Close can drop captured session material on exit.
r.authProvider = executorOpts.AuthProvider
executorOpts.AuthProvider = provider
}
if r.options.ShouldUseHostError() {
+4 -1
View File
@@ -24,7 +24,10 @@
{{binary}} -tags cve -exclude-templates http/cves/2020/
{{binary}} -tags cve -exclude-templates http/cves/2020/CVE-2020-9757.yaml
{{binary}} -tags cve -exclude-templates http/cves/2020/CVE-2020-9757.yaml -exclude-templates http/cves/2021/
{{binary}} -tags cve -include-tags dos,fuzz
# Skipped until the next nuclei release ships #7536: release still force-includes
# via -include-tags (bypassing -tags), so release/current load counts diverge by design.
# Covered by pkg/catalog/index filter unit tests in the meantime.
# {{binary}} -tags cve -include-tags dos,fuzz
{{binary}} -tags cve -ntv 8.8.8,8.8.9
{{binary}} -tags cve -severity high
{{binary}} -tags cve,dos,fuzz
+10
View File
@@ -24,6 +24,16 @@ func TestDNS(t *testing.T) {
}
})
t.Run("Duration", func(t *testing.T) {
results, err := testutils.RunNucleiTemplateAndGetResults("protocols/dns/duration.yaml", "one.one.one.one", suite.debug)
if err != nil {
t.Fatalf("dns duration request failed: %v", err)
}
if err := expectResultsCount(results, 1); err != nil {
t.Fatal(err)
}
})
t.Run("AAAA", func(t *testing.T) {
results, err := testutils.RunNucleiTemplateAndGetResults("protocols/dns/aaaa.yaml", "one.one.one.one", suite.debug)
if err != nil {
@@ -0,0 +1,9 @@
//go:build integration
// +build integration
package integration_test
import "time"
// Keep duration assertions above the timer granularity of fast local sockets on Windows.
const integrationDurationObservationDelay = 10 * time.Millisecond
-164
View File
@@ -8,9 +8,6 @@ import (
"net/http"
"net/http/httptest"
"net/url"
"os"
"runtime"
"strings"
"github.com/julienschmidt/httprouter"
"github.com/projectdiscovery/nuclei/v3/internal/tests/testutils"
@@ -18,28 +15,6 @@ import (
"github.com/projectdiscovery/nuclei/v3/pkg/utils/json"
)
func init() {
// The analyzer-driven DAST integration cases each fire a heavy burst of
// differential/heuristic probes through the full CLI against the in-process
// playground. On the Windows CI runner this is too slow/flaky and times out
// to zero findings (consistently across reruns), while the analyzer logic
// itself is already covered cross-platform by the pkg/fuzz/analyzers engine
// and e2e tests (the Tests (windows-latest) job runs them and passes). Skip
// only the binary-level analyzer cases on Windows to keep CI green without
// dropping real coverage.
if runtime.GOOS != "windows" {
return
}
filtered := make([]integrationCase, 0, len(fuzzingTestCases))
for _, c := range fuzzingTestCases {
if strings.HasPrefix(c.Path, "fuzz/analyzer-") {
continue
}
filtered = append(filtered, c)
}
fuzzingTestCases = filtered
}
const (
targetFile = "fuzz/testData/ginandjuice.proxify.yaml"
)
@@ -61,145 +36,6 @@ var fuzzingTestCases = []integrationCase{
{Path: "fuzz/fuzz-body-params-sqli.yaml", TestCase: &genericFuzzTestCase{expectedResults: 1}},
{Path: "fuzz/fuzz-body-xml-sqli.yaml", TestCase: &genericFuzzTestCase{expectedResults: 1}},
{Path: "fuzz/fuzz-body-generic-sqli.yaml", TestCase: &genericFuzzTestCase{expectedResults: 4}},
// Analyzer-driven DAST cases: each runs a real fuzzing template whose
// detection is delegated to a built-in analyzer, against the dedicated
// analyzer bench in the fuzz playground. A finding is produced only when the
// analyzer (not a static matcher) confirms the vulnerability.
{Path: "fuzz/analyzer-sqli.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/sqli?q=en", expectedResults: 1}},
{Path: "fuzz/analyzer-ssti.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/ssti?q=test", expectedResults: 1}},
{Path: "fuzz/analyzer-lfi.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/lfi?q=home.txt", expectedResults: 1}},
{Path: "fuzz/analyzer-cmdi.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/cmdi?q=127.0.0.1", expectedResults: 1}},
{Path: "fuzz/analyzer-ssrf.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/ssrf?q=https://example.com/a.png", expectedResults: 1}},
{Path: "fuzz/analyzer-open-redirect.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/redirect?q=/dashboard", expectedResults: 1}},
{Path: "fuzz/analyzer-crlf.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/crlf?q=/home", expectedResults: 1}},
{Path: "fuzz/analyzer-cors.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/cors?q=x", expectedResults: 1}},
{Path: "fuzz/analyzer-host-header.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/host-header?q=x", expectedResults: 1}},
// Negative cases: the same analyzer templates against benign routes must
// produce zero findings (false-positive guard at the CLI level).
{Path: "fuzz/analyzer-sqli.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/safe/reflect?q=en", expectedResults: 0}},
{Path: "fuzz/analyzer-ssti.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/safe/reflect?q=test", expectedResults: 0}},
{Path: "fuzz/analyzer-lfi.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/safe/reflect?q=home.txt", expectedResults: 0}},
{Path: "fuzz/analyzer-cmdi.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/safe/reflect?q=127.0.0.1", expectedResults: 0}},
{Path: "fuzz/analyzer-ssrf.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/safe/reflect?q=https://example.com/a.png", expectedResults: 0}},
{Path: "fuzz/analyzer-open-redirect.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/safe/redirect?q=/dashboard", expectedResults: 0}},
{Path: "fuzz/analyzer-crlf.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/safe/headers?q=/home", expectedResults: 0}},
{Path: "fuzz/analyzer-cors.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/safe/cors?q=x", expectedResults: 0}},
{Path: "fuzz/analyzer-host-header.yaml", TestCase: &analyzerFuzzTestCase{route: "/analyzer/safe/host?q=x", expectedResults: 0}},
// Non-query positions: each template fuzzes a different request component
// (path / header / body / cookie) of a captured request and relies on an
// analyzer for detection. Driven via the proxify-format capture file.
{Path: "fuzz/analyzer-path-sqli.yaml", TestCase: &analyzerPositionTestCase{expectedResults: 1}},
{Path: "fuzz/analyzer-header-sqli.yaml", TestCase: &analyzerPositionTestCase{expectedResults: 1}},
{Path: "fuzz/analyzer-body-sqli.yaml", TestCase: &analyzerPositionTestCase{expectedResults: 1}},
{Path: "fuzz/analyzer-cookie-ssti.yaml", TestCase: &analyzerPositionTestCase{expectedResults: 1}},
// Crawl-to-fuzz via the katana input format: each analyzer template is driven
// against a one-line katana JSONL crawl entry for the matching playground
// endpoint, exercising the full crawl -> input-format -> fuzz -> analyzer
// pipeline through the CLI (-im katana ... -dast) across GET-query,
// GET-with-cookie and POST-JSON-body crawl shapes. (nuclei DAST scopes
// fuzzing to one request per host, so each case uses its own crawl entry.)
{Path: "fuzz/analyzer-sqli.yaml", TestCase: &analyzerKatanaTestCase{method: "GET", endpoint: "http://localhost:8082/analyzer/sqli?q=en", expectedResults: 1}},
{Path: "fuzz/analyzer-ssti.yaml", TestCase: &analyzerKatanaTestCase{method: "GET", endpoint: "http://localhost:8082/analyzer/ssti?q=test", expectedResults: 1}},
{Path: "fuzz/analyzer-lfi.yaml", TestCase: &analyzerKatanaTestCase{method: "GET", endpoint: "http://localhost:8082/analyzer/lfi?q=home.txt", expectedResults: 1}},
{Path: "fuzz/analyzer-cmdi.yaml", TestCase: &analyzerKatanaTestCase{method: "GET", endpoint: "http://localhost:8082/analyzer/cmdi?q=127.0.0.1", expectedResults: 1}},
{Path: "fuzz/analyzer-ssrf.yaml", TestCase: &analyzerKatanaTestCase{method: "GET", endpoint: "http://localhost:8082/analyzer/ssrf?q=https://example.com/a.png", expectedResults: 1}},
{Path: "fuzz/analyzer-open-redirect.yaml", TestCase: &analyzerKatanaTestCase{method: "GET", endpoint: "http://localhost:8082/analyzer/redirect?q=/dashboard", expectedResults: 1}},
{Path: "fuzz/analyzer-crlf.yaml", TestCase: &analyzerKatanaTestCase{method: "GET", endpoint: "http://localhost:8082/analyzer/crlf?q=/home", expectedResults: 1}},
{Path: "fuzz/analyzer-cors.yaml", TestCase: &analyzerKatanaTestCase{method: "GET", endpoint: "http://localhost:8082/analyzer/cors?q=x", expectedResults: 1}},
{Path: "fuzz/analyzer-host-header.yaml", TestCase: &analyzerKatanaTestCase{method: "GET", endpoint: "http://localhost:8082/analyzer/host-header?q=x", expectedResults: 1}},
{Path: "fuzz/analyzer-cookie-ssti.yaml", TestCase: &analyzerKatanaTestCase{method: "GET", endpoint: "http://localhost:8082/analyzer/cookie/ssti", headers: map[string]string{"Cookie": "lang=en"}, expectedResults: 1}},
{Path: "fuzz/analyzer-body-sqli.yaml", TestCase: &analyzerKatanaTestCase{method: "POST", endpoint: "http://localhost:8082/analyzer/body/sqli", headers: map[string]string{"Content-Type": "application/json"}, body: `{"name":"en"}`, expectedResults: 1}},
}
// analyzerKatanaTestCase drives an analyzer template against a katana JSONL
// crawl entry for the matching playground endpoint, exercising the full crawl
// -> input-format -> fuzz -> analyzer pipeline through the CLI
// (-im katana ... -dast).
type analyzerKatanaTestCase struct {
method string
endpoint string
headers map[string]string
body string
expectedResults int
}
func (a *analyzerKatanaTestCase) Execute(filePath string) error {
crawlFile, cleanup, err := writeKatanaCrawlEntry(a.method, a.endpoint, a.headers, a.body)
if err != nil {
return err
}
defer cleanup()
results, err := testutils.RunNucleiWithArgsAndGetResults(debug, "-t", filePath, "-l", crawlFile, "-im", "katana", "-dast")
if err != nil {
return err
}
return expectResultsCount(results, a.expectedResults)
}
// writeKatanaCrawlEntry writes a single katana JSONL crawl record to a temp file
// and returns its path plus a cleanup func.
func writeKatanaCrawlEntry(method, endpoint string, headers map[string]string, body string) (string, func(), error) {
record := map[string]any{
"request": map[string]any{
"method": method,
"endpoint": endpoint,
"headers": headers,
"body": body,
},
}
line, err := json.Marshal(record)
if err != nil {
return "", func() {}, err
}
f, err := os.CreateTemp("", "analyzer-katana-*.jsonl")
if err != nil {
return "", func() {}, err
}
if _, err := f.Write(append(line, '\n')); err != nil {
_ = f.Close()
_ = os.Remove(f.Name())
return "", func() {}, err
}
_ = f.Close()
return f.Name(), func() { _ = os.Remove(f.Name()) }, nil
}
const analyzerPositionsTargetFile = "fuzz/testData/analyzer-positions.proxify.yaml"
// analyzerPositionTestCase runs an analyzer-driven template that fuzzes a
// non-query component against the captured analyzer-bench requests. Only the
// request matching the template's fuzzed component produces a finding.
type analyzerPositionTestCase struct {
expectedResults int
}
func (a *analyzerPositionTestCase) Execute(filePath string) error {
results, err := testutils.RunNucleiWithArgsAndGetResults(debug, "-t", filePath, "-l", analyzerPositionsTargetFile, "-im", "yaml", "-dast")
if err != nil {
return err
}
return expectResultsCount(results, a.expectedResults)
}
// analyzerFuzzTestCase runs an analyzer-driven fuzzing template against the
// running fuzz playground (localhost:8082) and asserts the number of findings.
type analyzerFuzzTestCase struct {
route string
expectedResults int
}
func (a *analyzerFuzzTestCase) Execute(filePath string) error {
target := "http://localhost:8082" + a.route
results, err := testutils.RunNucleiTemplateAndGetResults(filePath, target, debug, "-dast")
if err != nil {
return err
}
return expectResultsCount(results, a.expectedResults)
}
type genericFuzzTestCase struct {
@@ -16,6 +16,7 @@ import (
var headlessTestcases = []integrationCase{
{Path: "protocols/headless/headless-basic.yaml", TestCase: &headlessBasic{}},
{Path: "protocols/headless/headless-duration.yaml", TestCase: &headlessBasic{}},
{Path: "protocols/headless/headless-waitevent.yaml", TestCase: &headlessBasic{}},
{Path: "protocols/headless/headless-dsl.yaml", TestCase: &headlessBasic{}},
{Path: "protocols/headless/headless-self-contained.yaml", TestCase: &headlessSelfContained{}},
+28 -5
View File
@@ -75,6 +75,7 @@ var httpTestcases = []integrationCase{
{Path: "protocols/http/variable-dsl-function.yaml", TestCase: &httpVariableDSLFunction{}},
{Path: "protocols/http/get-override-sni.yaml", TestCase: &httpSniAnnotation{}},
{Path: "protocols/http/get-sni.yaml", TestCase: &customCLISNI{}},
{Path: "protocols/http/tls-metadata.yaml", TestCase: &httpTLSMetadata{}},
{Path: "protocols/http/redirect-match-url.yaml", TestCase: &httpRedirectMatchURL{}},
{Path: "protocols/http/get-sni-unsafe.yaml", TestCase: &customCLISNIUnsafe{}},
{Path: "protocols/http/annotation-timeout.yaml", TestCase: &annotationTimeout{}},
@@ -829,7 +830,7 @@ func (h *httpRawUnsafePath) Execute(filepath string) error {
}
actual := []string{}
for _, v := range strings.Split(results, "\n") {
for v := range strings.SplitSeq(results, "\n") {
if strings.Contains(v, "GET") {
parts := strings.Fields(v)
if len(parts) == 3 {
@@ -877,7 +878,7 @@ func (h *httpPaths) Execute(filepath string) error {
}
actual := []string{}
for _, v := range strings.Split(results, "\n") {
for v := range strings.SplitSeq(results, "\n") {
if strings.Contains(v, "GET") {
parts := strings.Fields(v)
if len(parts) == 3 {
@@ -1394,7 +1395,7 @@ func (h *httpVariableDSLFunction) Execute(filePath string) error {
}
actual := []string{}
for _, v := range strings.Split(results, "\n") {
for v := range strings.SplitSeq(results, "\n") {
if strings.Contains(v, "GET") {
parts := strings.Fields(v)
if len(parts) == 3 {
@@ -1431,6 +1432,28 @@ func (h *customCLISNI) Execute(filePath string) error {
return expectResultsCount(results, 1)
}
type httpTLSMetadata struct{}
// Execute verifies TLS handshake metadata from the same HTTP connection is matchable.
func (h *httpTLSMetadata) Execute(filePath string) error {
router := httprouter.New()
router.GET("/", func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
if r.TLS != nil && r.TLS.ServerName == "test" {
_, _ = w.Write([]byte("test-ok"))
return
}
_, _ = w.Write([]byte("test-ko"))
})
ts := httptest.NewTLSServer(router)
defer ts.Close()
results, err := testutils.RunNucleiTemplateAndGetResults(filePath, ts.URL, debug, "-sni", "test")
if err != nil {
return err
}
return expectResultsCount(results, 1)
}
type httpSniAnnotation struct{}
// Execute executes a test case and returns an error if occurred
@@ -1763,7 +1786,7 @@ func (h *httpRawPathSingleSlash) Execute(filepath string) error {
}
var actual string
for _, v := range strings.Split(results, "\n") {
for v := range strings.SplitSeq(results, "\n") {
if strings.Contains(v, "GET") {
parts := strings.Fields(v)
if len(parts) == 3 {
@@ -1788,7 +1811,7 @@ func (h *httpRawUnsafePathSingleSlash) Execute(filepath string) error {
}
var actual string
for _, v := range strings.Split(results, "\n") {
for v := range strings.SplitSeq(results, "\n") {
if strings.Contains(v, "GET") {
parts := strings.Fields(v)
if len(parts) == 3 {
@@ -0,0 +1,61 @@
//go:build integration
// +build integration
package integration_test
import (
"fmt"
"net"
"google.golang.org/grpc"
"google.golang.org/grpc/health"
healthpb "google.golang.org/grpc/health/grpc_health_v1"
"google.golang.org/grpc/reflection"
)
// startGRPCHealthServer starts an in-process gRPC server exposing the standard
// health service (status SERVING) and server reflection on a random loopback
// port. It returns the "host:port" address and a stop func.
func startGRPCHealthServer() (string, func(), error) {
lis, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return "", nil, fmt.Errorf("grpc health listen: %w", err)
}
srv := grpc.NewServer()
hs := health.NewServer()
hs.SetServingStatus("", healthpb.HealthCheckResponse_SERVING)
healthpb.RegisterHealthServer(srv, hs)
reflection.Register(srv)
go func() { _ = srv.Serve(lis) }()
return lis.Addr().String(), srv.Stop, nil
}
// javascriptGRPCHealth exercises the nuclei/grpc library end to end against a
// real gRPC server via server reflection.
type javascriptGRPCHealth struct{}
func (j *javascriptGRPCHealth) Execute(filePath string) error {
address, stop, err := startGRPCHealthServer()
if err != nil {
return err
}
defer stop()
results, err := runSignedNucleiTemplateAndGetResults(filePath, address, debug)
if err != nil {
return err
}
return expectResultsCount(results, 1)
}
// javascriptGRPCDenied asserts the nuclei/grpc library refuses to dial a host
// on the exclude list (network-policy enforcement) before connecting.
type javascriptGRPCDenied struct{}
func (j *javascriptGRPCDenied) Execute(filePath string) error {
results, err := runSignedNucleiTemplateAndGetResults(filePath, "127.0.0.1", debug, "-eh", "203.0.113.10")
if err != nil {
return err
}
return expectResultsCount(results, 1)
}
@@ -0,0 +1,86 @@
//go:build integration
// +build integration
package integration_test
import (
"io"
"net/http"
"net/http/httptest"
"strings"
)
// startJSHTTPServer starts a local HTTP server exercising the generic paths
// used by the nuclei/http javascript integration templates.
func startJSHTTPServer() *httptest.Server {
mux := http.NewServeMux()
mux.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Test", "ok")
_, _ = io.WriteString(w, "hello")
})
mux.HandleFunc("/echo-header", func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, r.Header.Get("X-Token"))
})
mux.HandleFunc("/echo", func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
_, _ = w.Write(b)
})
mux.HandleFunc("/set-cookie", func(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{Name: "sid", Value: "abc"})
_, _ = io.WriteString(w, "set")
})
mux.HandleFunc("/echo-cookie", func(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie("sid")
if err != nil || c.Value != "abc" {
_, _ = io.WriteString(w, "missing")
return
}
_, _ = io.WriteString(w, "sid-ok")
})
mux.HandleFunc("/redir", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/land", http.StatusFound)
})
mux.HandleFunc("/land", func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, "landed")
})
return httptest.NewServer(mux)
}
type javascriptHTTPGet struct{}
func (j *javascriptHTTPGet) Execute(filePath string) error {
ts := startJSHTTPServer()
defer ts.Close()
// Target as host:port so javascript Host/Port args populate cleanly.
hostPort := strings.TrimPrefix(ts.URL, "http://")
results, err := runSignedNucleiTemplateAndGetResults(filePath, hostPort, debug)
if err != nil {
return err
}
return expectResultsCount(results, 1)
}
type javascriptHTTPClientFlow struct{}
func (j *javascriptHTTPClientFlow) Execute(filePath string) error {
ts := startJSHTTPServer()
defer ts.Close()
hostPort := strings.TrimPrefix(ts.URL, "http://")
results, err := runSignedNucleiTemplateAndGetResults(filePath, hostPort, debug)
if err != nil {
return err
}
return expectResultsCount(results, 1)
}
type javascriptHTTPDenied struct{}
func (j *javascriptHTTPDenied) Execute(filePath string) error {
results, err := runSignedNucleiTemplateAndGetResults(filePath, "127.0.0.1", debug, "-eh", "203.0.113.10")
if err != nil {
return err
}
return expectResultsCount(results, 1)
}
@@ -0,0 +1,66 @@
//go:build integration
// +build integration
package integration_test
import (
"encoding/binary"
"io"
"net"
"time"
)
type javascriptMSSQLFingerprint struct{}
func (j *javascriptMSSQLFingerprint) Execute(filePath string) error {
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return err
}
defer func() { _ = ln.Close() }()
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go handleMSSQLPreloginConn(conn)
}
}()
results, err := runSignedNucleiTemplateAndGetResults(filePath, ln.Addr().String(), debug)
if err != nil {
return err
}
return expectResultsCount(results, 1)
}
func handleMSSQLPreloginConn(conn net.Conn) {
defer func() { _ = conn.Close() }()
_ = conn.SetDeadline(time.Now().Add(5 * time.Second))
buf := make([]byte, 256)
if _, err := io.ReadAtLeast(conn, buf, 8); err != nil {
return
}
_, _ = conn.Write(sampleMSSQLPreloginResponse())
}
func sampleMSSQLPreloginResponse() []byte {
body := []byte{
0x00, 0x00, 0x1f, 0x00, 0x06,
0x01, 0x00, 0x25, 0x00, 0x01,
0x02, 0x00, 0x26, 0x00, 0x01,
0x03, 0x00, 0x27, 0x00, 0x00,
0x04, 0x00, 0x27, 0x00, 0x01,
0x05, 0x00, 0x28, 0x00, 0x00,
0xff,
0x0f, 0x00, 0x07, 0xd0, 0x00, 0x00,
0x02,
0x00,
0x01,
}
header := []byte{0x04, 0x01, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00}
binary.BigEndian.PutUint16(header[2:4], uint16(8+len(body)))
return append(header, body...)
}
@@ -31,13 +31,21 @@ func javascriptGoExecSambaDisabled() bool {
var jsTestcases = []integrationCase{
{Path: "protocols/javascript/redis-pass-brute.yaml", TestCase: &javascriptRedisPassBrute{}, DisableOn: javascriptDockerDisabled, Serial: true},
{Path: "protocols/javascript/redis-lua-script.yaml", TestCase: &javascriptRedisLuaScript{}, DisableOn: javascriptDockerDisabled, Serial: true},
{Path: "protocols/javascript/ssh-server-fingerprint.yaml", TestCase: &javascriptSSHServerFingerprint{}, DisableOn: javascriptDockerDisabled, Serial: true},
{Path: "protocols/javascript/net-multi-step.yaml", TestCase: &networkMultiStep{}},
{Path: "protocols/javascript/net-https.yaml", TestCase: &javascriptNetHttps{}},
{Path: "protocols/javascript/grpc-health.yaml", TestCase: &javascriptGRPCHealth{}},
{Path: "protocols/javascript/grpc-denied.yaml", TestCase: &javascriptGRPCDenied{}},
{Path: "protocols/javascript/http-get.yaml", TestCase: &javascriptHTTPGet{}},
{Path: "protocols/javascript/http-client-flow.yaml", TestCase: &javascriptHTTPClientFlow{}},
{Path: "protocols/javascript/http-denied.yaml", TestCase: &javascriptHTTPDenied{}},
{Path: "protocols/javascript/rsync-test.yaml", TestCase: &javascriptRsyncTest{}, DisableOn: javascriptDockerDisabled, Serial: true},
{Path: "protocols/javascript/vnc-pass-brute.yaml", TestCase: &javascriptVncPassBrute{}, DisableOn: javascriptDockerDisabled, Serial: true},
{Path: "protocols/javascript/postgres-pass-brute.yaml", TestCase: &javascriptPostgresPassBrute{}, DisableOn: javascriptDockerDisabled, Serial: true},
{Path: "protocols/javascript/mysql-connect.yaml", TestCase: &javascriptMySQLConnect{}, DisableOn: javascriptDockerDisabled, Serial: true},
{Path: "protocols/javascript/mysql-fingerprint.yaml", TestCase: &javascriptMySQLFingerprint{}, DisableOn: javascriptDockerDisabled, Serial: true},
{Path: "protocols/javascript/mssql-fingerprint.yaml", TestCase: &javascriptMSSQLFingerprint{}},
{Path: "protocols/javascript/multi-ports.yaml", TestCase: &javascriptMultiPortsSSH{}},
{Path: "protocols/javascript/no-port-args.yaml", TestCase: &javascriptNoPortArgs{}},
{Path: "protocols/javascript/telnet-auth-test.yaml", TestCase: &javascriptTelnetAuthTest{}, DisableOn: javascriptDockerDisabled, Serial: true},
@@ -111,6 +119,16 @@ func (j *javascriptRedisPassBrute) Execute(filePath string) error {
}, javascriptServiceReadyTimeout, 0, nil))
}
type javascriptRedisLuaScript struct{}
func (j *javascriptRedisLuaScript) Execute(filePath string) error {
return runJavascriptDockerCase(filePath, newJavascriptDockerSpec("6379/tcp", &dockertest.RunOptions{
Repository: "redis",
Tag: "latest",
Cmd: []string{"redis-server", "--requirepass", "iamadmin"},
}, javascriptServiceReadyTimeout, 0, nil))
}
type javascriptSSHServerFingerprint struct{}
func (j *javascriptSSHServerFingerprint) Execute(filePath string) error {
@@ -163,6 +181,57 @@ func (j *javascriptMySQLConnect) Execute(filePath string) error {
}, javascriptDatabaseReadyTimeout, 0, mysqlReadyCheck("root", "secret")))
}
type javascriptMySQLFingerprint struct{}
// Execute fingerprints multiple MySQL/MariaDB server versions and asserts the
// extended handshake fields (protocol, version, salt, capabilities, auth plugin).
func (j *javascriptMySQLFingerprint) Execute(filePath string) error {
cases := []struct {
name string
repository string
tag string
env []string
}{
{
name: "mysql-5.7",
repository: "mysql",
tag: "5.7",
env: []string{"MYSQL_ROOT_PASSWORD=secret"},
},
{
name: "mysql-8.0",
repository: "mysql",
tag: "8.0",
env: []string{"MYSQL_ROOT_PASSWORD=secret"},
},
{
name: "mysql-8.4",
repository: "mysql",
tag: "8.4",
env: []string{"MYSQL_ROOT_PASSWORD=secret"},
},
{
name: "mariadb-11.4",
repository: "mariadb",
tag: "11.4",
env: []string{"MARIADB_ROOT_PASSWORD=secret"},
},
}
var errs []error
for _, tc := range cases {
err := runJavascriptDockerCase(filePath, newJavascriptDockerSpec("3306/tcp", &dockertest.RunOptions{
Repository: tc.repository,
Tag: tc.tag,
Env: tc.env,
}, javascriptDatabaseReadyTimeout, 0, mysqlReadyCheck("root", "secret")))
if err != nil {
errs = append(errs, fmt.Errorf("%s: %w", tc.name, err))
}
}
return multierr.Combine(errs...)
}
type javascriptMultiPortsSSH struct{}
func (j *javascriptMultiPortsSSH) Execute(filePath string) error {
@@ -116,6 +116,46 @@ func TestNetwork(t *testing.T) {
}
})
t.Run("Duration", func(t *testing.T) {
errState := &capturedError{}
server := testutils.NewTCPServer(nil, 0, func(conn net.Conn) {
defer func() { _ = conn.Close() }()
data, err := reader.ConnReadNWithTimeout(conn, 5, 5*time.Second)
if err != nil {
errState.Set(err)
return
}
if string(data) == "FIRST" {
time.Sleep(integrationDurationObservationDelay)
_, _ = conn.Write([]byte("PING"))
}
data, err = reader.ConnReadNWithTimeout(conn, 6, 5*time.Second)
if err != nil {
errState.Set(err)
return
}
if string(data) == "SECOND" {
time.Sleep(integrationDurationObservationDelay)
_, _ = conn.Write([]byte("PONG"))
}
_, _ = conn.Write([]byte("NUCLEI"))
})
defer server.Close()
results, err := testutils.RunNucleiTemplateAndGetResults("protocols/network/duration.yaml", server.URL, suite.debug)
if err != nil {
t.Fatalf("duration network request failed: %v", err)
}
if err := errState.Err(); err != nil {
t.Fatal(err)
}
if err := expectResultsCount(results, 1); err != nil {
t.Fatal(err)
}
})
t.Run("SelfContained", func(t *testing.T) {
server := testutils.NewTCPServer(nil, 0, func(conn net.Conn) {
defer func() { _ = conn.Close() }()
+11
View File
@@ -23,6 +23,17 @@ func TestSSL(t *testing.T) {
}
})
t.Run("Duration", func(t *testing.T) {
server := newTLSServer(t, &tls.Config{})
results, err := testutils.RunNucleiTemplateAndGetResults("protocols/ssl/duration.yaml", server.URL, suite.debug)
if err != nil {
t.Fatalf("duration ssl request failed: %v", err)
}
if err := expectResultsCount(results, 1); err != nil {
t.Fatal(err)
}
})
t.Run("BasicZTLS", func(t *testing.T) {
server := newTLSServer(t, &tls.Config{})
results, err := testutils.RunNucleiTemplateAndGetResults("protocols/ssl/basic-ztls.yaml", server.URL, suite.debug, "-ztls")
@@ -1,35 +0,0 @@
id: analyzer-body-sqli
info:
name: Body-position error-based SQLi via analyzer
author: pdteam
severity: high
description: |
Drives the sqli_error analyzer while fuzzing the JSON body "name" field,
proving the analyzer works on the body component through the real pipeline.
http:
- pre-condition:
- type: dsl
dsl:
- 'method == "POST"'
condition: and
payloads:
seed:
- "en"
fuzzing:
- part: body
type: replace
mode: single
keys:
- "name"
fuzz:
- "{{seed}}"
analyzer:
name: sqli_error
matchers:
- type: dsl
dsl:
- "analyzer == true"
@@ -1,29 +0,0 @@
id: analyzer-cmdi
info:
name: OS Command Injection via analyzer
author: pdteam
severity: critical
description: |
Drives the built-in cmdi analyzer through the real fuzzing pipeline. The
analyzer injects shell separators followed by `id` and confirms when the
command output (uid=...gid=...) appears in the response.
http:
- method: GET
path:
- "{{BaseURL}}"
fuzzing:
- part: query
type: replace
mode: single
keys:
- "q"
fuzz:
- "127.0.0.1"
analyzer:
name: cmdi
matchers:
- type: dsl
dsl:
- "analyzer == true"
@@ -1,37 +0,0 @@
id: analyzer-cookie-ssti
info:
name: Cookie-position SSTI via analyzer
author: pdteam
severity: critical
description: |
Drives the ssti analyzer while fuzzing the "lang" cookie value, proving the
analyzer works on the cookie component through the real pipeline. SSTI is
used (not SQLi) because cookie value sanitization strips SQLi quote payloads.
http:
- pre-condition:
- type: dsl
dsl:
- 'method == "GET"'
- 'len(cookie) > 0'
condition: and
payloads:
seed:
- "en"
fuzzing:
- part: cookie
type: replace
mode: single
keys:
- "lang"
fuzz:
- "{{seed}}"
analyzer:
name: ssti
matchers:
- type: dsl
dsl:
- "analyzer == true"
@@ -1,29 +0,0 @@
id: analyzer-cors
info:
name: CORS Misconfiguration via analyzer
author: pdteam
severity: medium
description: |
Drives the built-in cors analyzer through the real fuzzing pipeline. The
analyzer sends an attacker-controlled Origin and confirms when it is
reflected in Access-Control-Allow-Origin together with credentials.
http:
- method: GET
path:
- "{{BaseURL}}"
fuzzing:
- part: query
type: replace
mode: single
keys:
- "q"
fuzz:
- "x"
analyzer:
name: cors
matchers:
- type: dsl
dsl:
- "analyzer == true"
@@ -1,29 +0,0 @@
id: analyzer-crlf
info:
name: CRLF Injection via analyzer
author: pdteam
severity: medium
description: |
Drives the built-in crlf analyzer through the real fuzzing pipeline. The
analyzer injects raw CRLF sequences with canary headers/cookies and confirms
when the response is split to include them.
http:
- method: GET
path:
- "{{BaseURL}}"
fuzzing:
- part: query
type: replace
mode: single
keys:
- "q"
fuzz:
- "/home"
analyzer:
name: crlf
matchers:
- type: dsl
dsl:
- "analyzer == true"
@@ -1,35 +0,0 @@
id: analyzer-header-sqli
info:
name: Header-position error-based SQLi via analyzer
author: pdteam
severity: high
description: |
Drives the sqli_error analyzer while fuzzing the X-Search request header,
proving the analyzer works on the header component through the real pipeline.
http:
- pre-condition:
- type: dsl
dsl:
- 'method == "GET"'
condition: and
payloads:
seed:
- "en"
fuzzing:
- part: header
type: replace
mode: single
keys:
- "X-Search"
fuzz:
- "{{seed}}"
analyzer:
name: sqli_error
matchers:
- type: dsl
dsl:
- "analyzer == true"
@@ -1,29 +0,0 @@
id: analyzer-host-header
info:
name: Host Header Injection via analyzer
author: pdteam
severity: medium
description: |
Drives the built-in host_header_injection analyzer through the real fuzzing
pipeline. The analyzer overrides Host / X-Forwarded-Host with a canary and
confirms when it is reflected back in an absolute URL.
http:
- method: GET
path:
- "{{BaseURL}}"
fuzzing:
- part: query
type: replace
mode: single
keys:
- "q"
fuzz:
- "x"
analyzer:
name: host_header_injection
matchers:
- type: dsl
dsl:
- "analyzer == true"
@@ -1,29 +0,0 @@
id: analyzer-lfi
info:
name: Local File Inclusion via analyzer
author: pdteam
severity: high
description: |
Drives the built-in lfi analyzer through the real fuzzing pipeline. The
analyzer injects path-traversal payloads and confirms a finding when known
file-content signatures (e.g. /etc/passwd, win.ini) appear.
http:
- method: GET
path:
- "{{BaseURL}}"
fuzzing:
- part: query
type: replace
mode: single
keys:
- "q"
fuzz:
- "home.txt"
analyzer:
name: lfi
matchers:
- type: dsl
dsl:
- "analyzer == true"
@@ -1,29 +0,0 @@
id: analyzer-open-redirect
info:
name: Open Redirect via analyzer
author: pdteam
severity: medium
description: |
Drives the built-in open_redirect analyzer through the real fuzzing
pipeline. The analyzer injects an unresolvable canary host and confirms when
the application redirects to it via the Location header or final URL.
http:
- method: GET
path:
- "{{BaseURL}}"
fuzzing:
- part: query
type: replace
mode: single
keys:
- "q"
fuzz:
- "/dashboard"
analyzer:
name: open_redirect
matchers:
- type: dsl
dsl:
- "analyzer == true"
@@ -1,33 +0,0 @@
id: analyzer-path-sqli
info:
name: Path-position error-based SQLi via analyzer
author: pdteam
severity: high
description: |
Drives the sqli_error analyzer while fuzzing a numeric PATH segment, proving
the analyzer works on the path component through the real pipeline.
http:
- pre-condition:
- type: dsl
dsl:
- 'method == "GET"'
condition: and
payloads:
seed:
- "1"
fuzzing:
- part: path
type: replace
mode: single
fuzz:
- "{{seed}}"
analyzer:
name: sqli_error
matchers:
- type: dsl
dsl:
- "analyzer == true"
@@ -1,29 +0,0 @@
id: analyzer-sqli
info:
name: Error-based SQLi via analyzer
author: pdteam
severity: high
description: |
Drives the built-in sqli_error analyzer through the real fuzzing pipeline.
The analyzer injects syntax-breaking tokens and fingerprints DBMS errors; a
finding is raised only when the analyzer confirms the injection.
http:
- method: GET
path:
- "{{BaseURL}}"
fuzzing:
- part: query
type: replace
mode: single
keys:
- "q"
fuzz:
- "1337"
analyzer:
name: sqli_error
matchers:
- type: dsl
dsl:
- "analyzer == true"
@@ -1,29 +0,0 @@
id: analyzer-ssrf
info:
name: Server-Side Request Forgery via analyzer
author: pdteam
severity: high
description: |
Drives the built-in ssrf analyzer through the real fuzzing pipeline. The
analyzer points the parameter at cloud metadata endpoints and confirms when
metadata signatures are reflected in-band.
http:
- method: GET
path:
- "{{BaseURL}}"
fuzzing:
- part: query
type: replace
mode: single
keys:
- "q"
fuzz:
- "https://example.com/a.png"
analyzer:
name: ssrf
matchers:
- type: dsl
dsl:
- "analyzer == true"
@@ -1,29 +0,0 @@
id: analyzer-ssti
info:
name: Server-Side Template Injection via analyzer
author: pdteam
severity: critical
description: |
Drives the built-in ssti analyzer through the real fuzzing pipeline. The
analyzer injects an arithmetic oracle in multiple template syntaxes and only
reports when the engine evaluates the expression.
http:
- method: GET
path:
- "{{BaseURL}}"
fuzzing:
- part: query
type: replace
mode: single
keys:
- "q"
fuzz:
- "1337"
analyzer:
name: ssti
matchers:
- type: dsl
dsl:
- "analyzer == true"
@@ -1,72 +0,0 @@
timestamp: 2024-02-20T19:24:13+05:30
url: http://127.0.0.1:8082/analyzer/path/sqli/75
request:
raw: |+
GET /analyzer/path/sqli/75 HTTP/1.1
Host: 127.0.0.1:8082
Accept-Encoding: gzip
Connection: close
User-Agent: nuclei-fuzz
response:
raw: |+
HTTP/1.1 200 OK
Content-Type: application/json
Connection: close
---
timestamp: 2024-02-20T19:24:13+05:30
url: http://127.0.0.1:8082/analyzer/header/sqli
request:
raw: |+
GET /analyzer/header/sqli HTTP/1.1
Host: 127.0.0.1:8082
Accept-Encoding: gzip
Connection: close
X-Search: en
User-Agent: nuclei-fuzz
response:
raw: |+
HTTP/1.1 200 OK
Content-Type: application/json
Connection: close
---
timestamp: 2024-02-20T19:24:13+05:30
url: http://127.0.0.1:8082/analyzer/body/sqli
request:
raw: |+
POST /analyzer/body/sqli HTTP/1.1
Host: 127.0.0.1:8082
Accept-Encoding: gzip
Connection: close
Content-Type: application/json
Content-Length: 13
User-Agent: nuclei-fuzz
{"name":"en"}
response:
raw: |+
HTTP/1.1 200 OK
Content-Type: application/json
Connection: close
---
timestamp: 2024-02-20T19:24:13+05:30
url: http://127.0.0.1:8082/analyzer/cookie/ssti
request:
raw: |+
GET /analyzer/cookie/ssti HTTP/1.1
Host: 127.0.0.1:8082
Accept-Encoding: gzip
Connection: close
Cookie: lang=en
User-Agent: nuclei-fuzz
response:
raw: |+
HTTP/1.1 200 OK
Content-Type: text/html
Connection: close
@@ -0,0 +1,23 @@
id: dns-duration
info:
name: DNS Duration
author: pdteam
severity: info
flow: dns("duration_dns")
dns:
- id: duration_dns
name: "{{FQDN}}"
type: A
class: inet
recursion: true
retries: 3
matchers:
- type: dsl
condition: and
dsl:
- "duration > 0 && duration < 60"
- "duration_dns_duration > 0 && duration_dns_duration < 60"
- "duration == duration_dns_duration"
@@ -0,0 +1,35 @@
id: headless-duration
info:
name: Headless Duration
author: pdteam
severity: info
tags: headless
flow: headless("duration_headless")
headless:
- id: duration_headless
steps:
- action: navigate
args:
url: "{{BaseURL}}/"
- action: waitload
matchers:
- type: dsl
condition: and
dsl:
- "duration > 0 && duration < 60"
- "duration_1 > 0 && duration_1 < 60"
- "duration_2 > 0 && duration_2 < 60"
- "duration == duration_2"
- "duration_headless_duration > 0 && duration_headless_duration < 60"
- "duration_headless_duration_1 > 0 && duration_headless_duration_1 < 60"
- "duration_headless_duration_2 > 0 && duration_headless_duration_2 < 60"
- "duration_headless_duration == duration"
- "duration_headless_duration_1 == duration_1"
- "duration_headless_duration_2 == duration_2"
- type: word
words:
- "<html>"
matchers-condition: and
@@ -0,0 +1,22 @@
id: http-tls-metadata
info:
name: HTTP TLS metadata from same connection
author: pdteam
severity: info
http:
- method: GET
path:
- "{{BaseURL}}"
matchers-condition: and
matchers:
- type: word
words:
- "test-ok"
- type: dsl
dsl:
- sni == "test"
- tls_version != ""
- cipher != ""
- len(to_string(domains)) > 0 || subject_cn != "" || len(to_string(subject_an)) > 0
@@ -0,0 +1,37 @@
id: grpc-network-policy-denied
info:
name: gRPC Network Policy Denial
author: pdteam
severity: info
description: |
Hardening check for the nuclei/grpc javascript library: dialing a host on
the exclude list (RFC 5737 TEST-NET-3) must be rejected by the network
policy before any connection is made. The error is captured and asserted.
javascript:
- code: |
const grpc = require('nuclei/grpc');
let out = "";
try {
const opts = new grpc.Options();
opts.Plaintext = true;
const client = new grpc.Client("203.0.113.10:50051", opts);
client.Invoke('grpc.health.v1.Health/Check', '{}');
} catch (e) {
out = "" + e;
}
out;
args:
Host: "{{Host}}"
matchers-condition: and
matchers:
- type: dsl
dsl:
- "success == true"
- type: word
part: response
words:
- "network policy"
- "203.0.113.10"
condition: and
@@ -0,0 +1,31 @@
id: grpc-health-check
info:
name: gRPC Health Check
author: pdteam
severity: info
description: |
Exercises the nuclei/grpc javascript library end to end: dial through the
network policy, resolve the schema via server reflection and invoke the
standard health check, expecting a SERVING response.
javascript:
- code: |
const grpc = require('nuclei/grpc');
const opts = new grpc.Options();
opts.Plaintext = true;
const client = new grpc.Client(address, opts);
const resp = client.Invoke('grpc.health.v1.Health/Check', '{"service":""}');
client.Close();
resp;
args:
address: "{{Host}}:{{Port}}"
matchers-condition: and
matchers:
- type: dsl
dsl:
- "success == true"
- type: word
part: response
words:
- "SERVING"
@@ -0,0 +1,30 @@
id: js-http-client-flow
info:
name: nuclei/http client flow
author: pdteam
severity: info
description: |
Exercises nuclei/http Client for Post, cookie jar reuse, and redirect
following against a local HTTP server.
javascript:
- code: |
const http = require('nuclei/http');
const client = new http.Client();
const post = client.Post(BaseURL + '/echo', 'payload');
client.Get(BaseURL + '/set-cookie');
const cookie = client.Get(BaseURL + '/echo-cookie');
const redir = client.Get(BaseURL + '/redir');
post.Body + '|' + cookie.Body + '|' + redir.StatusCode + '|' + redir.Body;
args:
BaseURL: "http://{{Host}}:{{Port}}"
matchers-condition: and
matchers:
- type: dsl
dsl:
- "success == true"
- type: word
part: response
words:
- "payload|sid-ok|200|landed"
@@ -0,0 +1,33 @@
id: js-http-network-policy-denied
info:
name: nuclei/http network policy denial
author: pdteam
severity: info
description: |
Hardening check for nuclei/http: requesting a host on the exclude list
(RFC 5737 TEST-NET-3) must be rejected by network policy before dialing.
javascript:
- code: |
const http = require('nuclei/http');
let out = "";
try {
http.Get('http://203.0.113.10/');
} catch (e) {
out = "" + e;
}
out;
args:
Host: "{{Host}}"
matchers-condition: and
matchers:
- type: dsl
dsl:
- "success == true"
- type: word
part: response
words:
- "network policy"
- "203.0.113.10"
condition: and
@@ -0,0 +1,29 @@
id: js-http-get
info:
name: nuclei/http GET
author: pdteam
severity: info
description: |
Exercises nuclei/http package Get and Client.Get/SetHeader against a local
HTTP server (status, body, response headers, default request header).
javascript:
- code: |
const http = require('nuclei/http');
const a = http.Get(BaseURL + '/hello');
const client = new http.Client();
client.SetHeader('X-Token', 'nuclei');
const b = client.Get(BaseURL + '/echo-header');
a.StatusCode + '|' + a.Body + '|' + a.GetHeader('X-Test') + '|' + b.Body;
args:
BaseURL: "http://{{Host}}:{{Port}}"
matchers-condition: and
matchers:
- type: dsl
dsl:
- "success == true"
- type: word
part: response
words:
- "200|hello|ok|nuclei"
@@ -0,0 +1,42 @@
id: mssql-fingerprint
info:
name: MSSQL Fingerprint Test
author: pdteam
severity: info
javascript:
- pre-condition: |
isPortOpen(Host, Port)
code: |
const mssql = require('nuclei/mssql');
const client = new mssql.MSSQLClient();
const isMssql = client.IsMssql(Host, Port);
const info = client.FingerprintMssql(Host, Port);
const ok = isMssql && info && info.Version && info.Version.length > 0 && info.MajorVersion > 0 && info.EncryptionMode && info.EncryptionMode.length > 0 && info.Protocol === "mssql";
Export({
ok: ok,
isMssql: isMssql,
version: info.Version,
majorVersion: info.MajorVersion,
minorVersion: info.MinorVersion,
buildNumber: info.BuildNumber,
encryptionMode: info.EncryptionMode,
protocol: info.Protocol,
transport: info.Transport,
});
args:
Host: "{{Host}}"
Port: "1433"
matchers:
- type: dsl
dsl:
- "success == true"
- "ok == true"
- "isMssql == true"
- "len(version) > 0"
- "majorVersion > 0"
- 'protocol == "mssql"'
- 'transport == "tcp"'
- "len(encryptionMode) > 0"
@@ -0,0 +1,45 @@
id: mysql-fingerprint
info:
name: MySQL Fingerprint Test
author: pdteam
severity: info
javascript:
- pre-condition: |
isPortOpen(Host, Port)
code: |
const mysql = require('nuclei/mysql');
const client = new mysql.MySQLClient();
const isMySQL = client.IsMySQL(Host, Port);
const info = client.FingerprintMySQL(Host, Port);
const ok = isMySQL && info && info.ProtocolVersion === 10 && info.Version && info.Version.length > 0 && info.Salt && info.Salt.length > 0 && info.CapabilityFlags > 0 && info.Debug && info.Debug.PacketType === 'handshake';
Export({
ok: ok,
isMySQL: isMySQL,
packetType: info.Debug.PacketType,
protocolVersion: info.ProtocolVersion,
version: info.Version,
threadId: info.ThreadID,
authPluginName: info.AuthPluginName,
capabilityFlags: info.CapabilityFlags,
capabilities: info.Capabilities,
status: info.Status,
salt: info.Salt,
});
args:
Host: "{{Host}}"
Port: "3306"
matchers:
- type: dsl
dsl:
- "success == true"
- "ok == true"
- "isMySQL == true"
- 'packetType == "handshake"'
- "protocolVersion == 10"
- "len(version) > 0"
- "capabilityFlags > 0"
- "len(salt) > 0"
- "threadId > 0"
@@ -0,0 +1,29 @@
id: redis-lua-script
info:
name: Redis RunLuaScript - Detect
author: DhiyaneshDK
severity: info
javascript:
- code: |
const redis = require('nuclei/redis');
// Set a key via KEYS with a string and a numeric arg via ARGV: validates
// that keys are passed through and non-string args are not dropped
redis.RunLuaScript(Host, Port, Password, 'return redis.call("set", KEYS[1], ARGV[1] .. ":" .. ARGV[2])', ['testkey'], ['testvalue', 123]);
// Read the value back via KEYS and return it as the response so the
// matcher can assert the combined string was stored correctly
redis.RunLuaScript(Host, Port, Password, 'return redis.call("get", KEYS[1])', ['testkey'], []);
args:
Host: "{{Host}}"
Port: "6379"
# must match the --requirepass value of the dockerized redis instance
# started by internal/tests/integration/javascript_test.go
Password: "iamadmin"
matchers:
- type: dsl
dsl:
- 'success == true && contains(response, "testvalue:123")'
@@ -0,0 +1,48 @@
id: network-duration
info:
name: Network Duration
author: pdteam
severity: info
flow: tcp("duration_network")
network:
- id: duration_network
inputs:
- data: "FIRST"
read: 4
name: first
- data: "SECOND"
read: 4
name: second
host:
- "{{Hostname}}"
read-size: 6
matchers:
- type: dsl
condition: and
dsl:
- "duration > 0 && duration < 60"
- "duration_1 > 0 && duration_1 < 60"
- "duration_2 > 0 && duration_2 < 60"
- "duration == duration_2"
- "duration_network_duration > 0 && duration_network_duration < 60"
- "duration_network_duration_1 > 0 && duration_network_duration_1 < 60"
- "duration_network_duration_2 > 0 && duration_network_duration_2 < 60"
- "duration_network_duration == duration"
- "duration_network_duration_1 == duration_1"
- "duration_network_duration_2 == duration_2"
- type: word
part: first
words:
- "PING"
- type: word
part: second
words:
- "PONG"
- type: word
part: data
words:
- "NUCLEI"
matchers-condition: and
@@ -0,0 +1,20 @@
id: ssl-duration
info:
name: SSL Duration
author: pdteam
severity: info
flow: ssl("duration_ssl")
ssl:
- id: duration_ssl
address: "{{Host}}:{{Port}}"
matchers:
- type: dsl
condition: and
dsl:
- "probe_status == true"
- "duration > 0 && duration < 60"
- "duration_ssl_duration > 0 && duration_ssl_duration < 60"
- "duration == duration_ssl_duration"
@@ -0,0 +1,36 @@
id: websocket-duration
info:
name: WebSocket Duration
author: pdteam
severity: info
flow: websocket("duration_ws")
websocket:
- id: duration_ws
address: "{{Scheme}}://{{Hostname}}"
inputs:
- data: hello
- data: status
matchers:
- type: dsl
condition: and
dsl:
- "duration > 0 && duration < 60"
- "duration_1 > 0 && duration_1 < 60"
- "duration_2 > 0 && duration_2 < 60"
- "duration == duration_2"
- "duration_ws_duration > 0 && duration_ws_duration < 60"
- "duration_ws_duration_1 > 0 && duration_ws_duration_1 < 60"
- "duration_ws_duration_2 > 0 && duration_ws_duration_2 < 60"
- "duration_ws_duration == duration"
- "duration_ws_duration_1 == duration_1"
- "duration_ws_duration_2 == duration_2"
- type: word
part: response
words:
- world
- ready
condition: and
matchers-condition: and
@@ -0,0 +1,19 @@
id: whois-duration
info:
name: WHOIS Duration
author: pdteam
severity: info
flow: whois("duration_whois")
whois:
- id: duration_whois
query: "{{Host}}"
matchers:
- type: dsl
condition: and
dsl:
- "duration > 0 && duration < 60"
- "duration_whois_duration > 0 && duration_whois_duration < 60"
- "duration == duration_whois_duration"
@@ -7,6 +7,7 @@ import (
"net"
"strings"
"testing"
"time"
"github.com/gobwas/ws/wsutil"
"github.com/projectdiscovery/nuclei/v3/internal/tests/testutils"
@@ -38,6 +39,37 @@ func TestWebSocket(t *testing.T) {
}
})
t.Run("Duration", func(t *testing.T) {
connHandler := func(conn net.Conn) {
for {
msg, op, err := wsutil.ReadClientData(conn)
if err != nil {
return
}
switch string(msg) {
case "hello":
time.Sleep(integrationDurationObservationDelay)
_ = wsutil.WriteServerMessage(conn, op, []byte("world"))
case "status":
time.Sleep(integrationDurationObservationDelay)
_ = wsutil.WriteServerMessage(conn, op, []byte("ready"))
default:
return
}
}
}
server := testutils.NewWebsocketServer("", connHandler, func(origin string) bool { return true })
defer server.Close()
results, err := testutils.RunNucleiTemplateAndGetResults("protocols/websocket/duration.yaml", strings.ReplaceAll(server.URL, "http", "ws"), suite.debug)
if err != nil {
t.Fatalf("duration websocket request failed: %v", err)
}
if err := expectResultsCount(results, 1); err != nil {
t.Fatal(err)
}
})
t.Run("CSWSH", func(t *testing.T) {
server := testutils.NewWebsocketServer("", func(conn net.Conn) {}, func(origin string) bool { return true })
defer server.Close()
+1
View File
@@ -9,6 +9,7 @@ import (
var whoisTestCases = []integrationCase{
{Path: "protocols/whois/basic.yaml", TestCase: &whoisBasic{}},
{Path: "protocols/whois/duration.yaml", TestCase: &whoisBasic{}},
}
type whoisBasic struct{}
+15
View File
@@ -339,6 +339,21 @@ func WithScanStrategy(strategy string) NucleiSDKOptions {
// OutputWriter
type OutputWriter output.Writer
// WithResultCallback sets a result callback invoked for each finding.
//
// When used with ThreadSafeNucleiEngine.ExecuteNucleiWithOpts / ExecuteNucleiWithOptsCtx,
// the callback is scoped to that execution only (combined with any GlobalResultCallback).
// When used at engine construction time, it behaves like the default result callback list.
func WithResultCallback(callback func(event *output.ResultEvent)) NucleiSDKOptions {
return func(e *NucleiEngine) error {
if callback == nil {
return nil
}
e.resultCallbacks = append(e.resultCallbacks, callback)
return nil
}
}
// UseOutputWriter allows setting custom output writer
// by default a mock writer is used with user defined callback
// if outputWriter is used callback will be ignored
+46 -12
View File
@@ -5,6 +5,7 @@ import (
"time"
"github.com/logrusorgru/aurora/v4"
"github.com/projectdiscovery/nuclei/v3/internal/tests/testutils"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/loader"
"github.com/projectdiscovery/nuclei/v3/pkg/core"
"github.com/projectdiscovery/nuclei/v3/pkg/input/provider"
@@ -22,14 +23,17 @@ import (
// in ThreadSafeNucleiEngine
type unsafeOptions struct {
executerOpts *protocols.ExecutorOptions
engine *core.Engine
}
// createEphemeralObjects creates ephemeral nuclei objects/instances/types
func createEphemeralObjects(ctx context.Context, base *NucleiEngine, opts *types.Options) (*unsafeOptions, error) {
// createEphemeralObjects creates ephemeral nuclei objects/instances/types.
// outputWriter overrides the base engine writer when non-nil (used for per-call callbacks).
func createEphemeralObjects(ctx context.Context, base *NucleiEngine, opts *types.Options, outputWriter output.Writer) (*unsafeOptions, error) {
if outputWriter == nil {
outputWriter = base.customWriter
}
u := &unsafeOptions{}
u.executerOpts = &protocols.ExecutorOptions{
Output: base.customWriter,
Output: outputWriter,
Options: opts,
Progress: base.customProgress,
Catalog: base.catalog,
@@ -40,6 +44,11 @@ func createEphemeralObjects(ctx context.Context, base *NucleiEngine, opts *types
ResumeCfg: types.NewResumeCfg(),
Parser: base.parser,
Browser: base.browserInstance,
// Thread-safe executes can run concurrently with different Output writers.
// The compiled-template cache shallow-copies requests and mutates shared
// ExecutorOptions.Output via UpdateOptions/ApplyNewEngineOptions, which
// would otherwise route all findings to whichever call last won the race.
DoNotCache: true,
}
if opts.ShouldUseHostError() && base.hostErrCache != nil {
u.executerOpts.HostErrorsCache = base.hostErrCache
@@ -52,11 +61,33 @@ func createEphemeralObjects(ctx context.Context, base *NucleiEngine, opts *types
opts.RateLimitDuration = time.Second
}
u.executerOpts.RateLimiter = utils.GetRateLimiter(ctx, opts.RateLimit, opts.RateLimitDuration)
u.engine = core.New(opts)
u.engine.SetExecuterOptions(u.executerOpts)
return u, nil
}
// resolveEphemeralOutput combines the base/global writer with any per-call result callbacks.
// Global callbacks keep working; per-call callbacks are isolated to this execution.
func resolveEphemeralOutput(base, call *NucleiEngine) output.Writer {
out := base.customWriter
if call == nil || len(call.resultCallbacks) == 0 {
return out
}
callbacks := append([]func(event *output.ResultEvent){}, call.resultCallbacks...)
callWriter := testutils.NewMockOutputWriter(call.opts.OmitTemplate)
callWriter.WriteCallback = func(event *output.ResultEvent) {
for _, cb := range callbacks {
if cb != nil {
cb(event)
}
}
}
if out == nil {
return callWriter
}
return output.NewMultiWriter(out, callWriter)
}
// closeEphemeralObjects closes all resources used by ephemeral nuclei objects/instances/types
func closeEphemeralObjects(u *unsafeOptions) {
if u.executerOpts.RateLimiter != nil {
@@ -113,14 +144,15 @@ func (e *ThreadSafeNucleiEngine) GlobalLoadAllTemplates() error {
}
// GlobalResultCallback sets a callback function which will be called for each result
// across all ExecuteNucleiWithOpts invocations. For a callback scoped to a single
// execution, pass WithResultCallback in that call's opts instead.
func (e *ThreadSafeNucleiEngine) GlobalResultCallback(callback func(event *output.ResultEvent)) {
e.eng.resultCallbacks = []func(*output.ResultEvent){callback}
}
// ExecuteNucleiWithOptsCtx executes templates on targets and calls callback on each result(only if results are found)
// This method can be called concurrently and it will use some global resources but can be run parallelly
// by invoking this method with different options and targets
// Note: Not all options are thread-safe. this method will throw error if you try to use non-thread-safe options
// ExecuteNucleiWithOptsCtx executes templates on targets and can be called concurrently.
// Pass WithResultCallback in opts for a per-execution result callback (combined with any
// GlobalResultCallback). Not all options are thread-safe.
func (e *ThreadSafeNucleiEngine) ExecuteNucleiWithOptsCtx(ctx context.Context, targets []string, opts ...NucleiSDKOptions) error {
baseOpts := e.eng.opts.Copy()
tmpEngine := &NucleiEngine{opts: baseOpts, mode: threadSafe}
@@ -131,7 +163,7 @@ func (e *ThreadSafeNucleiEngine) ExecuteNucleiWithOptsCtx(ctx context.Context, t
}
// create ephemeral nuclei objects/instances/types using base nuclei engine
unsafeOpts, err := createEphemeralObjects(ctx, e.eng, tmpEngine.opts)
unsafeOpts, err := createEphemeralObjects(ctx, e.eng, tmpEngine.opts, resolveEphemeralOutput(e.eng, tmpEngine))
if err != nil {
return err
}
@@ -145,7 +177,9 @@ func (e *ThreadSafeNucleiEngine) ExecuteNucleiWithOptsCtx(ctx context.Context, t
}
unsafeOpts.executerOpts.WorkflowLoader = workflowLoader
store, err := loader.New(loader.NewConfig(tmpEngine.opts, e.eng.catalog, unsafeOpts.executerOpts))
loaderConfig := loader.NewConfig(tmpEngine.opts, e.eng.catalog, unsafeOpts.executerOpts)
loaderConfig.MetadataIndex = e.eng.getMetadataIndex()
store, err := loader.New(loaderConfig)
if err != nil {
return errkit.Wrapf(err, "Could not create loader client: %s", err)
}
+73
View File
@@ -0,0 +1,73 @@
package nuclei
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/index"
)
func BenchmarkExecuteNucleiWithOptsCtx(b *testing.B) {
templatePath := filepath.Join(b.TempDir(), "benchmark.yaml")
err := os.WriteFile(templatePath, []byte(`id: benchmark-execute-nuclei-with-opts-ctx
info:
name: Benchmark ExecuteNucleiWithOptsCtx
author: pdteam
severity: info
http:
- method: GET
path:
- "{{BaseURL}}"
matchers:
- type: status
status:
- 418
`), 0o600)
if err != nil {
b.Fatalf("could not write benchmark template: %s", err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
b.Cleanup(server.Close)
metadataIndex, err := index.NewIndex(b.TempDir())
if err != nil {
b.Fatalf("could not create benchmark metadata index: %s", err)
}
engine, err := NewThreadSafeNucleiEngineCtx(
b.Context(),
DisableUpdateCheck(),
WithTemplatesOrWorkflows(TemplateSources{Templates: []string{templatePath}}),
)
if err != nil {
b.Fatalf("could not create thread-safe nuclei engine: %s", err)
}
b.Cleanup(engine.Close)
// Keep benchmark metadata isolated from the user's persistent cache.
engine.eng.metadataIndexOnce.Do(func() {
engine.eng.metadataIndex = metadataIndex
})
targets := []string{server.URL}
if err := engine.ExecuteNucleiWithOptsCtx(b.Context(), targets); err != nil {
b.Fatalf("could not warm up nuclei execution: %s", err)
}
b.ReportAllocs()
b.ResetTimer()
for b.Loop() {
if err := engine.ExecuteNucleiWithOptsCtx(b.Context(), targets); err != nil {
b.Fatalf("could not execute nuclei: %s", err)
}
}
}
+168
View File
@@ -0,0 +1,168 @@
package nuclei_test
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync"
"testing"
nuclei "github.com/projectdiscovery/nuclei/v3/lib"
"github.com/projectdiscovery/nuclei/v3/pkg/output"
"github.com/stretchr/testify/require"
)
func TestThreadSafeWithResultCallbackIsolation(t *testing.T) {
templatePath := writeSDKMatchTemplate(t)
srvA := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("token-A-unique"))
}))
t.Cleanup(srvA.Close)
srvB := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("token-B-unique"))
}))
t.Cleanup(srvB.Close)
ne, err := nuclei.NewThreadSafeNucleiEngineCtx(context.Background())
require.NoError(t, err)
t.Cleanup(ne.Close)
var (
mu sync.Mutex
callbackA []string
callbackB []string
globalURLs []string
globalCount int
errA, errB error
)
ne.GlobalResultCallback(func(event *output.ResultEvent) {
mu.Lock()
defer mu.Unlock()
globalCount++
globalURLs = append(globalURLs, eventMatchedURL(event))
})
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
errA = ne.ExecuteNucleiWithOptsCtx(context.Background(), []string{srvA.URL},
nuclei.WithTemplatesOrWorkflows(nuclei.TemplateSources{Templates: []string{templatePath}}),
nuclei.WithTemplateFilters(nuclei.TemplateFilters{IDs: []string{"sdk-callback-match"}}),
nuclei.WithResultCallback(func(event *output.ResultEvent) {
mu.Lock()
defer mu.Unlock()
callbackA = append(callbackA, eventMatchedURL(event))
}),
)
}()
go func() {
defer wg.Done()
errB = ne.ExecuteNucleiWithOptsCtx(context.Background(), []string{srvB.URL},
nuclei.WithTemplatesOrWorkflows(nuclei.TemplateSources{Templates: []string{templatePath}}),
nuclei.WithTemplateFilters(nuclei.TemplateFilters{IDs: []string{"sdk-callback-match"}}),
nuclei.WithResultCallback(func(event *output.ResultEvent) {
mu.Lock()
defer mu.Unlock()
callbackB = append(callbackB, eventMatchedURL(event))
}),
)
}()
wg.Wait()
require.NoError(t, errA)
require.NoError(t, errB)
mu.Lock()
defer mu.Unlock()
require.NotEmpty(t, callbackA, "per-call callback A should receive results")
require.NotEmpty(t, callbackB, "per-call callback B should receive results")
for _, matched := range callbackA {
require.Contains(t, matched, srvA.URL, "callback A must only see server A")
require.NotContains(t, matched, srvB.URL, "callback A must not see server B")
}
for _, matched := range callbackB {
require.Contains(t, matched, srvB.URL, "callback B must only see server B")
require.NotContains(t, matched, srvA.URL, "callback B must not see server A")
}
// Global callback still sees both executions (no regression).
require.GreaterOrEqual(t, globalCount, 2)
require.NotEmpty(t, globalURLs)
}
func TestThreadSafeGlobalCallbackWithoutPerCallStillWorks(t *testing.T) {
templatePath := writeSDKMatchTemplate(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("token-A-unique"))
}))
t.Cleanup(srv.Close)
ne, err := nuclei.NewThreadSafeNucleiEngineCtx(context.Background())
require.NoError(t, err)
t.Cleanup(ne.Close)
var got int
ne.GlobalResultCallback(func(event *output.ResultEvent) {
got++
})
err = ne.ExecuteNucleiWithOptsCtx(context.Background(), []string{srv.URL},
nuclei.WithTemplatesOrWorkflows(nuclei.TemplateSources{Templates: []string{templatePath}}),
nuclei.WithTemplateFilters(nuclei.TemplateFilters{IDs: []string{"sdk-callback-match"}}),
)
require.NoError(t, err)
require.GreaterOrEqual(t, got, 1, "global callback should still receive results without WithResultCallback")
}
func TestWithResultCallbackNilIsNoop(t *testing.T) {
ne, err := nuclei.NewThreadSafeNucleiEngineCtx(context.Background(), nuclei.WithResultCallback(nil))
require.NoError(t, err)
ne.Close()
}
func writeSDKMatchTemplate(t *testing.T) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "sdk-callback-match.yaml")
content := `id: sdk-callback-match
info:
name: SDK callback match
author: nuclei-sdk-test
severity: info
http:
- method: GET
path:
- "{{BaseURL}}/"
matchers:
- type: word
words:
- "token-"
`
require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
return path
}
// eventMatchedURL prefers Matched/URL over Host (Host omits the port).
func eventMatchedURL(event *output.ResultEvent) string {
if event == nil {
return ""
}
if event.Matched != "" {
return event.Matched
}
if event.URL != "" {
return event.URL
}
return event.Host
}
+32 -1
View File
@@ -11,6 +11,7 @@ import (
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/pkg/authprovider"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog"
metadataindex "github.com/projectdiscovery/nuclei/v3/pkg/catalog/index"
"github.com/projectdiscovery/nuclei/v3/pkg/catalog/loader"
"github.com/projectdiscovery/nuclei/v3/pkg/core"
"github.com/projectdiscovery/nuclei/v3/pkg/input/provider"
@@ -83,6 +84,9 @@ type NucleiEngine struct {
ownsParser bool // true when the engine created the parser and may purge it on Close
authprovider authprovider.AuthProvider
metadataIndex *metadataindex.Index
metadataIndexOnce sync.Once
// unexported meta options
opts *types.Options
interactshOpts *interactsh.Options
@@ -100,6 +104,24 @@ type NucleiEngine struct {
tmpDir string
}
func (e *NucleiEngine) getMetadataIndex() *metadataindex.Index {
e.metadataIndexOnce.Do(func() {
metadataIndex, err := metadataindex.NewDefaultIndex()
if err != nil {
e.Logger.Warning().Msgf("Could not create metadata cache: %v", err)
return
}
e.metadataIndex = metadataIndex
if err := e.metadataIndex.Load(); err != nil {
e.Logger.Warning().Msgf("Could not load metadata cache: %v", err)
}
})
return e.metadataIndex
}
// LoadAllTemplates loads all nuclei template based on given options
func (e *NucleiEngine) LoadAllTemplates() error {
workflowLoader, err := workflow.NewLoader(e.executerOpts)
@@ -108,7 +130,11 @@ func (e *NucleiEngine) LoadAllTemplates() error {
}
e.executerOpts.WorkflowLoader = workflowLoader
e.store, err = loader.New(loader.NewConfig(e.opts, e.catalog, e.executerOpts))
loaderConfig := loader.NewConfig(e.opts, e.catalog, e.executerOpts)
if e.mode == threadSafe {
loaderConfig.MetadataIndex = e.getMetadataIndex()
}
e.store, err = loader.New(loaderConfig)
if err != nil {
return errkit.Wrapf(err, "Could not create loader client: %s", err)
}
@@ -239,6 +265,11 @@ func (e *NucleiEngine) closeInternal() {
if e.httpxClient != nil {
_ = e.httpxClient.Close()
}
if e.metadataIndex != nil {
if err := e.metadataIndex.Save(); err != nil {
e.Logger.Warning().Msgf("Could not save metadata cache: %v", err)
}
}
if e.tmpDir != "" {
_ = os.RemoveAll(e.tmpDir)
}
+21 -18
View File
@@ -18,11 +18,16 @@ var knownLeaks = []goleak.Option{
// net/http transport maintains idle keep-alive connections whose goroutines
// exit on idle timeout or explicit close - not real leaks.
goleak.IgnoreAnyFunction("net/http.(*http2ClientConn).readLoop"),
goleak.IgnoreAnyFunction("net/http.(*persistConn).readLoop"),
goleak.IgnoreAnyFunction("net/http.(*persistConn).writeLoop"),
// expirable LRU cache creates a background goroutine for TTL expiration that persists
// see: https://github.com/hashicorp/golang-lru/blob/770151e9c8cdfae1797826b7b74c33d6f103fbd8/expirable/expirable_lru.go#L79
goleak.IgnoreAnyContainingPkg("github.com/hashicorp/golang-lru/v2/expirable"),
goleak.IgnoreAnyFunction("net/http.(*persistConn).readLoop"),
goleak.IgnoreAnyFunction("net/http.(*persistConn).writeLoop"),
// httpcache leveldb + shared rate limiter + memguardian outlive a single SDK engine.
// Sleep alone is not reliable under -race or when NewNucleiEngineCtx fails before Close.
goleak.IgnoreAnyContainingPkg("github.com/syndtr/goleveldb"),
goleak.IgnoreAnyContainingPkg("github.com/projectdiscovery/ratelimit"),
goleak.IgnoreAnyFunction("github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/protocolstate.StartActiveMemGuardian.func1"),
}
func TestSimpleNuclei(t *testing.T) {
@@ -38,12 +43,12 @@ func TestSimpleNuclei(t *testing.T) {
nuclei.WithTemplateFilters(nuclei.TemplateFilters{ProtocolTypes: "dns"}), // filter dns templates
nuclei.EnableStatsWithOpts(nuclei.StatsOptions{JSON: true}),
)
require.Nil(t, err)
require.NoError(t, err)
defer ne.Close()
ne.LoadTargets([]string{"scanme.sh"}, false) // probe non http/https target is set to false here
// when callback is nil it nuclei will print JSON output to stdout
err = ne.ExecuteWithCallback(nil)
require.Nil(t, err)
defer ne.Close()
require.NoError(t, err)
}
// this is shared test so needs to be run as separate process
@@ -76,14 +81,14 @@ func TestSimpleNucleiRemote(t *testing.T) {
},
),
)
require.Nil(t, err)
require.NoError(t, err)
defer ne.Close()
ne.LoadTargets([]string{"scanme.sh"}, false) // probe non http/https target is set to false here
err = ne.LoadAllTemplates()
require.Nil(t, err, "could not load templates")
require.NoError(t, err, "could not load templates")
// when callback is nil it nuclei will print JSON output to stdout
err = ne.ExecuteWithCallback(nil)
require.Nil(t, err)
defer ne.Close()
require.NoError(t, err)
}
// this is shared test so needs to be run as separate process
if env.GetEnvOrDefault("TestSimpleNucleiRemote", false) {
@@ -108,22 +113,20 @@ func TestThreadSafeNuclei(t *testing.T) {
}()
// create nuclei engine with options
ne, err := nuclei.NewThreadSafeNucleiEngineCtx(context.TODO())
require.Nil(t, err)
require.NoError(t, err)
defer ne.Close()
// scan 1 = run dns templates on scanme.sh
t.Run("scanme.sh", func(t *testing.T) {
err = ne.ExecuteNucleiWithOpts([]string{"scanme.sh"}, nuclei.WithTemplateFilters(nuclei.TemplateFilters{ProtocolTypes: "dns"}))
require.Nil(t, err)
require.NoError(t, err)
})
// scan 2 = run dns templates on honey.scanme.sh
t.Run("honey.scanme.sh", func(t *testing.T) {
err = ne.ExecuteNucleiWithOpts([]string{"honey.scanme.sh"}, nuclei.WithTemplateFilters(nuclei.TemplateFilters{ProtocolTypes: "dns"}))
require.Nil(t, err)
require.NoError(t, err)
})
// wait for all scans to finish
defer ne.Close()
}
if env.GetEnvOrDefault("TestThreadSafeNuclei", false) {
@@ -153,11 +156,11 @@ func TestWithVarsNuclei(t *testing.T) {
nuclei.WithVars([]string{"token=foobar"}),
nuclei.WithVerbosity(nuclei.VerbosityOptions{Debug: true}),
)
require.Nil(t, err)
require.NoError(t, err)
defer ne.Close()
ne.LoadTargets([]string{"scanme.sh"}, true) // probe http/https target is set to true here
err = ne.ExecuteWithCallback(nil)
require.Nil(t, err)
defer ne.Close()
require.NoError(t, err)
}
// this is shared test so needs to be run as separate process
if env.GetEnvOrDefault("TestWithVarsNuclei", false) {
+31 -1
View File
@@ -122,7 +122,12 @@
"CAA",
"TLSA",
"ANY",
"SRV"
"SRV",
"RRSIG",
"NSEC",
"DNSKEY",
"NSEC3",
"NSEC3PARAM"
],
"title": "type of DNS request to make",
"description": "Type is the type of DNS request to make"
@@ -468,6 +473,31 @@
"type": "boolean",
"title": "do not perform recursion",
"description": "Specifies whether to not do recursive checks if folders are provided"
},
"smb-user": {
"type": "string",
"title": "SMB username",
"description": "Username for SMB file targets"
},
"smb-password": {
"type": "string",
"title": "SMB password",
"description": "Password for SMB file targets"
},
"smb-domain": {
"type": "string",
"title": "SMB domain",
"description": "Domain for SMB authentication"
},
"smb-hash": {
"type": "string",
"title": "SMB NT hash",
"description": "NT hash for SMB pass-the-hash"
},
"smb-port": {
"type": "integer",
"title": "SMB port",
"description": "TCP port for SMB (default 445)"
}
},
"additionalProperties": false,
-286
View File
@@ -1,286 +0,0 @@
package authx
import (
"context"
"fmt"
"net/http"
"strings"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/pkg/authprovider/autologin"
"github.com/projectdiscovery/utils/errkit"
)
// AutoLoginConfig declares a turnkey, template-free authenticated-scanning
// login. Instead of pointing at a hand-written auth template (Dynamic.TemplatePath),
// the user supplies only a login URL and credentials; nuclei fetches the login
// page, auto-detects the form (username/password fields plus hidden CSRF tokens)
// and submits it, capturing the resulting session. The captured session is fed
// into the same Dynamic state machine, so refresh-interval and
// reauth-status-codes re-run the login automatically.
type AutoLoginConfig struct {
// LoginURL is the page serving the login form (required).
LoginURL string `json:"login-url" yaml:"login-url"`
// Username / Password are the credentials to submit. Username may be empty
// for password-only forms.
Username string `json:"username" yaml:"username"`
Password string `json:"password" yaml:"password"`
// UsernameField / PasswordField force the form field names when
// auto-detection would guess wrong (optional).
UsernameField string `json:"username-field" yaml:"username-field"`
PasswordField string `json:"password-field" yaml:"password-field"`
// ExtraFields are additional form values submitted verbatim (e.g. a tenant
// id), overriding detected fields of the same name.
ExtraFields []KV `json:"extra-fields" yaml:"extra-fields"`
// TokenRegex, when set, extracts a bearer token (first capture group) from
// the final response body for token-based logins that don't set a cookie.
TokenRegex string `json:"token-regex" yaml:"token-regex"`
// Headless drives a real browser for the login instead of the HTTP engine,
// enabling JS-rendered / SPA / multi-step / SSO login pages.
Headless bool `json:"headless" yaml:"headless"`
// ShowBrowser runs the headless browser headful (debugging only).
ShowBrowser bool `json:"show-browser" yaml:"show-browser"`
// UseInstalledChrome forces a system-installed Chrome for the headless login.
UseInstalledChrome bool `json:"use-installed-chrome" yaml:"use-installed-chrome"`
// Proxy routes the headless login's browser traffic through this proxy URL.
Proxy string `json:"proxy" yaml:"proxy"`
// Steps, when set, drives an explicit multi-step headless login flow
// (username-first / SSO / consent screens) instead of single-shot detection.
Steps []autologin.LoginStep `json:"steps" yaml:"steps"`
// Recording is a path to a Chrome DevTools Recorder / @puppeteer/replay JSON
// export of a real login. It is compiled into Steps at validation time, so a
// complex login can be authored by recording in a browser instead of writing
// steps by hand. Implies a headless login. Recorded credential literals are
// replaced with {{username}}/{{password}} placeholders.
Recording string `json:"recording" yaml:"recording"`
}
// Validate validates the auto-login configuration.
func (a *AutoLoginConfig) Validate() error {
// A recording compiles into Steps and drives a headless login. Load it first
// so a missing/invalid recording fails fast and so it can supply the login
// URL when one isn't given explicitly.
if a.Recording != "" {
steps, err := autologin.StepsFromRecordingFile(a.Recording, a.Username, a.Password)
if err != nil {
return err
}
a.Steps = steps
a.Headless = true
if a.LoginURL == "" {
a.LoginURL = autologin.FirstNavigateURL(steps)
}
}
if a.LoginURL == "" {
return errkit.New("login-url is required for auto-login dynamic secret")
}
if a.Password == "" {
return errkit.New("password is required for auto-login dynamic secret")
}
return nil
}
// AutoLoginRuntimeOptions carries scan-level browser configuration (from
// types.Options) into the auto-login engine so a headless login uses the same
// identity (user-agent, headers), network path (proxy, CDP endpoint) and Chrome
// settings as the scan. Per-secret YAML fields take precedence over these.
type AutoLoginRuntimeOptions struct {
HTTPClient *http.Client
UserAgent string
CustomHeaders map[string]string
Proxy string
CDPEndpoint string
UseInstalledChrome bool
ShowBrowser bool
}
// SetAutoLoginCallback installs the fetch callback that performs the form-based
// auto-login and renders the captured session into the secret. Unlike
// SetLazyFetchCallback, it does not run the template-substitution wrapper: the
// applied secret is built directly from the captured cookies/token, so it is
// simply overwritten on each (re-)authentication.
//
// rt carries optional scan-level browser/runtime options (user-agent, headers,
// proxy, CDP endpoint, HTTP client); pass nil for defaults.
func (d *Dynamic) SetAutoLoginCallback(rt *AutoLoginRuntimeOptions) {
if rt == nil {
rt = &AutoLoginRuntimeOptions{}
}
d.autoLoginClient = rt.HTTPClient
d.fetchCallback = func(d *Dynamic) error {
if d.AutoLogin == nil {
return errkit.New("auto-login callback invoked without auto-login config")
}
cfg := autologin.Config{
LoginURL: d.AutoLogin.LoginURL,
Username: d.AutoLogin.Username,
Password: d.AutoLogin.Password,
UsernameField: d.AutoLogin.UsernameField,
PasswordField: d.AutoLogin.PasswordField,
TokenRegex: d.AutoLogin.TokenRegex,
ExtraFields: kvSliceToMap(d.AutoLogin.ExtraFields),
Headless: d.AutoLogin.Headless,
ShowBrowser: d.AutoLogin.ShowBrowser || rt.ShowBrowser,
UseInstalledChrome: d.AutoLogin.UseInstalledChrome || rt.UseInstalledChrome,
Proxy: firstNonEmpty(d.AutoLogin.Proxy, rt.Proxy),
CDPEndpoint: rt.CDPEndpoint,
UserAgent: rt.UserAgent,
CustomHeaders: rt.CustomHeaders,
Steps: d.AutoLogin.Steps,
}
var (
session *autologin.Session
err error
)
engine := autoLoginEngineName(d.AutoLogin.Headless)
if d.AutoLogin.Headless {
// The browser engine runs JS, so it handles SPA / multi-step / SSO
// login pages the HTTP form submitter cannot.
session, err = autologin.LoginHeadless(context.Background(), cfg)
} else {
session, err = autologin.Login(context.Background(), d.autoLoginClient, cfg)
}
if err != nil {
// Surface a clear, actionable failure at scan start (prefetch) and on
// every lazy re-authentication.
gologger.Warning().Msgf("auto-login (%s) failed for %s: %s", engine, cfg.LoginURL, err)
return errkit.Wrapf(err, "auto-login (%s) failed for %s", engine, cfg.LoginURL)
}
// Expose extracted values for observability / downstream templating.
d.Extracted = map[string]interface{}{}
if session.CookieHeader != "" {
d.Extracted["cookie"] = session.CookieHeader
}
for _, c := range session.Cookies {
d.Extracted[c.Name] = c.Value
}
if session.Token != "" {
d.Extracted["token"] = session.Token
}
if applyErr := d.applyAutoLoginSession(session); applyErr != nil {
gologger.Warning().Msgf("auto-login (%s) for %s: %s", engine, cfg.LoginURL, applyErr)
return errkit.Wrapf(applyErr, "auto-login (%s) for %s", engine, cfg.LoginURL)
}
gologger.Info().Msgf("auto-login (%s) succeeded for %s: %s", engine, cfg.LoginURL, summarizeSession(session))
return nil
}
}
// autoLoginEngineName returns a human-readable engine label for log messages.
func autoLoginEngineName(headless bool) string {
if headless {
return "headless"
}
return "http"
}
// summarizeSession produces a concise human-readable summary of what an
// auto-login captured, for the success log line.
func summarizeSession(session *autologin.Session) string {
if session == nil {
return "no session"
}
var parts []string
if n := len(session.Cookies); n > 0 {
parts = append(parts, fmt.Sprintf("%d cookie(s)", n))
}
if session.Token != "" {
parts = append(parts, "bearer token")
}
if n := len(session.LocalStorage); n > 0 {
parts = append(parts, fmt.Sprintf("%d localStorage item(s)", n))
}
if n := len(session.SessionStorage); n > 0 {
parts = append(parts, fmt.Sprintf("%d sessionStorage item(s)", n))
}
if len(parts) == 0 {
return "no usable session material"
}
return strings.Join(parts, ", ")
}
// applyAutoLoginSession builds the concrete applied secret(s) from a captured
// session. Cookies are applied as a cookie secret; an extracted token is applied
// as a bearer header. When both are present (common for SPAs that set a session
// cookie *and* a localStorage JWT) both are applied. The applied fields are
// reset first so a re-authentication fully replaces the previous session.
func (d *Dynamic) applyAutoLoginSession(session *autologin.Session) error {
if d.Secret == nil {
d.Secret = &Secret{}
}
// Reset previously applied auth so re-auth replaces (not appends to) it.
d.Headers = nil
d.Cookies = nil
d.Token = ""
d.Secrets = nil
// Stash captured web storage (headless logins only) and any extra derived
// secrets on the shared fetchState so every domain-scoped value-copy and a
// re-authentication observe the same, freshly-captured session. Reset both
// each re-auth.
if d.fetchState != nil {
d.fetchState.webStorageLocal = session.LocalStorage
d.fetchState.webStorageSession = session.SessionStorage
d.fetchState.autoLoginSecrets = nil
}
hasCookies := len(session.Cookies) > 0
hasToken := session.Token != ""
hasStorage := len(session.LocalStorage) > 0 || len(session.SessionStorage) > 0
if !hasCookies && !hasToken && !hasStorage {
return errkit.New("auto-login produced no applicable session (no cookies, token or web storage)")
}
if !hasCookies && !hasToken {
// Storage-only session (e.g. a pure localStorage-JWT SPA): there is no
// HTTP-applicable secret, but the headless engine will replay the storage.
// Leave the secret type empty so it yields no HTTP strategy.
d.Type = ""
return nil
}
switch {
case hasCookies:
d.Type = string(CookiesAuth)
for _, c := range session.Cookies {
d.Cookies = append(d.Cookies, Cookie{Key: c.Name, Value: c.Value})
}
if hasToken && d.fetchState != nil {
// Apply the token as an additional bearer header secret on the shared
// fetchState so it reaches every domain the secret is scoped to.
d.fetchState.autoLoginSecrets = append(d.fetchState.autoLoginSecrets, &Secret{
Type: string(BearerTokenAuth),
Domains: d.Domains,
Token: session.Token,
})
}
default: // token only
d.Type = string(BearerTokenAuth)
d.Token = session.Token
}
return nil
}
// firstNonEmpty returns the first non-empty string argument.
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}
// kvSliceToMap converts a slice of KV pairs to a map for the autologin engine.
func kvSliceToMap(kvs []KV) map[string]string {
if len(kvs) == 0 {
return nil
}
m := make(map[string]string, len(kvs))
for _, kv := range kvs {
m[kv.Key] = kv.Value
}
return m
}
-308
View File
@@ -1,308 +0,0 @@
package authx
import (
"fmt"
"net/http"
"os"
"path/filepath"
"sync"
"sync/atomic"
"testing"
"github.com/projectdiscovery/nuclei/v3/pkg/authprovider/autologin"
"github.com/stretchr/testify/require"
)
func TestAutoLoginConfig_Validate_Recording(t *testing.T) {
recording := `{"steps": [
{"type": "navigate", "url": "https://app.example.com/login"},
{"type": "change", "value": "dave", "selectors": [["#user"]]},
{"type": "change", "value": "secret", "selectors": [["#pass"]]},
{"type": "click", "selectors": [["#submit"]]}
]}`
path := filepath.Join(t.TempDir(), "login.flow.json")
require.NoError(t, os.WriteFile(path, []byte(recording), 0o600))
// LoginURL intentionally omitted: it must be derived from the recording.
cfg := &AutoLoginConfig{Recording: path, Username: "dave", Password: "secret"}
require.NoError(t, cfg.Validate())
require.True(t, cfg.Headless, "a recording must force a headless login")
require.Equal(t, "https://app.example.com/login", cfg.LoginURL, "login URL derived from first navigate step")
require.Equal(t, []autologin.LoginStep{
{Action: "navigate", Value: "https://app.example.com/login"},
{Action: "fill", Selector: "#user", Value: "{{username}}"},
{Action: "fill", Selector: "#pass", Value: "{{password}}"},
{Action: "click", Selector: "#submit"},
}, cfg.Steps, "recording must compile to placeholder-parameterized steps")
}
func TestAutoLoginConfig_Validate_RecordingMissingFile(t *testing.T) {
cfg := &AutoLoginConfig{Recording: filepath.Join(t.TempDir(), "nope.json"), Password: "x"}
require.Error(t, cfg.Validate(), "a missing recording file must fail validation")
}
func newAutoLoginDynamic() *Dynamic {
return &Dynamic{
Secret: &Secret{Domains: []string{"app.example.com"}},
AutoLogin: &AutoLoginConfig{LoginURL: "https://app.example.com/login", Password: "x"},
}
}
func TestApplyAutoLoginSession_CookiesOnly(t *testing.T) {
d := newAutoLoginDynamic()
err := d.applyAutoLoginSession(&autologin.Session{
Cookies: []*http.Cookie{{Name: "session", Value: "abc"}},
})
require.NoError(t, err)
require.Equal(t, string(CookiesAuth), d.Type)
require.Len(t, d.Cookies, 1)
require.Equal(t, "session", d.Cookies[0].Key)
require.Empty(t, d.Secrets, "no extra bearer secret when there is no token")
}
func TestApplyAutoLoginSession_TokenOnly(t *testing.T) {
d := newAutoLoginDynamic()
err := d.applyAutoLoginSession(&autologin.Session{Token: "jwt-123"})
require.NoError(t, err)
require.Equal(t, string(BearerTokenAuth), d.Type)
require.Equal(t, "jwt-123", d.Token)
require.Empty(t, d.Secrets)
}
func TestApplyAutoLoginSession_CookiesAndToken(t *testing.T) {
d := newAutoLoginDynamic()
require.NoError(t, d.Validate())
err := d.applyAutoLoginSession(&autologin.Session{
Cookies: []*http.Cookie{{Name: "session", Value: "abc"}},
Token: "jwt-123",
})
require.NoError(t, err)
require.Equal(t, string(CookiesAuth), d.Type)
// The bearer lives on the shared fetchState (so it reaches every scoped
// domain), not on the per-copy Secrets slice.
require.Empty(t, d.Secrets, "bearer must not live on the per-copy Secrets slice")
require.Len(t, d.fetchState.autoLoginSecrets, 1, "token should be applied as an additional bearer secret")
require.Equal(t, string(BearerTokenAuth), d.fetchState.autoLoginSecrets[0].Type)
require.Equal(t, "jwt-123", d.fetchState.autoLoginSecrets[0].Token)
require.Equal(t, []string{"app.example.com"}, d.fetchState.autoLoginSecrets[0].Domains)
}
func TestApplyAutoLoginSession_Empty(t *testing.T) {
d := newAutoLoginDynamic()
err := d.applyAutoLoginSession(&autologin.Session{})
require.Error(t, err)
}
func TestSummarizeSession(t *testing.T) {
require.Equal(t, "no session", summarizeSession(nil))
require.Equal(t, "no usable session material", summarizeSession(&autologin.Session{}))
require.Equal(t, "2 cookie(s)", summarizeSession(&autologin.Session{
Cookies: []*http.Cookie{{Name: "a"}, {Name: "b"}},
}))
require.Equal(t, "1 cookie(s), bearer token, 2 localStorage item(s)", summarizeSession(&autologin.Session{
Cookies: []*http.Cookie{{Name: "a"}},
Token: "jwt",
LocalStorage: map[string]string{"x": "1", "y": "2"},
}))
}
func TestAutoLoginEngineName(t *testing.T) {
require.Equal(t, "headless", autoLoginEngineName(true))
require.Equal(t, "http", autoLoginEngineName(false))
}
// TestDynamic_WebStorage_ConcurrentReauth exercises the locking contract that
// storage replay relies on: readers (the headless engine) call WebStorage while
// re-authentication concurrently rewrites the captured storage on the shared
// fetchState. Must be run under -race.
func TestDynamic_WebStorage_ConcurrentReauth(t *testing.T) {
d := newAutoLoginDynamic()
d.ReauthStatusCodes = []int{401}
require.NoError(t, d.Validate())
var gen atomic.Int64
// Each (re)auth captures a fresh storage map, simulating a new session.
d.fetchCallback = func(dyn *Dynamic) error {
n := gen.Add(1)
// Cookie + token + storage so every shared-state write path (Secret,
// fetchState.autoLoginSecrets, webStorage) is exercised under -race.
return dyn.applyAutoLoginSession(&autologin.Session{
Cookies: []*http.Cookie{{Name: "session", Value: fmt.Sprintf("v%d", n)}},
Token: fmt.Sprintf("token-%d", n),
LocalStorage: map[string]string{"jwt": fmt.Sprintf("token-%d", n)},
})
}
// Share the session across value-copies, as the real provider does.
strategy := &DynamicAuthStrategy{Dynamic: *d}
var wg sync.WaitGroup
const workers = 8
const iterations = 200
for w := 0; w < workers; w++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for i := 0; i < iterations; i++ {
switch id % 4 {
case 0:
// Force a re-auth (rewrites the storage map under the write lock).
strategy.Dynamic.MarkStale()
_ = strategy.Dynamic.Fetch(false)
case 1:
// Read + iterate storage the way resolveBrowserStorage does.
local, session := strategy.WebStorage()
for k, v := range local {
_ = k + v
}
for k, v := range session {
_ = k + v
}
// Also materialize strategies (reads shared autoLoginSecrets).
_ = strategy.Dynamic.GetStrategies()
case 2:
strategy.Dynamic.ApplyStrategies(func(AuthStrategy) {})
default:
_ = strategy.Dynamic.NotifyResponse(401)
}
}
}(w)
}
wg.Wait()
// After the storm, a fresh fetch must still yield a consistent session.
local, _ := strategy.WebStorage()
require.NotEmpty(t, local["jwt"], "storage must remain populated after concurrent re-auth")
}
// TestDynamicClose_WipesCapturedSession verifies that Close drops the in-memory
// session captured by an auto-login (cookies, bearer token, web storage and
// extracted values) without contacting the target.
func TestDynamicClose_WipesCapturedSession(t *testing.T) {
d := newAutoLoginDynamic()
require.NoError(t, d.Validate())
d.fetchCallback = func(dyn *Dynamic) error {
return dyn.applyAutoLoginSession(&autologin.Session{
Cookies: []*http.Cookie{{Name: "session", Value: "abc"}},
Token: "jwt-123",
LocalStorage: map[string]string{"jwt": "jwt-123"},
})
}
require.NoError(t, d.Fetch(false))
d.Extracted = map[string]interface{}{"token": "jwt-123"}
// Sanity: the session is populated before Close.
require.NotEmpty(t, d.Cookies)
local, _ := d.WebStorage()
require.NotEmpty(t, local["jwt"])
d.Close()
require.Empty(t, d.Cookies, "cookies must be wiped")
require.Empty(t, d.Token, "token must be wiped")
require.Empty(t, d.Headers, "headers must be wiped")
require.Nil(t, d.Extracted, "extracted values must be cleared")
require.False(t, d.IsExpired(), "a closed session reports not-expired (unfetched)")
require.Equal(t, []string{"app.example.com"}, d.Domains, "scoping domains must be preserved")
localAfter, sessionAfter := d.fetchState.webStorageLocal, d.fetchState.webStorageSession
require.Nil(t, localAfter, "captured local storage must be cleared")
require.Nil(t, sessionAfter, "captured session storage must be cleared")
}
// TestAutoLogin_MultiDomain_BearerSharedAcrossCopies reproduces a sharing bug:
// the auto-login session (cookie + bearer token) must be applied on *every*
// domain the secret is scoped to, not just the one whose DynamicAuthStrategy
// copy happened to trigger the login. init() creates one value-copy per domain,
// all sharing *Secret and *fetchState; the extra bearer secret must be visible
// to sibling copies too.
func TestAutoLogin_MultiDomain_BearerSharedAcrossCopies(t *testing.T) {
d := &Dynamic{
Secret: &Secret{Domains: []string{"app.example.com", "api.example.com"}},
AutoLogin: &AutoLoginConfig{LoginURL: "https://app.example.com/login", Password: "x"},
}
require.NoError(t, d.Validate())
d.fetchCallback = func(dyn *Dynamic) error {
return dyn.applyAutoLoginSession(&autologin.Session{
Cookies: []*http.Cookie{{Name: "session", Value: "abc"}},
Token: "jwt-123",
})
}
// Mirror init(): one copy per domain, sharing the pointers.
copyApp := &DynamicAuthStrategy{Dynamic: *d}
copyAPI := &DynamicAuthStrategy{Dynamic: *d}
// The app-domain copy triggers the login.
appStrategies := copyApp.Dynamic.GetStrategies()
require.Len(t, appStrategies, 2, "app domain must get cookie + bearer")
// The api-domain copy (login already done, shared fetchState) must ALSO see
// both the cookie and the bearer token.
apiStrategies := copyAPI.Dynamic.GetStrategies()
require.Len(t, apiStrategies, 2, "api domain must also get cookie + bearer (shared session)")
// Concretely, the bearer must be applied to a request on the api domain.
req, _ := http.NewRequest(http.MethodGet, "https://api.example.com/data", nil)
copyAPI.Apply(req)
require.Equal(t, "Bearer jwt-123", req.Header.Get("Authorization"), "bearer token must reach the api domain")
require.NotEmpty(t, req.Header.Get("Cookie"), "session cookie must reach the api domain")
}
func TestApplyAutoLoginSession_WebStorageCaptured(t *testing.T) {
d := newAutoLoginDynamic()
require.NoError(t, d.Validate())
d.SetAutoLoginCallback(nil)
// Stub the fetch to bypass the network and simulate a headless capture.
d.fetchCallback = func(dyn *Dynamic) error {
return dyn.applyAutoLoginSession(&autologin.Session{
Cookies: []*http.Cookie{{Name: "session", Value: "abc"}},
LocalStorage: map[string]string{"jwt": "eyJ..."},
SessionStorage: map[string]string{"csrf": "tok"},
})
}
local, session := d.WebStorage()
require.Equal(t, map[string]string{"jwt": "eyJ..."}, local)
require.Equal(t, map[string]string{"csrf": "tok"}, session)
// And the DynamicAuthStrategy must surface the same storage to the engine.
strategy := &DynamicAuthStrategy{Dynamic: *d}
l2, s2 := strategy.WebStorage()
require.Equal(t, "eyJ...", l2["jwt"])
require.Equal(t, "tok", s2["csrf"])
}
func TestApplyAutoLoginSession_StorageOnly(t *testing.T) {
d := newAutoLoginDynamic()
require.NoError(t, d.Validate())
// Storage-only session (pure localStorage-JWT SPA): no HTTP secret, but the
// session is still valid because storage will be replayed by the engine.
err := d.applyAutoLoginSession(&autologin.Session{
LocalStorage: map[string]string{"jwt": "x"},
})
require.NoError(t, err)
require.Empty(t, d.Type, "storage-only session yields no HTTP strategy")
require.Equal(t, map[string]string{"jwt": "x"}, d.fetchState.webStorageLocal)
}
func TestApplyAutoLoginSession_ReauthReplaces(t *testing.T) {
d := newAutoLoginDynamic()
require.NoError(t, d.Validate())
// First auth: cookies + token (creates an extra bearer secret).
require.NoError(t, d.applyAutoLoginSession(&autologin.Session{
Cookies: []*http.Cookie{{Name: "session", Value: "v1"}},
Token: "jwt-1",
}))
require.Len(t, d.fetchState.autoLoginSecrets, 1)
// Re-auth: cookies only -> the previous bearer secret and cookies must be
// fully replaced, not appended.
require.NoError(t, d.applyAutoLoginSession(&autologin.Session{
Cookies: []*http.Cookie{{Name: "session", Value: "v2"}},
}))
require.Empty(t, d.fetchState.autoLoginSecrets, "stale bearer secret must be cleared on re-auth")
require.Len(t, d.Cookies, 1)
require.Equal(t, "v2", d.Cookies[0].Value, "cookie value must be refreshed")
}
+26 -386
View File
@@ -2,10 +2,8 @@ package authx
import (
"fmt"
"net/http"
"strings"
"sync"
"time"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/nuclei/v3/pkg/protocols/common/replacer"
@@ -16,35 +14,11 @@ import (
type LazyFetchSecret func(d *Dynamic) error
// fetchState holds the session state for a dynamic secret and makes it safe
// for concurrent use. It is stored as a pointer in Dynamic so that value copies
// (e.g. the one held inside DynamicAuthStrategy) share the same session.
//
// Unlike a plain sync.Once, this state machine supports re-authentication: the
// login flow can be re-run when the session goes stale (explicitly marked,
// detected from a response status, or after a configured refresh interval).
//
// Locking contract:
// - Fetch/Refresh take the write lock while (re)running the login callback.
// - The dynamic apply path (ApplyStrategies) holds the read lock while reading
// the rendered secret, so request-time reads never race a concurrent re-auth.
// fetchState holds the sync.Once and error for thread-safe fetching.
// This is stored as a pointer in Dynamic so that value copies share the same state.
type fetchState struct {
mu sync.RWMutex
fetched bool // whether the login callback has completed at least once
stale bool // whether the session must be re-authenticated on next fetch
fetchedAt time.Time // time of the last successful/attempted fetch
err error // error from the most recent fetch attempt
// webStorageLocal/webStorageSession hold browser web storage captured by a
// headless auto-login, to be replayed into headless scan pages. Stored here
// (on the shared fetchState pointer) so all Dynamic value-copies and a
// re-authentication observe the same, freshly-captured storage.
webStorageLocal map[string]string
webStorageSession map[string]string
// autoLoginSecrets holds extra applied secrets derived from an auto-login
// session (e.g. a bearer token captured alongside a session cookie). They
// live on the shared fetchState rather than the per-copy Dynamic.Secrets
// slice so every domain-scoped value-copy applies the same captured session.
autoLoginSecrets []*Secret
once sync.Once
err error
}
var (
@@ -56,44 +30,16 @@ var (
// ex: username and password are dynamic secrets, the actual secret is the token obtained
// after authenticating with the username and password
type Dynamic struct {
*Secret `yaml:",inline"` // this is a static secret that will be generated after the dynamic secret is resolved
Secrets []*Secret `yaml:"secrets"`
TemplatePath string `json:"template" yaml:"template"`
Variables []KV `json:"variables" yaml:"variables"`
Input string `json:"input" yaml:"input"` // (optional) target for the dynamic secret
Extracted map[string]interface{} `json:"-" yaml:"-"` // extracted values from the dynamic secret
// AutoLogin, when set, replaces the template-based login (TemplatePath) with
// a turnkey form-based auto-login: nuclei fetches the login page, detects the
// form and submits the supplied credentials, capturing the session
// automatically. It is mutually exclusive with TemplatePath.
AutoLogin *AutoLoginConfig `json:"auto-login" yaml:"auto-login"`
// RefreshInterval, when set (e.g. "15m"), causes the session to be
// re-authenticated automatically once the rendered secret is older than the
// interval. When empty/zero the session is fetched once and never expires by time.
RefreshInterval string `json:"refresh-interval" yaml:"refresh-interval"`
// ReauthStatusCodes is the set of HTTP response status codes that indicate
// the session has expired (e.g. [401, 403]). When a matching response is
// observed, the session is marked stale and re-authenticated before the next
// request. When empty, response-triggered re-authentication is disabled.
ReauthStatusCodes []int `json:"reauth-status-codes" yaml:"reauth-status-codes"`
fetchCallback LazyFetchSecret `json:"-" yaml:"-"`
*Secret `yaml:",inline"` // this is a static secret that will be generated after the dynamic secret is resolved
Secrets []*Secret `yaml:"secrets"`
TemplatePath string `json:"template" yaml:"template"`
Variables []KV `json:"variables" yaml:"variables"`
Input string `json:"input" yaml:"input"` // (optional) target for the dynamic secret
Extracted map[string]interface{} `json:"-" yaml:"-"` // extracted values from the dynamic secret
fetchCallback LazyFetchSecret `json:"-" yaml:"-"`
// fetchState is shared across value-copies of Dynamic (e.g., inside DynamicAuthStrategy).
// It must be initialized via Validate() before calling Fetch().
fetchState *fetchState `json:"-" yaml:"-"`
// refreshInterval is the parsed form of RefreshInterval.
refreshInterval time.Duration `json:"-" yaml:"-"`
// autoLoginClient is an optional template HTTP client (proxy/TLS) reused for
// auto-login requests. nil means use the autologin engine's defaults.
autoLoginClient *http.Client `json:"-" yaml:"-"`
// origSecret/origSecrets hold pristine copies of the secret templates (with
// their {{var}} placeholders intact) captured before the first fetch. They are
// used to re-render the secret on each re-authentication, since substitution
// is destructive (placeholders are replaced with concrete values in place).
origSecret *Secret `json:"-" yaml:"-"`
origSecrets []*Secret `json:"-" yaml:"-"`
}
func (d *Dynamic) GetDomainAndDomainRegex() ([]string, []string) {
@@ -134,56 +80,12 @@ func (d *Dynamic) Validate() error {
// NOTE: Validate() must not be called concurrently with Fetch()/GetStrategies().
// Re-validating resets fetch state and allows re-fetching.
d.fetchState = &fetchState{}
// Auto-login is a template-free alternative to TemplatePath. When set, it is
// validated on its own and the template/variables requirements do not apply;
// domains (used for scoping the captured session) are still required.
if d.AutoLogin != nil {
if d.TemplatePath != "" {
return errkit.New("auto-login and template are mutually exclusive for dynamic secret")
}
if err := d.AutoLogin.Validate(); err != nil {
return err
}
domains, domainsRegex := d.GetDomainAndDomainRegex()
if len(domains) == 0 && len(domainsRegex) == 0 {
return errkit.New("domains or domains-regex are required for auto-login dynamic secret")
}
if d.RefreshInterval != "" {
dur, err := time.ParseDuration(d.RefreshInterval)
if err != nil {
return errkit.New("invalid refresh-interval %q for dynamic secret: %s", d.RefreshInterval, err)
}
if dur < 0 {
return errkit.New("refresh-interval cannot be negative for dynamic secret")
}
d.refreshInterval = dur
}
// The applied secret is built from the captured session at fetch time, so
// skip the (pre-fetch) Secret.Validate which would reject the still-empty
// cookies/token. We only need its domains, captured above.
if d.Secret != nil {
d.skipCookieParse = true
}
return nil
}
if d.TemplatePath == "" {
return errkit.New(" template-path is required for dynamic secret")
}
if len(d.Variables) == 0 {
return errkit.New("variables are required for dynamic secret")
}
if d.RefreshInterval != "" {
dur, err := time.ParseDuration(d.RefreshInterval)
if err != nil {
return errkit.New("invalid refresh-interval %q for dynamic secret: %s", d.RefreshInterval, err)
}
if dur < 0 {
return errkit.New("refresh-interval cannot be negative for dynamic secret")
}
d.refreshInterval = dur
}
if d.Secret != nil {
d.skipCookieParse = true // skip cookie parsing in dynamic secrets during validation
@@ -200,15 +102,8 @@ func (d *Dynamic) Validate() error {
return nil
}
// SetLazyFetchCallback sets the lazy fetch callback for the dynamic secret.
//
// The provided callback is responsible for running the login flow and populating
// d.Extracted. The wrapper here renders the secret templates with the freshly
// extracted values. It snapshots the pristine secret templates on first use so
// that re-authentication can re-render them with new values (template
// substitution is destructive and would otherwise consume the placeholders).
// SetLazyFetchCallback sets the lazy fetch callback for the dynamic secret
func (d *Dynamic) SetLazyFetchCallback(callback LazyFetchSecret) {
d.snapshotTemplates()
d.fetchCallback = func(d *Dynamic) error {
err := callback(d)
if err != nil {
@@ -219,16 +114,12 @@ func (d *Dynamic) SetLazyFetchCallback(callback LazyFetchSecret) {
}
if d.Secret != nil {
restoreSecretTemplate(d.Secret, d.origSecret)
if err := d.applyValuesToSecret(d.Secret); err != nil {
return err
}
}
for i, secret := range d.Secrets {
if i < len(d.origSecrets) {
restoreSecretTemplate(secret, d.origSecrets[i])
}
for _, secret := range d.Secrets {
if err := d.applyValuesToSecret(secret); err != nil {
return err
}
@@ -237,47 +128,6 @@ func (d *Dynamic) SetLazyFetchCallback(callback LazyFetchSecret) {
}
}
// snapshotTemplates captures pristine copies of the secret templates (with their
// {{var}} placeholders intact) so they can be re-rendered on re-authentication.
func (d *Dynamic) snapshotTemplates() {
if d.Secret != nil {
d.origSecret = cloneSecretTemplate(d.Secret)
}
if len(d.Secrets) > 0 {
d.origSecrets = make([]*Secret, len(d.Secrets))
for i, secret := range d.Secrets {
d.origSecrets[i] = cloneSecretTemplate(secret)
}
}
}
// cloneSecretTemplate deep-copies the templated fields of a secret so the
// original placeholders survive destructive in-place substitution.
func cloneSecretTemplate(s *Secret) *Secret {
if s == nil {
return nil
}
clone := *s
clone.Headers = append([]KV(nil), s.Headers...)
clone.Cookies = append([]Cookie(nil), s.Cookies...)
clone.Params = append([]KV(nil), s.Params...)
return &clone
}
// restoreSecretTemplate resets the templated fields of live back to the pristine
// template captured in snap, so the next substitution starts from placeholders.
func restoreSecretTemplate(live, snap *Secret) {
if live == nil || snap == nil {
return
}
live.Headers = append([]KV(nil), snap.Headers...)
live.Cookies = append([]Cookie(nil), snap.Cookies...)
live.Params = append([]KV(nil), snap.Params...)
live.Username = snap.Username
live.Password = snap.Password
live.Token = snap.Token
}
func (d *Dynamic) applyValuesToSecret(secret *Secret) error {
// evaluate headers
for i, header := range secret.Headers {
@@ -346,68 +196,20 @@ func (d *Dynamic) GetStrategies() []AuthStrategy {
// does not terminate the entire scan process.
_ = d.Fetch(false)
if d.fetchState == nil {
return nil
}
d.fetchState.mu.RLock()
defer d.fetchState.mu.RUnlock()
if d.fetchState.err != nil {
if d.fetchState != nil && d.fetchState.err != nil {
return nil
}
var strategies []AuthStrategy
if d.Secret != nil {
if s := d.GetStrategy(); s != nil {
strategies = append(strategies, s)
}
strategies = append(strategies, d.GetStrategy())
}
for _, secret := range d.Secrets {
if s := secret.GetStrategy(); s != nil {
strategies = append(strategies, s)
}
}
for _, secret := range d.fetchState.autoLoginSecrets {
if s := secret.GetStrategy(); s != nil {
strategies = append(strategies, s)
}
strategies = append(strategies, secret.GetStrategy())
}
return strategies
}
// ApplyStrategies fetches (or re-authenticates) the session if needed and then
// applies each resolved auth strategy via the provided apply func while holding
// the read lock. This guarantees the rendered secret is never read at request
// time while a concurrent re-authentication is rewriting it.
func (d *Dynamic) ApplyStrategies(apply func(AuthStrategy)) {
// Fetch (re)authenticates under the write lock if the session is missing or stale.
_ = d.Fetch(false)
if d.fetchState == nil {
return
}
d.fetchState.mu.RLock()
defer d.fetchState.mu.RUnlock()
if d.fetchState.err != nil {
return
}
if d.Secret != nil {
if s := d.GetStrategy(); s != nil {
apply(s)
}
}
for _, secret := range d.Secrets {
if s := secret.GetStrategy(); s != nil {
apply(s)
}
}
for _, secret := range d.fetchState.autoLoginSecrets {
if s := secret.GetStrategy(); s != nil {
apply(s)
}
}
}
// Fetch fetches the dynamic secret, (re)running the login flow when the session
// has not been fetched yet or has gone stale (explicitly marked, expired by
// refresh-interval, or invalidated by an observed response status code).
// Fetch fetches the dynamic secret
// if isFatal is true, it will stop the execution if the secret could not be fetched
func (d *Dynamic) Fetch(isFatal bool) error {
if d.fetchState == nil {
@@ -417,135 +219,18 @@ func (d *Dynamic) Fetch(isFatal bool) error {
return errkit.New("dynamic secret not validated: call Validate() before Fetch()")
}
d.fetchState.mu.Lock()
if !d.fetchState.fetched || d.isExpiredLocked() {
d.runFetchLocked()
}
err := d.fetchState.err
d.fetchState.mu.Unlock()
if err != nil && isFatal {
gologger.Fatal().Msgf("Could not fetch dynamic secret: %s\n", err)
}
return err
}
// Refresh forces an immediate re-authentication, re-running the login flow
// regardless of the current session state.
func (d *Dynamic) Refresh(isFatal bool) error {
if d.fetchState == nil {
if isFatal {
gologger.Fatal().Msgf("Could not refresh dynamic secret: Validate() must be called before Refresh()")
d.fetchState.once.Do(func() {
if d.fetchCallback == nil {
d.fetchState.err = errkit.New("dynamic secret fetch callback not set: call SetLazyFetchCallback() before Fetch()")
return
}
return errkit.New("dynamic secret not validated: call Validate() before Refresh()")
}
d.fetchState.err = d.fetchCallback(d)
})
d.fetchState.mu.Lock()
d.fetchState.fetched = false
d.fetchState.stale = false
d.runFetchLocked()
err := d.fetchState.err
d.fetchState.mu.Unlock()
if err != nil && isFatal {
gologger.Fatal().Msgf("Could not refresh dynamic secret: %s\n", err)
if d.fetchState.err != nil && isFatal {
gologger.Fatal().Msgf("Could not fetch dynamic secret: %s\n", d.fetchState.err)
}
return err
}
// MarkStale marks the session for re-authentication on the next fetch. It is
// cheap and does not perform any network I/O; the actual re-auth happens on the
// next Fetch/ApplyStrategies call so it never runs in a response-handling path.
func (d *Dynamic) MarkStale() {
if d.fetchState == nil {
return
}
d.fetchState.mu.Lock()
d.fetchState.stale = true
d.fetchState.mu.Unlock()
}
// NotifyResponse inspects a response and, if its status code is configured as a
// session-expiry signal, marks the session stale so it is re-authenticated
// before the next request. It returns true if re-authentication was triggered.
func (d *Dynamic) NotifyResponse(statusCode int) bool {
if d.fetchState == nil || !d.shouldReauthOnStatus(statusCode) {
return false
}
// Only mark stale if we actually have an established session to refresh.
d.fetchState.mu.RLock()
fetched := d.fetchState.fetched
d.fetchState.mu.RUnlock()
if !fetched {
return false
}
d.MarkStale()
return true
}
// WebStorage returns the browser web storage (localStorage/sessionStorage)
// captured by a headless auto-login, fetching the session first if needed. It
// returns nil maps when no storage was captured (e.g. HTTP auto-login or a
// fetch error).
func (d *Dynamic) WebStorage() (map[string]string, map[string]string) {
_ = d.Fetch(false)
if d.fetchState == nil {
return nil, nil
}
d.fetchState.mu.RLock()
defer d.fetchState.mu.RUnlock()
if d.fetchState.err != nil {
return nil, nil
}
return d.fetchState.webStorageLocal, d.fetchState.webStorageSession
}
// IsExpired reports whether the session is currently considered expired/stale.
func (d *Dynamic) IsExpired() bool {
if d.fetchState == nil {
return false
}
d.fetchState.mu.RLock()
defer d.fetchState.mu.RUnlock()
return d.isExpiredLocked()
}
// runFetchLocked runs the login callback. The caller must hold the write lock.
func (d *Dynamic) runFetchLocked() {
if d.fetchCallback == nil {
d.fetchState.err = errkit.New("dynamic secret fetch callback not set: call SetLazyFetchCallback() before Fetch()")
return
}
d.fetchState.err = d.fetchCallback(d)
d.fetchState.fetched = true
d.fetchState.stale = false
d.fetchState.fetchedAt = time.Now()
}
// isExpiredLocked reports whether the session needs re-authentication. The
// caller must hold at least the read lock.
func (d *Dynamic) isExpiredLocked() bool {
if !d.fetchState.fetched {
return false
}
if d.fetchState.stale {
return true
}
if d.refreshInterval > 0 && time.Since(d.fetchState.fetchedAt) > d.refreshInterval {
return true
}
return false
}
// shouldReauthOnStatus reports whether the given status code is configured as a
// session-expiry signal.
func (d *Dynamic) shouldReauthOnStatus(statusCode int) bool {
for _, code := range d.ReauthStatusCodes {
if code == statusCode {
return true
}
}
return false
return d.fetchState.err
}
// Error returns the error if any
@@ -553,50 +238,5 @@ func (d *Dynamic) Error() error {
if d.fetchState == nil {
return nil
}
d.fetchState.mu.RLock()
defer d.fetchState.mu.RUnlock()
return d.fetchState.err
}
// Close drops the in-memory session captured for this dynamic secret: the
// applied cookies/token/headers, any captured web storage and derived secrets,
// and the extracted values. It does not contact the server (sessions are left
// to expire on their own); it simply ensures nuclei does not retain live
// session material after the scan, which matters when nuclei is embedded as a
// library and the process outlives the scan. The session is also marked
// unfetched so a later reuse re-authenticates from scratch.
func (d *Dynamic) Close() {
if d.fetchState != nil {
d.fetchState.mu.Lock()
d.fetchState.webStorageLocal = nil
d.fetchState.webStorageSession = nil
for _, s := range d.fetchState.autoLoginSecrets {
wipeSecretSession(s)
}
d.fetchState.autoLoginSecrets = nil
d.fetchState.fetched = false
d.fetchState.stale = false
d.fetchState.err = nil
d.fetchState.mu.Unlock()
}
wipeSecretSession(d.Secret)
for _, s := range d.Secrets {
wipeSecretSession(s)
}
d.Extracted = nil
}
// wipeSecretSession zeroes the session-bearing fields of a secret (cookies,
// token, headers, query params and web storage), leaving non-sensitive scoping
// fields (type/domains) intact. It is a no-op on nil.
func wipeSecretSession(s *Secret) {
if s == nil {
return
}
s.Headers = nil
s.Cookies = nil
s.Params = nil
s.Token = ""
s.LocalStorage = nil
s.SessionStorage = nil
}
+3 -3
View File
@@ -98,8 +98,8 @@ func TestDynamicUnmarshalJSON(t *testing.T) {
require.Equal(t, "HeadersAuth", d.Type)
require.Equal(t, []string{"api.test.com"}, d.Domains)
require.Len(t, d.Headers, 1)
require.Equal(t, "X-API-Key", d.Headers[0].Key)
require.Equal(t, "secret-key", d.Headers[0].Value)
require.Equal(t, "X-API-Key", d.Secret.Headers[0].Key)
require.Equal(t, "secret-key", d.Secret.Headers[0].Value)
// Dynamic fields
require.Equal(t, "test-template.yaml", d.TemplatePath)
@@ -529,7 +529,7 @@ func TestSetLazyFetchCallbackAppliesValues(t *testing.T) {
err := d.Fetch(false)
require.NoError(t, err)
require.Equal(t, "Bearer jwt-secret-123", d.Headers[0].Value)
require.Equal(t, "Bearer jwt-secret-123", d.Secret.Headers[0].Value)
})
t.Run("applies to secrets array", func(t *testing.T) {
+10 -31
View File
@@ -22,11 +22,6 @@ const (
HeadersAuth AuthType = "Header"
CookiesAuth AuthType = "Cookie"
QueryAuth AuthType = "Query"
// WebStorageAuth carries browser localStorage/sessionStorage to be seeded
// into headless scan pages. It has no HTTP representation (its Apply is a
// no-op) and is used to replay token-in-web-storage sessions (e.g. captured
// via auth-capture) for SPA scans.
WebStorageAuth AuthType = "WebStorage"
)
// SupportedAuthTypes returns the supported auth types
@@ -37,7 +32,6 @@ func SupportedAuthTypes() []string {
string(HeadersAuth),
string(CookiesAuth),
string(QueryAuth),
string(WebStorageAuth),
}
}
@@ -58,20 +52,16 @@ type AuthFileInfo struct {
// Secret is a struct for secret or credential
type Secret struct {
Type string `json:"type" yaml:"type"`
Domains []string `json:"domains" yaml:"domains"`
DomainsRegex []string `json:"domains-regex" yaml:"domains-regex"`
Headers []KV `json:"headers" yaml:"headers"` // Headers preserve exact casing (useful for case-sensitive APIs)
Cookies []Cookie `json:"cookies" yaml:"cookies"`
Params []KV `json:"params" yaml:"params"`
Username string `json:"username" yaml:"username"` // can be either email or username
Password string `json:"password" yaml:"password"`
Token string `json:"token" yaml:"token"` // Bearer Auth token
// LocalStorage / SessionStorage carry browser web storage for WebStorageAuth
// secrets, seeded into headless scan pages before page scripts run.
LocalStorage map[string]string `json:"local-storage" yaml:"local-storage"`
SessionStorage map[string]string `json:"session-storage" yaml:"session-storage"`
skipCookieParse bool `json:"-" yaml:"-"` // temporary flag to skip cookie parsing (used in dynamic secrets)
Type string `json:"type" yaml:"type"`
Domains []string `json:"domains" yaml:"domains"`
DomainsRegex []string `json:"domains-regex" yaml:"domains-regex"`
Headers []KV `json:"headers" yaml:"headers"` // Headers preserve exact casing (useful for case-sensitive APIs)
Cookies []Cookie `json:"cookies" yaml:"cookies"`
Params []KV `json:"params" yaml:"params"`
Username string `json:"username" yaml:"username"` // can be either email or username
Password string `json:"password" yaml:"password"`
Token string `json:"token" yaml:"token"` // Bearer Auth token
skipCookieParse bool `json:"-" yaml:"-"` // temporary flag to skip cookie parsing (used in dynamic secrets)
}
// GetStrategy returns the auth strategy for the secret
@@ -87,8 +77,6 @@ func (s *Secret) GetStrategy() AuthStrategy {
return NewCookiesAuthStrategy(s)
case strings.EqualFold(s.Type, string(QueryAuth)):
return NewQueryAuthStrategy(s)
case strings.EqualFold(s.Type, string(WebStorageAuth)):
return NewWebStorageAuthStrategy(s.LocalStorage, s.SessionStorage)
}
return nil
}
@@ -144,10 +132,6 @@ func (s *Secret) Validate() error {
return fmt.Errorf("invalid cookie in cookiesAuth: %s", err)
}
}
case strings.EqualFold(s.Type, string(WebStorageAuth)):
if len(s.LocalStorage) == 0 && len(s.SessionStorage) == 0 {
return fmt.Errorf("local-storage or session-storage cannot be empty in web storage auth")
}
case strings.EqualFold(s.Type, string(QueryAuth)):
if len(s.Params) == 0 {
return fmt.Errorf("query cannot be empty in query auth")
@@ -243,11 +227,6 @@ func GetTemplatePathsFromSecretFile(file string) ([]string, error) {
}
var paths []string
for _, dynamic := range auth.Dynamic {
// Auto-login dynamics have no template path; skip them so we don't try to
// load an empty template.
if dynamic.TemplatePath == "" {
continue
}
paths = append(paths, dynamic.TemplatePath)
}
return paths, nil
-138
View File
@@ -1,138 +0,0 @@
package authx
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"sync"
"sync/atomic"
"testing"
"github.com/stretchr/testify/require"
)
// fakeAuthServer simulates a login + protected-resource backend with
// server-side session expiry, to exercise the dynamic session lifecycle end to
// end over real HTTP:
// - GET /login issues a fresh incrementing bearer token and marks it as the
// only currently-valid session.
// - GET /api returns 200 when the presented bearer matches the valid session,
// and 401 once the session has been expired server-side.
type fakeAuthServer struct {
mu sync.Mutex
counter int
valid string // currently valid token; empty means "no valid session"
logins atomic.Int32
}
func (s *fakeAuthServer) handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
s.counter++
s.valid = fmt.Sprintf("sess-%d", s.counter)
tok := s.valid
s.mu.Unlock()
s.logins.Add(1)
_ = json.NewEncoder(w).Encode(map[string]string{"token": tok})
})
mux.HandleFunc("/api", func(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
valid := s.valid
s.mu.Unlock()
if valid != "" && r.Header.Get("Authorization") == "Bearer "+valid {
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "secret-data")
return
}
w.WriteHeader(http.StatusUnauthorized)
_, _ = io.WriteString(w, "session expired")
})
return mux
}
// expire invalidates the current session server-side without issuing a new one,
// so the next /api call with the old token returns 401.
func (s *fakeAuthServer) expire() {
s.mu.Lock()
s.valid = ""
s.mu.Unlock()
}
// TestSessionLifecycle_E2E drives the whole dynamic-session state machine against
// a real HTTP server: it performs a real login to mint a token, uses it on a
// protected endpoint, lets the server expire the session, and verifies that a
// 401 (surfaced through NotifyResponse) triggers a real re-authentication whose
// fresh token restores access — all through the production Dynamic/strategy code.
func TestSessionLifecycle_E2E(t *testing.T) {
backend := &fakeAuthServer{}
srv := httptest.NewServer(backend.handler())
defer srv.Close()
httpClient := srv.Client()
d := &Dynamic{
Secret: &Secret{
Type: "Header",
Domains: []string{"127.0.0.1", "localhost"},
Headers: []KV{{Key: "Authorization", Value: "Bearer {{token}}"}},
},
TemplatePath: "auth.yaml",
Variables: []KV{{Key: "user", Value: "admin"}},
ReauthStatusCodes: []int{401},
}
require.NoError(t, d.Validate())
// The login callback performs a REAL HTTP login and extracts the token, the
// same shape the template-driven fetch produces.
d.SetLazyFetchCallback(func(dyn *Dynamic) error {
resp, err := httpClient.Get(srv.URL + "/login")
if err != nil {
return err
}
defer func() { _ = resp.Body.Close() }()
var payload struct {
Token string `json:"token"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return err
}
dyn.Extracted = map[string]interface{}{"token": payload.Token}
return nil
})
strategy := &DynamicAuthStrategy{Dynamic: *d}
inspector, ok := AuthStrategy(strategy).(ResponseInspector)
require.True(t, ok, "DynamicAuthStrategy must implement ResponseInspector")
// doAPI applies the (possibly re-authenticated) session and calls /api.
doAPI := func() int {
req, err := http.NewRequest(http.MethodGet, srv.URL+"/api", nil)
require.NoError(t, err)
strategy.Apply(req)
resp, err := httpClient.Do(req)
require.NoError(t, err)
_ = resp.Body.Close()
// surface the response to the auth strategy, like the http protocol does
inspector.OnResponse(resp.StatusCode)
return resp.StatusCode
}
// 1) First call logs in and succeeds.
require.Equal(t, http.StatusOK, doAPI(), "first authenticated request should succeed")
require.Equal(t, int32(1), backend.logins.Load(), "exactly one login so far")
require.False(t, d.IsExpired())
// 2) Server expires the session out from under us → next call sees 401 and,
// via NotifyResponse, marks the session stale.
backend.expire()
require.Equal(t, http.StatusUnauthorized, doAPI(), "request with the now-expired token should 401")
require.True(t, d.IsExpired(), "401 must mark the session stale for re-authentication")
// 3) Next call re-authenticates (a second real login) and succeeds again.
require.Equal(t, http.StatusOK, doAPI(), "request after re-auth should succeed with the fresh token")
require.Equal(t, int32(2), backend.logins.Load(), "a second login should have occurred")
require.False(t, d.IsExpired(), "session should be fresh again after re-auth")
}
-250
View File
@@ -1,250 +0,0 @@
package authx
import (
"net/http"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// newDynamicWithToken builds a validated dynamic secret whose login callback
// returns an incrementing token each time it runs, so re-authentication is observable.
func newDynamicWithToken(t *testing.T, calls *atomic.Int32) *Dynamic {
t.Helper()
d := &Dynamic{
Secret: &Secret{
Type: "Header",
Domains: []string{"example.com"},
Headers: []KV{{Key: "Authorization", Value: "Bearer {{token}}"}},
},
TemplatePath: "auth.yaml",
Variables: []KV{{Key: "user", Value: "admin"}},
}
require.NoError(t, d.Validate())
d.SetLazyFetchCallback(func(dyn *Dynamic) error {
n := calls.Add(1)
dyn.Extracted = map[string]interface{}{"token": tokenForCall(n)}
return nil
})
return d
}
func tokenForCall(n int32) string {
switch n {
case 1:
return "token-1"
case 2:
return "token-2"
case 3:
return "token-3"
default:
return "token-n"
}
}
func applyHeader(t *testing.T, d *Dynamic) string {
t.Helper()
strategy := &DynamicAuthStrategy{Dynamic: *d}
req, _ := http.NewRequest("GET", "https://example.com", nil)
strategy.Apply(req)
return req.Header.Get("Authorization")
}
func TestSessionRefreshReRendersTemplate(t *testing.T) {
var calls atomic.Int32
d := newDynamicWithToken(t, &calls)
require.Equal(t, "Bearer token-1", applyHeader(t, d), "first fetch should render initial token")
require.Equal(t, "Bearer token-1", applyHeader(t, d), "second apply should reuse cached session")
require.Equal(t, int32(1), calls.Load(), "login should run once before refresh")
require.NoError(t, d.Refresh(false))
require.Equal(t, int32(2), calls.Load(), "refresh should re-run the login flow")
require.Equal(t, "Bearer token-2", applyHeader(t, d), "refresh must re-render template with the new token")
}
func TestSessionMarkStaleTriggersReauth(t *testing.T) {
var calls atomic.Int32
d := newDynamicWithToken(t, &calls)
require.Equal(t, "Bearer token-1", applyHeader(t, d))
require.Equal(t, int32(1), calls.Load())
d.MarkStale()
require.True(t, d.IsExpired(), "session should report expired after MarkStale")
require.Equal(t, "Bearer token-2", applyHeader(t, d), "stale session should re-authenticate on next apply")
require.Equal(t, int32(2), calls.Load())
require.False(t, d.IsExpired(), "session should be fresh again after re-auth")
}
func TestSessionRefreshIntervalExpiry(t *testing.T) {
var calls atomic.Int32
d := &Dynamic{
Secret: &Secret{
Type: "Header",
Domains: []string{"example.com"},
Headers: []KV{{Key: "Authorization", Value: "Bearer {{token}}"}},
},
TemplatePath: "auth.yaml",
Variables: []KV{{Key: "user", Value: "admin"}},
RefreshInterval: "40ms",
}
require.NoError(t, d.Validate())
d.SetLazyFetchCallback(func(dyn *Dynamic) error {
n := calls.Add(1)
dyn.Extracted = map[string]interface{}{"token": tokenForCall(n)}
return nil
})
require.Equal(t, "Bearer token-1", applyHeader(t, d))
require.Equal(t, "Bearer token-1", applyHeader(t, d), "within interval the session is reused")
require.Equal(t, int32(1), calls.Load())
time.Sleep(60 * time.Millisecond)
require.True(t, d.IsExpired(), "session should expire after the refresh interval")
require.Equal(t, "Bearer token-2", applyHeader(t, d), "expired session should re-authenticate")
require.Equal(t, int32(2), calls.Load())
}
func TestSessionNotifyResponseReauth(t *testing.T) {
t.Run("configured status marks stale", func(t *testing.T) {
var calls atomic.Int32
d := newDynamicWithToken(t, &calls)
d.ReauthStatusCodes = []int{401, 403}
require.Equal(t, "Bearer token-1", applyHeader(t, d))
require.False(t, d.NotifyResponse(200), "2xx must not trigger re-auth")
require.False(t, d.IsExpired())
require.True(t, d.NotifyResponse(401), "401 must trigger re-auth for established session")
require.True(t, d.IsExpired())
require.Equal(t, "Bearer token-2", applyHeader(t, d))
require.Equal(t, int32(2), calls.Load())
})
t.Run("no reauth before first fetch", func(t *testing.T) {
var calls atomic.Int32
d := newDynamicWithToken(t, &calls)
d.ReauthStatusCodes = []int{401}
require.False(t, d.NotifyResponse(401), "should not mark stale before a session exists")
require.Equal(t, int32(0), calls.Load())
})
t.Run("unconfigured status code is ignored", func(t *testing.T) {
var calls atomic.Int32
d := newDynamicWithToken(t, &calls)
// ReauthStatusCodes left empty => response-triggered reauth disabled
require.Equal(t, "Bearer token-1", applyHeader(t, d))
require.False(t, d.NotifyResponse(401))
require.False(t, d.IsExpired())
})
}
func TestSessionResponseInspectorInterface(t *testing.T) {
var calls atomic.Int32
d := newDynamicWithToken(t, &calls)
d.ReauthStatusCodes = []int{401}
var strategy AuthStrategy = &DynamicAuthStrategy{Dynamic: *d}
inspector, ok := strategy.(ResponseInspector)
require.True(t, ok, "DynamicAuthStrategy must implement ResponseInspector")
// establish session via the same shared state
req, _ := http.NewRequest("GET", "https://example.com", nil)
strategy.Apply(req)
require.Equal(t, "Bearer token-1", req.Header.Get("Authorization"))
require.True(t, inspector.OnResponse(401))
req2, _ := http.NewRequest("GET", "https://example.com", nil)
strategy.Apply(req2)
require.Equal(t, "Bearer token-2", req2.Header.Get("Authorization"))
}
func TestSessionValidateRejectsBadRefreshInterval(t *testing.T) {
d := &Dynamic{
TemplatePath: "auth.yaml",
Variables: []KV{{Key: "user", Value: "admin"}},
RefreshInterval: "not-a-duration",
}
err := d.Validate()
require.Error(t, err)
require.Contains(t, err.Error(), "invalid refresh-interval")
}
// TestSessionConcurrentApplyDuringReauth exercises the locking contract: request
// threads applying the strategy must never race a concurrent re-authentication.
// Intended to be run with -race.
func TestSessionConcurrentApplyDuringReauth(t *testing.T) {
var calls atomic.Int32
d := &Dynamic{
Secret: &Secret{
Type: "Header",
Domains: []string{"example.com"},
Headers: []KV{{Key: "Authorization", Value: "Bearer {{token}}"}},
},
TemplatePath: "auth.yaml",
Variables: []KV{{Key: "user", Value: "admin"}},
}
require.NoError(t, d.Validate())
d.SetLazyFetchCallback(func(dyn *Dynamic) error {
calls.Add(1)
// simulate some work during login so writers and readers overlap
time.Sleep(time.Millisecond)
dyn.Extracted = map[string]interface{}{"token": "tok"}
return nil
})
strategy := &DynamicAuthStrategy{Dynamic: *d}
const workers = 16
var wg sync.WaitGroup
stop := make(chan struct{})
// readers continuously apply the strategy
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
req, _ := http.NewRequest("GET", "https://example.com", nil)
strategy.Apply(req)
require.Equal(t, "Bearer tok", req.Header.Get("Authorization"))
}
}
}()
}
// writers continuously invalidate the session, forcing re-auth
for i := 0; i < 4; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-stop:
return
default:
d.MarkStale()
time.Sleep(time.Millisecond)
}
}
}()
}
time.Sleep(200 * time.Millisecond)
close(stop)
wg.Wait()
require.Greater(t, calls.Load(), int32(1), "re-authentication should have happened multiple times")
}
+12 -32
View File
@@ -15,22 +15,6 @@ type AuthStrategy interface {
ApplyOnRR(*retryablehttp.Request)
}
// ResponseInspector is an optional interface that an AuthStrategy may implement
// to observe responses to authenticated requests. It is used to detect session
// expiry (e.g. a 401 after a previously valid session) and trigger
// re-authentication for subsequent requests.
type ResponseInspector interface {
// OnResponse is called with the status code of a response to a request the
// strategy authenticated. It returns true if re-authentication was triggered.
OnResponse(statusCode int) bool
}
var (
_ AuthStrategy = &DynamicAuthStrategy{}
_ ResponseInspector = &DynamicAuthStrategy{}
_ BrowserStorageProvider = &DynamicAuthStrategy{}
)
// DynamicAuthStrategy is an auth strategy for dynamic secrets
// it implements the AuthStrategy interface
type DynamicAuthStrategy struct {
@@ -40,26 +24,22 @@ type DynamicAuthStrategy struct {
// Apply applies the strategy to the request
func (d *DynamicAuthStrategy) Apply(req *http.Request) {
d.Dynamic.ApplyStrategies(func(s AuthStrategy) {
strategies := d.Dynamic.GetStrategies()
if strategies == nil {
return
}
for _, s := range strategies {
if s == nil {
continue
}
s.Apply(req)
})
}
}
// ApplyOnRR applies the strategy to the retryable request
func (d *DynamicAuthStrategy) ApplyOnRR(req *retryablehttp.Request) {
d.Dynamic.ApplyStrategies(func(s AuthStrategy) {
strategy := d.Dynamic.GetStrategies()
for _, s := range strategy {
s.ApplyOnRR(req)
})
}
// OnResponse inspects a response status code and marks the dynamic session for
// re-authentication when the code signals an expired session.
func (d *DynamicAuthStrategy) OnResponse(statusCode int) bool {
return d.Dynamic.NotifyResponse(statusCode)
}
// WebStorage exposes the browser web storage captured by a headless auto-login
// so the headless engine can replay it into scan pages.
func (d *DynamicAuthStrategy) WebStorage() (map[string]string, map[string]string) {
return d.Dynamic.WebStorage()
}
}
@@ -1,47 +0,0 @@
package authx
import (
"net/http"
"github.com/projectdiscovery/retryablehttp-go"
)
var (
_ AuthStrategy = &WebStorageAuthStrategy{}
_ BrowserStorageProvider = &WebStorageAuthStrategy{}
)
// BrowserStorageProvider is an optional interface implemented by auth strategies
// that carry browser web storage (localStorage/sessionStorage) to be replayed
// into headless scan pages. HTTP-only strategies do not implement it, so the
// headless engine type-asserts for it before seeding storage.
type BrowserStorageProvider interface {
// WebStorage returns the localStorage and sessionStorage items to seed.
WebStorage() (local map[string]string, session map[string]string)
}
// WebStorageAuthStrategy carries captured web storage. Its Apply methods are
// intentional no-ops: web storage cannot be expressed on an HTTP request, so it
// only takes effect in the headless engine, which reads it via the
// BrowserStorageProvider interface and seeds it into the page before page
// scripts run.
type WebStorageAuthStrategy struct {
Local map[string]string
Session map[string]string
}
// NewWebStorageAuthStrategy creates a new web storage auth strategy.
func NewWebStorageAuthStrategy(local, session map[string]string) *WebStorageAuthStrategy {
return &WebStorageAuthStrategy{Local: local, Session: session}
}
// Apply is a no-op (web storage is not an HTTP request artifact).
func (s *WebStorageAuthStrategy) Apply(_ *http.Request) {}
// ApplyOnRR is a no-op (web storage is not an HTTP request artifact).
func (s *WebStorageAuthStrategy) ApplyOnRR(_ *retryablehttp.Request) {}
// WebStorage returns the carried storage items.
func (s *WebStorageAuthStrategy) WebStorage() (map[string]string, map[string]string) {
return s.Local, s.Session
}
@@ -1,40 +0,0 @@
package authx
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestWebStorageSecret_GetStrategy(t *testing.T) {
s := &Secret{
Type: string(WebStorageAuth),
Domains: []string{"app.example.com"},
LocalStorage: map[string]string{"jwt": "eyJ.payload.sig"},
SessionStorage: map[string]string{"csrf": "tok"},
}
require.NoError(t, s.Validate())
strategy := s.GetStrategy()
require.NotNil(t, strategy)
bsp, ok := strategy.(BrowserStorageProvider)
require.True(t, ok, "web storage secret must yield a BrowserStorageProvider")
local, session := bsp.WebStorage()
require.Equal(t, "eyJ.payload.sig", local["jwt"])
require.Equal(t, "tok", session["csrf"])
}
func TestWebStorageSecret_ValidateEmpty(t *testing.T) {
s := &Secret{Type: string(WebStorageAuth), Domains: []string{"app.example.com"}}
require.Error(t, s.Validate(), "web storage secret with no storage must be invalid")
}
func TestWebStorageSecret_SessionOnlyValid(t *testing.T) {
s := &Secret{
Type: string(WebStorageAuth),
Domains: []string{"app.example.com"},
SessionStorage: map[string]string{"csrf": "tok"},
}
require.NoError(t, s.Validate(), "session-storage alone should satisfy validation")
}
-124
View File
@@ -1,124 +0,0 @@
package autologin
import (
"context"
"net/url"
"regexp"
"strings"
"github.com/go-rod/rod/lib/proto"
"github.com/projectdiscovery/utils/errkit"
)
// CaptureOnce launches a visible browser at cfg.LoginURL and lets the user log
// in manually, then — once ready() returns — captures the resulting session
// (cookies, web storage and an optional token). It performs no automated form
// filling.
//
// This is the "log in yourself, I'll grab the session" mode for one-off
// authenticated scans of flows that are impractical to record/replay
// (hardware-key MFA, CAPTCHA, interactive SSO). Because the captured session is
// a point-in-time snapshot there is no automated re-authentication; ready
// blocks until the caller signals login completion (e.g. the user pressing
// Enter in the terminal).
func CaptureOnce(ctx context.Context, cfg Config, ready func() error) (*Session, error) {
if ready == nil {
return nil, errkit.New("auto-login(capture): a ready signal is required")
}
if strings.TrimSpace(cfg.LoginURL) == "" {
return nil, errkit.New("auto-login(capture): login-url is required")
}
if _, err := url.Parse(cfg.LoginURL); err != nil {
return nil, errkit.Wrap(err, "auto-login(capture): invalid login-url")
}
var tokenRe *regexp.Regexp
if cfg.TokenRegex != "" {
var err error
if tokenRe, err = regexp.Compile(cfg.TokenRegex); err != nil {
return nil, errkit.Wrap(err, "auto-login(capture): invalid token-regex")
}
}
// Force a visible browser so the user can interact with the login page.
cfg.ShowBrowser = true
browser, cleanup, err := launchBrowser(cfg)
if err != nil {
return nil, errkit.Wrap(err, "auto-login(capture): failed to launch browser")
}
defer cleanup()
page, err := browser.Page(proto.TargetCreateTarget{})
if err != nil {
return nil, errkit.Wrap(err, "auto-login(capture): failed to open page")
}
defer func() { _ = page.Close() }()
page = page.Context(ctx)
if cfg.UserAgent != "" {
if uaErr := page.SetUserAgent(&proto.NetworkSetUserAgentOverride{UserAgent: cfg.UserAgent}); uaErr != nil {
return nil, errkit.Wrap(uaErr, "auto-login(capture): failed to set user-agent")
}
}
if len(cfg.CustomHeaders) > 0 {
pairs := make([]string, 0, len(cfg.CustomHeaders)*2)
for k, v := range cfg.CustomHeaders {
pairs = append(pairs, k, v)
}
if _, hErr := page.SetExtraHeaders(pairs); hErr != nil {
return nil, errkit.Wrap(hErr, "auto-login(capture): failed to set custom headers")
}
}
if err := page.Navigate(cfg.LoginURL); err != nil {
return nil, errkit.Wrap(err, "auto-login(capture): failed to navigate to login page")
}
_ = page.WaitLoad()
// Block until the user signals that the manual login is complete.
if err := ready(); err != nil {
return nil, errkit.Wrap(err, "auto-login(capture): aborted before capture")
}
if err := ctx.Err(); err != nil {
return nil, err
}
session := &Session{}
if info, ierr := page.Info(); ierr == nil {
session.FinalURL = info.URL
}
session.Cookies = capturePageCookies(page, cfg.LoginURL, session.FinalURL)
session.CookieHeader = renderCookieHeader(session.Cookies)
session.LocalStorage = readStorage(page, "localStorage")
session.SessionStorage = readStorage(page, "sessionStorage")
if tokenRe != nil {
var haystack strings.Builder
if html, herr := page.HTML(); herr == nil {
haystack.WriteString(html)
}
for _, store := range []map[string]string{session.LocalStorage, session.SessionStorage} {
for k, v := range store {
haystack.WriteString("\n")
haystack.WriteString(k)
haystack.WriteString("=")
haystack.WriteString(v)
}
}
if m := tokenRe.FindStringSubmatch(haystack.String()); len(m) > 1 {
session.Token = m[1]
}
}
// When no explicit token-regex matched, surface a JWT-shaped value from web
// storage so a captured SPA session also authenticates HTTP scan requests.
if session.Token == "" {
session.Token = detectStorageJWT(session.LocalStorage, session.SessionStorage)
}
if len(session.Cookies) == 0 && session.Token == "" && len(session.LocalStorage) == 0 && len(session.SessionStorage) == 0 {
return nil, errkit.New("auto-login(capture): no session captured (no cookies, token or web storage found)")
}
return session, nil
}
@@ -1,97 +0,0 @@
package autologin
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"runtime"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// requireDisplay skips headful (visible-browser) tests when no display server is
// available, e.g. headless Linux CI. macOS and Windows always provide one, but
// capture-once forces a visible browser which cannot start without an X/Wayland
// display.
func requireDisplay(t *testing.T) {
t.Helper()
if runtime.GOOS == "linux" && os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" {
t.Skip("no display server ($DISPLAY/$WAYLAND_DISPLAY) available; skipping headful capture-once test")
}
}
// capturePage already "logs the user in" on GET (sets a session cookie and seeds
// web storage via JS), so a test can drive CaptureOnce without simulating manual
// interaction: the ready() callback simply returns once the page has loaded.
const capturePage = `<html><head><title>App</title></head><body>
<h1>welcome dave</h1>
<script>
window.localStorage.setItem('auth_token', 'eyJhbGciOiJI.payload.sig');
window.sessionStorage.setItem('csrf', 'abc123');
</script></body></html>`
func TestCaptureOnce_E2E(t *testing.T) {
requireChrome(t)
requireDisplay(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{Name: "session", Value: "sess-dave", Path: "/"})
w.Header().Set("Content-Type", "text/html")
_, _ = fmt.Fprint(w, capturePage)
}))
defer srv.Close()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
var readyCalled bool
session, err := CaptureOnce(ctx, Config{
LoginURL: srv.URL + "/login",
TokenRegex: `(eyJ[A-Za-z0-9._-]+)`,
}, func() error {
readyCalled = true // stands in for the user pressing Enter after logging in
return nil
})
require.NoError(t, err)
require.True(t, readyCalled, "ready callback must be invoked before capture")
names := map[string]string{}
for _, c := range session.Cookies {
names[c.Name] = c.Value
}
require.Equal(t, "sess-dave", names["session"], "session cookie must be captured")
require.Equal(t, "eyJhbGciOiJI.payload.sig", session.LocalStorage["auth_token"], "localStorage must be captured")
require.Equal(t, "abc123", session.SessionStorage["csrf"], "sessionStorage must be captured")
require.Equal(t, "eyJhbGciOiJI.payload.sig", session.Token, "token must be extracted")
}
func TestCaptureOnce_ReadyErrorAborts(t *testing.T) {
requireChrome(t)
requireDisplay(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprint(w, "<html><body>login</body></html>")
}))
defer srv.Close()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
_, err := CaptureOnce(ctx, Config{LoginURL: srv.URL + "/login"}, func() error {
return errors.New("user cancelled")
})
require.Error(t, err, "a ready() error must abort the capture")
}
func TestCaptureOnce_Validation(t *testing.T) {
_, err := CaptureOnce(context.Background(), Config{LoginURL: "https://x"}, nil)
require.Error(t, err, "nil ready must error")
_, err = CaptureOnce(context.Background(), Config{}, func() error { return nil })
require.Error(t, err, "missing login-url must error")
}
-311
View File
@@ -1,311 +0,0 @@
// Package autologin implements turnkey form-based authentication for nuclei's
// authenticated scanning. Instead of requiring a hand-written auth template that
// performs the login and extracts a token, the caller supplies only a login URL
// and credentials; this package fetches the login page, auto-detects the login
// form (username/password fields plus any hidden CSRF tokens), submits the
// credentials and captures the resulting session.
//
// detect.go contains the pure, network-free form-detection logic so it can be
// unit-tested in isolation from the HTTP engine in login.go.
package autologin
import (
"net/url"
"strings"
"github.com/projectdiscovery/utils/errkit"
"golang.org/x/net/html"
)
var (
// ErrNoLoginForm is returned when the page contains no form with a password
// field (the minimal signal that a form is a credential-login form).
ErrNoLoginForm = errkit.New("no login form (password field) found on page")
)
// LoginForm describes a detected HTML login form and everything needed to
// submit it.
type LoginForm struct {
// Action is the absolute URL the form submits to, resolved against the page URL.
Action string
// Method is the upper-cased HTTP method ("POST" or "GET").
Method string
// EncType is the form encoding ("application/x-www-form-urlencoded" or
// "multipart/form-data"); empty means the urlencoded default.
EncType string
// UsernameField is the name of the input that should receive the username.
// It may be empty when no plausible username field is present (e.g. a
// password-only or token form), in which case the caller may still submit.
UsernameField string
// PasswordField is the name of the password input. Always non-empty on a
// successfully detected form.
PasswordField string
// Fields holds extra form values that must be submitted verbatim — most
// importantly hidden inputs carrying CSRF tokens, plus any pre-filled
// defaults and a named submit button. Username/password are NOT included
// here; the engine fills those into UsernameField/PasswordField.
Fields map[string]string
}
// usernameHintTokens are substrings (matched case-insensitively against an
// input's name/id/autocomplete) that strongly suggest a username/identifier
// field. Ordered roughly by specificity.
var usernameHintTokens = []string{"username", "user", "email", "e-mail", "login", "account", "userid", "uid", "identifier", "phone"}
// DetectLoginForm parses the given HTML page body and returns the best
// credential-login form found on it. pageURL is the absolute URL the body was
// fetched from and is used to resolve relative form actions. It returns
// ErrNoLoginForm if no form containing a password field exists.
func DetectLoginForm(body string, pageURL *url.URL) (*LoginForm, error) {
doc, err := html.Parse(strings.NewReader(body))
if err != nil {
return nil, errkit.Wrap(err, "failed to parse html")
}
var forms []*html.Node
collectForms(doc, &forms)
var best *LoginForm
bestScore := -1
for _, formNode := range forms {
candidate, score := buildLoginForm(formNode, pageURL)
if candidate == nil {
continue
}
// Prefer the highest-scoring form, keeping the first on ties so document
// order acts as the tie-breaker.
if score > bestScore {
best = candidate
bestScore = score
}
}
if best == nil {
return nil, ErrNoLoginForm
}
return best, nil
}
// collectForms walks the parse tree depth-first and appends every <form> node.
func collectForms(n *html.Node, out *[]*html.Node) {
if n.Type == html.ElementNode && n.Data == "form" {
*out = append(*out, n)
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
collectForms(c, out)
}
}
// fieldNode is a flattened view of a form control relevant to login detection.
type fieldNode struct {
tag string // input, select, textarea, button
typ string // lower-cased type attribute (inputs)
name string
id string
value string
autoComp string // autocomplete attribute
}
// buildLoginForm inspects a single <form> node and, if it looks like a
// credential-login form (contains exactly one usable password field), returns a
// populated LoginForm together with a heuristic score used to pick the best
// form on a page. A nil form means "not a login form".
func buildLoginForm(formNode *html.Node, pageURL *url.URL) (*LoginForm, int) {
var fields []fieldNode
collectFields(formNode, &fields)
// Locate the password field. A form with no password field is not a
// credential-login form; a form with several password fields is almost
// always a registration / change-password form, so we skip it.
passwordIdx := -1
passwordCount := 0
for i, f := range fields {
if f.tag == "input" && f.typ == "password" {
passwordCount++
if passwordIdx == -1 {
passwordIdx = i
}
}
}
if passwordIdx == -1 || passwordCount > 1 {
return nil, 0
}
password := fields[passwordIdx]
if password.name == "" {
// A password field with no name cannot be submitted.
return nil, 0
}
form := &LoginForm{
Method: resolveMethod(formNode),
EncType: strings.ToLower(strings.TrimSpace(getAttr(formNode, "enctype"))),
PasswordField: password.name,
Fields: map[string]string{},
}
form.Action = resolveAction(formNode, pageURL)
usernameIdx := pickUsernameField(fields, passwordIdx)
if usernameIdx >= 0 {
form.UsernameField = fields[usernameIdx].name
}
// Carry along hidden inputs (CSRF tokens etc.), pre-filled defaults and a
// single named submit button, but never the username/password controls.
submitAdded := false
for i, f := range fields {
if i == passwordIdx || i == usernameIdx || f.name == "" {
continue
}
switch {
case f.tag == "input" && f.typ == "hidden":
form.Fields[f.name] = f.value
case f.tag == "input" && (f.typ == "submit" || f.typ == "image"):
// Many backends key off the submit button name; include the first one.
if !submitAdded && f.value != "" {
form.Fields[f.name] = f.value
submitAdded = true
}
case f.tag == "button" && (f.typ == "submit" || f.typ == ""):
if !submitAdded && f.value != "" {
form.Fields[f.name] = f.value
submitAdded = true
}
case f.value != "" && f.typ != "checkbox" && f.typ != "radio":
// Preserve pre-filled defaults for other text-like inputs so we don't
// drop values the server expects.
form.Fields[f.name] = f.value
}
}
// Score: a real login form usually has both a username and a password field;
// CSRF/hidden tokens raise confidence further. This lets DetectLoginForm
// prefer the actual login form over, say, a newsletter box that happens to
// share the page.
score := 1
if form.UsernameField != "" {
score += 2
}
if len(form.Fields) > 0 {
score++
}
return form, score
}
// collectFields flattens the form controls within a form node in document order.
func collectFields(n *html.Node, out *[]fieldNode) {
for c := n.FirstChild; c != nil; c = c.NextSibling {
if c.Type == html.ElementNode {
switch c.Data {
case "input", "select", "textarea", "button":
*out = append(*out, fieldNode{
tag: c.Data,
typ: strings.ToLower(strings.TrimSpace(getAttr(c, "type"))),
name: getAttr(c, "name"),
id: getAttr(c, "id"),
value: getAttr(c, "value"),
autoComp: strings.ToLower(strings.TrimSpace(getAttr(c, "autocomplete"))),
})
}
}
collectFields(c, out)
}
}
// pickUsernameField selects the most likely username/identifier field. It
// considers only text-like inputs appearing before the password field and
// prefers ones whose name/id/autocomplete match a known hint token; otherwise
// it falls back to the closest text-like input preceding the password.
func pickUsernameField(fields []fieldNode, passwordIdx int) int {
isCandidate := func(f fieldNode) bool {
if f.tag != "input" {
return false
}
switch f.typ {
case "", "text", "email", "tel", "search":
return f.name != ""
default:
return false
}
}
// First pass: hint-token match among candidates preceding the password field.
bestHint := -1
bestHintRank := len(usernameHintTokens)
for i := 0; i < passwordIdx; i++ {
f := fields[i]
if !isCandidate(f) {
continue
}
hay := strings.ToLower(f.name + " " + f.id + " " + f.autoComp)
for rank, tok := range usernameHintTokens {
if strings.Contains(hay, tok) && rank < bestHintRank {
bestHint = i
bestHintRank = rank
}
}
}
if bestHint >= 0 {
return bestHint
}
// Second pass: closest text-like input before the password field.
for i := passwordIdx - 1; i >= 0; i-- {
if isCandidate(fields[i]) {
return i
}
}
// Last resort: first text-like input anywhere in the form (covers oddly
// ordered markup where the username sits after the password).
for i := range fields {
if i == passwordIdx {
continue
}
if isCandidate(fields[i]) {
return i
}
}
return -1
}
// resolveMethod returns the upper-cased form method, defaulting to POST. The
// HTML default is GET, but credential-login forms virtually always POST, and a
// GET login would leak credentials into the URL/query — so for an autonomous
// login submitter POST is the safer default when unspecified.
func resolveMethod(formNode *html.Node) string {
m := strings.ToUpper(strings.TrimSpace(getAttr(formNode, "method")))
if m == "" {
return "POST"
}
return m
}
// resolveAction resolves the form action against the page URL. An empty/missing
// action submits back to the page itself.
func resolveAction(formNode *html.Node, pageURL *url.URL) string {
action := strings.TrimSpace(getAttr(formNode, "action"))
if action == "" {
if pageURL != nil {
return pageURL.String()
}
return ""
}
if pageURL == nil {
return action
}
ref, err := url.Parse(action)
if err != nil {
return action
}
return pageURL.ResolveReference(ref).String()
}
// getAttr returns the value of the named attribute (case-insensitive) on an
// element node, or "" if absent.
func getAttr(n *html.Node, key string) string {
for _, a := range n.Attr {
if strings.EqualFold(a.Key, key) {
return a.Val
}
}
return ""
}
-177
View File
@@ -1,177 +0,0 @@
package autologin
import (
"net/url"
"testing"
"github.com/stretchr/testify/require"
)
func mustURL(t *testing.T, raw string) *url.URL {
t.Helper()
u, err := url.Parse(raw)
require.NoError(t, err)
return u
}
func TestDetectLoginForm_Simple(t *testing.T) {
body := `<html><body>
<form action="/login" method="post">
<input type="text" name="username">
<input type="password" name="password">
<button type="submit">Log in</button>
</form>
</body></html>`
form, err := DetectLoginForm(body, mustURL(t, "https://app.example.com/auth"))
require.NoError(t, err)
require.Equal(t, "POST", form.Method)
require.Equal(t, "https://app.example.com/login", form.Action)
require.Equal(t, "username", form.UsernameField)
require.Equal(t, "password", form.PasswordField)
}
func TestDetectLoginForm_EmailAndCSRF(t *testing.T) {
body := `<html><body>
<form action="https://id.example.com/sessions" method="POST">
<input type="hidden" name="csrf_token" value="abc123">
<input type="email" name="email" autocomplete="username">
<input type="password" name="pass">
<input type="submit" name="commit" value="Sign in">
</form>
</body></html>`
form, err := DetectLoginForm(body, mustURL(t, "https://id.example.com/login"))
require.NoError(t, err)
require.Equal(t, "https://id.example.com/sessions", form.Action)
require.Equal(t, "email", form.UsernameField)
require.Equal(t, "pass", form.PasswordField)
require.Equal(t, "abc123", form.Fields["csrf_token"], "CSRF hidden input must be carried")
require.Equal(t, "Sign in", form.Fields["commit"], "named submit button should be carried")
require.NotContains(t, form.Fields, "email", "username field must not be in extra fields")
require.NotContains(t, form.Fields, "pass", "password field must not be in extra fields")
}
func TestDetectLoginForm_RelativeActionResolution(t *testing.T) {
body := `<form action="../session" method="post">
<input type="text" name="user">
<input type="password" name="pwd">
</form>`
form, err := DetectLoginForm(body, mustURL(t, "https://example.com/app/login"))
require.NoError(t, err)
require.Equal(t, "https://example.com/session", form.Action)
}
func TestDetectLoginForm_MissingActionUsesPageURL(t *testing.T) {
body := `<form method="post">
<input type="text" name="user">
<input type="password" name="pwd">
</form>`
form, err := DetectLoginForm(body, mustURL(t, "https://example.com/login?next=/home"))
require.NoError(t, err)
require.Equal(t, "https://example.com/login?next=/home", form.Action)
}
func TestDetectLoginForm_DefaultMethodIsPost(t *testing.T) {
body := `<form action="/login">
<input type="text" name="user">
<input type="password" name="pwd">
</form>`
form, err := DetectLoginForm(body, mustURL(t, "https://example.com/login"))
require.NoError(t, err)
require.Equal(t, "POST", form.Method)
}
func TestDetectLoginForm_PicksLoginFormAmongMany(t *testing.T) {
body := `<html><body>
<form action="/search" method="get">
<input type="text" name="q">
<input type="submit" value="Search">
</form>
<form action="/newsletter" method="post">
<input type="email" name="newsletter_email">
<input type="submit" value="Subscribe">
</form>
<form action="/login" method="post">
<input type="hidden" name="_token" value="xyz">
<input type="text" name="login" autocomplete="username">
<input type="password" name="password">
<input type="submit" value="Sign in">
</form>
</body></html>`
form, err := DetectLoginForm(body, mustURL(t, "https://example.com/"))
require.NoError(t, err)
require.Equal(t, "https://example.com/login", form.Action)
require.Equal(t, "login", form.UsernameField)
require.Equal(t, "password", form.PasswordField)
require.Equal(t, "xyz", form.Fields["_token"])
}
func TestDetectLoginForm_UsernameHintBeatsOrder(t *testing.T) {
// Two text inputs precede the password; the one with a username hint should
// win even though it is not the closest to the password field.
body := `<form action="/login" method="post">
<input type="text" name="email" autocomplete="username">
<input type="text" name="captcha">
<input type="password" name="password">
</form>`
form, err := DetectLoginForm(body, mustURL(t, "https://example.com/login"))
require.NoError(t, err)
require.Equal(t, "email", form.UsernameField)
require.NotContains(t, form.Fields, "captcha", "valueless non-username text input should not be injected")
}
func TestDetectLoginForm_NoPasswordField(t *testing.T) {
body := `<form action="/search" method="get">
<input type="text" name="q">
<input type="submit" value="Search">
</form>`
_, err := DetectLoginForm(body, mustURL(t, "https://example.com/"))
require.ErrorIs(t, err, ErrNoLoginForm)
}
func TestDetectLoginForm_SkipsMultiPasswordForms(t *testing.T) {
// A registration / change-password form (two password fields) must not be
// treated as a login form.
body := `<form action="/register" method="post">
<input type="text" name="username">
<input type="password" name="password">
<input type="password" name="password_confirm">
</form>`
_, err := DetectLoginForm(body, mustURL(t, "https://example.com/"))
require.ErrorIs(t, err, ErrNoLoginForm)
}
func TestDetectLoginForm_PasswordOnlyForm(t *testing.T) {
// A password-only form (no username) is still a valid login form.
body := `<form action="/unlock" method="post">
<input type="hidden" name="token" value="t">
<input type="password" name="password">
</form>`
form, err := DetectLoginForm(body, mustURL(t, "https://example.com/unlock"))
require.NoError(t, err)
require.Equal(t, "", form.UsernameField)
require.Equal(t, "password", form.PasswordField)
require.Equal(t, "t", form.Fields["token"])
}
func TestDetectLoginForm_DefaultTypeIsText(t *testing.T) {
// An input with no type attribute defaults to text and should be eligible as
// the username field.
body := `<form action="/login" method="post">
<input name="user">
<input type="password" name="pwd">
</form>`
form, err := DetectLoginForm(body, mustURL(t, "https://example.com/login"))
require.NoError(t, err)
require.Equal(t, "user", form.UsernameField)
}
-649
View File
@@ -1,649 +0,0 @@
package autologin
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"regexp"
"sort"
"strings"
"sync"
"time"
"github.com/go-rod/rod"
"github.com/go-rod/rod/lib/input"
"github.com/go-rod/rod/lib/launcher"
"github.com/go-rod/rod/lib/proto"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/utils/errkit"
fileutil "github.com/projectdiscovery/utils/file"
osutils "github.com/projectdiscovery/utils/os"
)
// LoginHeadless performs a browser-driven auto-login using go-rod. Unlike the
// HTTP engine (Login), it executes JavaScript, so it works on SPA/JS-rendered
// login pages, client-side form construction and multi-step / SSO redirect
// flows that the static HTTP form submitter cannot handle. It navigates to the
// login page in a real (headless) browser, fills the detected credential
// fields, submits, waits for the page to settle and captures the resulting
// session (cookies, plus an optional token from the body or localStorage).
//
// It intentionally drives go-rod directly rather than nuclei's headless engine
// package: that package imports authprovider, and autologin is (transitively)
// imported by authprovider, so depending on it would create an import cycle.
func LoginHeadless(ctx context.Context, cfg Config) (*Session, error) {
if strings.TrimSpace(cfg.LoginURL) == "" {
return nil, errkit.New("auto-login(headless): login-url is required")
}
loginURL, err := url.Parse(cfg.LoginURL)
if err != nil {
return nil, errkit.Wrap(err, "auto-login(headless): invalid login-url")
}
var tokenRe *regexp.Regexp
if cfg.TokenRegex != "" {
if tokenRe, err = regexp.Compile(cfg.TokenRegex); err != nil {
return nil, errkit.Wrap(err, "auto-login(headless): invalid token-regex")
}
}
timeout := cfg.Timeout
if timeout == 0 {
timeout = 45 * time.Second
}
settle := cfg.SettleTime
if settle == 0 {
settle = 5 * time.Second
}
browser, cleanup, err := launchBrowser(cfg)
if err != nil {
return nil, errkit.Wrap(err, "auto-login(headless): failed to launch browser")
}
defer cleanup()
// Use an incognito context for a clean, isolated cookie jar per login.
incognito, err := browser.Incognito()
if err != nil {
return nil, errkit.Wrap(err, "auto-login(headless): failed to create incognito context")
}
page, err := incognito.Page(proto.TargetCreateTarget{})
if err != nil {
return nil, errkit.Wrap(err, "auto-login(headless): failed to open page")
}
defer func() { _ = page.Close() }()
page = page.Context(ctx).Timeout(timeout)
// Passively observe response headers so a token delivered via a response
// header (Authorization, X-Auth-Token, ...) during an XHR login is captured,
// mirroring the HTTP engine. This is observation-only (no request hijacking)
// to avoid interfering with the login flow. The listener stops when the page
// is closed (deferred above).
//
// Two safeguards keep this from latching onto the wrong token: we only honor
// responses served by the login origin (a page loads many third-party
// requests — CDNs, analytics — whose headers must not be mistaken for the
// app's session token), and we keep the first match (first-party auth
// responses come early; a later beacon must not clobber the real token).
var (
headerTokenMu sync.Mutex
headerToken string
)
if err := (proto.NetworkEnable{}).Call(page); err == nil {
go page.EachEvent(func(e *proto.NetworkResponseReceived) {
if e.Response == nil {
return
}
if !sameLoginOrigin(loginURL, e.Response.URL) {
return
}
hdr := make(http.Header, len(e.Response.Headers))
for k, v := range e.Response.Headers {
hdr.Set(k, v.Str())
}
if tok := detectHeaderToken(hdr, tokenRe); tok != "" {
headerTokenMu.Lock()
if headerToken == "" {
headerToken = tok
}
headerTokenMu.Unlock()
}
})()
}
// Apply UA / custom headers before navigating so the login request carries
// the same identity as the scan.
if cfg.UserAgent != "" {
if uaErr := page.SetUserAgent(&proto.NetworkSetUserAgentOverride{UserAgent: cfg.UserAgent}); uaErr != nil {
return nil, errkit.Wrap(uaErr, "auto-login(headless): failed to set user-agent")
}
}
if len(cfg.CustomHeaders) > 0 {
pairs := make([]string, 0, len(cfg.CustomHeaders)*2)
for k, v := range cfg.CustomHeaders {
pairs = append(pairs, k, v)
}
if _, hErr := page.SetExtraHeaders(pairs); hErr != nil {
return nil, errkit.Wrap(hErr, "auto-login(headless): failed to set custom headers")
}
}
if err := page.Navigate(cfg.LoginURL); err != nil {
return nil, errkit.Wrap(err, "auto-login(headless): failed to navigate to login page")
}
if err := page.WaitLoad(); err != nil {
return nil, errkit.Wrap(err, "auto-login(headless): login page failed to load")
}
// Give client-side scripts a chance to render the form.
_ = rod.Try(func() { page.Timeout(settle).MustWaitStable() })
if len(cfg.Steps) > 0 {
// Explicit multi-step flow (username-first / SSO / consent screens).
if err := runLoginSteps(ctx, page, cfg, settle); err != nil {
return nil, err
}
} else if err := autoFillAndSubmit(ctx, page, cfg, loginURL, settle); err != nil {
return nil, err
}
// Wait for the post-submit navigation / SPA state to settle.
_ = rod.Try(func() { page.Timeout(settle).MustWaitStable() })
_ = page.WaitLoad()
session := &Session{}
if info, ierr := page.Info(); ierr == nil {
session.FinalURL = info.URL
}
// Capture cookies and web storage from the browser for the login + final hosts.
session.Cookies = capturePageCookies(page, cfg.LoginURL, session.FinalURL)
session.CookieHeader = renderCookieHeader(session.Cookies)
session.LocalStorage = readStorage(page, "localStorage")
session.SessionStorage = readStorage(page, "sessionStorage")
// Token extraction: search the rendered body and web storage.
if tokenRe != nil {
var haystack strings.Builder
if html, herr := page.HTML(); herr == nil {
haystack.WriteString(html)
}
for _, store := range []map[string]string{session.LocalStorage, session.SessionStorage} {
for k, v := range store {
haystack.WriteString("\n")
haystack.WriteString(k)
haystack.WriteString("=")
haystack.WriteString(v)
}
}
if m := tokenRe.FindStringSubmatch(haystack.String()); len(m) > 1 {
session.Token = m[1]
}
}
// Fallback: many SPAs keep their bearer token in web storage (e.g.
// localStorage["token"]) rather than a cookie. When no explicit token-regex
// matched, surface a JWT-shaped value from web storage as the session token
// so HTTP scan requests carry an Authorization header without the user
// having to specify a token-regex.
if session.Token == "" {
session.Token = detectStorageJWT(session.LocalStorage, session.SessionStorage)
}
// Last resort: a token observed in a response header during the login (e.g.
// an XHR auth call that returned Authorization / X-Auth-Token).
if session.Token == "" {
headerTokenMu.Lock()
session.Token = headerToken
headerTokenMu.Unlock()
}
// Success heuristic: being re-prompted (final page still presents a login
// form) with no token means the login failed.
if session.Token == "" {
if html, herr := page.HTML(); herr == nil {
var finalParsed *url.URL
if session.FinalURL != "" {
finalParsed, _ = url.Parse(session.FinalURL)
}
if _, derr := DetectLoginForm(html, finalParsed); derr == nil {
return nil, errkit.Wrapf(ErrLoginFailed, "still presented a login form at %s", session.FinalURL)
}
}
}
if len(session.Cookies) == 0 && session.Token == "" {
return nil, errkit.Wrapf(ErrLoginFailed, "no cookies or token captured at %s", session.FinalURL)
}
return session, nil
}
// sameLoginOrigin reports whether respURL is served by the same host as the
// login page. It is used to ignore third-party (CDN/analytics) responses when
// passively scavenging a session token from response headers. An unparsable
// respURL is treated as not matching (fail closed).
func sameLoginOrigin(loginURL *url.URL, respURL string) bool {
if loginURL == nil || respURL == "" {
return false
}
u, err := url.Parse(respURL)
if err != nil {
return false
}
return strings.EqualFold(u.Hostname(), loginURL.Hostname())
}
// jwtValueRe matches a JWT-shaped value (header.payload.signature in base64url).
// It is intentionally not anchored so it also matches tokens wrapped in quotes
// or embedded in a small JSON blob stored in web storage.
var jwtValueRe = regexp.MustCompile(`eyJ[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{6,}\.[A-Za-z0-9_-]{4,}`)
// detectStorageJWT returns the first JWT-shaped value found across the supplied
// web storage maps, or "" when none is present. Keys that conventionally hold an
// auth token are inspected first (and otherwise keys are visited in sorted order)
// so the result is deterministic across runs.
func detectStorageJWT(stores ...map[string]string) string {
preferredKeys := []string{"token", "access_token", "accesstoken", "jwt", "auth_token", "authtoken", "id_token", "idtoken", "bearer"}
for _, store := range stores {
if len(store) == 0 {
continue
}
for _, want := range preferredKeys {
for k, v := range store {
if strings.EqualFold(k, want) {
if m := jwtValueRe.FindString(v); m != "" {
return m
}
}
}
}
}
for _, store := range stores {
if len(store) == 0 {
continue
}
keys := make([]string, 0, len(store))
for k := range store {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
if m := jwtValueRe.FindString(store[k]); m != "" {
return m
}
}
}
return ""
}
// launchBrowser launches (or connects to) a browser and returns it with a
// cleanup func. A system-installed Chrome is preferred when available to avoid
// triggering a managed-browser download.
func launchBrowser(cfg Config) (*rod.Browser, func(), error) {
if cfg.CDPEndpoint != "" {
b := rod.New().ControlURL(cfg.CDPEndpoint)
if err := b.Connect(); err != nil {
return nil, nil, err
}
return b, func() { _ = b.Close() }, nil
}
l := launcher.New().
Set("disable-gpu").
Set("disable-notifications").
Set("ignore-certificate-errors").
Set("ignore-ssl-errors").
Set("incognito").
Headless(!cfg.ShowBrowser).
Leakless(false)
if osutils.IsLinux() {
l = l.NoSandbox(true)
}
if cfg.Proxy != "" {
l = l.Proxy(cfg.Proxy)
}
// Mirror nuclei's headless engine browser selection: on musl (most likely
// Alpine) the managed Chromium download cannot run, so a system Chrome is
// required — as it is when the user forces it. Otherwise prefer a system
// Chrome when present and fall back to go-rod's managed browser, logging so a
// one-time download is not surprising in a server/CI context.
executablePath, _ := os.Executable()
useMusl, _ := fileutil.UseMusl(executablePath)
if path, ok := launcher.LookPath(); ok {
l = l.Bin(path)
} else if cfg.UseInstalledChrome || useMusl {
return nil, nil, errkit.New("auto-login(headless): no chrome/chromium binary found (a system install is required here)")
} else {
gologger.Info().Msgf("auto-login(headless): no system chrome found, using go-rod managed browser (a one-time download may occur)")
}
controlURL, err := l.Launch()
if err != nil {
return nil, nil, err
}
b := rod.New().ControlURL(controlURL)
if err := b.Connect(); err != nil {
l.Kill()
return nil, nil, err
}
return b, func() { _ = b.Close(); l.Kill() }, nil
}
// autoFillAndSubmit runs the default single-shot login: detect the password
// (and username) field, type the credentials and submit.
func autoFillAndSubmit(ctx context.Context, page *rod.Page, cfg Config, loginURL *url.URL, settle time.Duration) error {
// SPA forms are frequently injected after an async tick, so the password
// field may not exist immediately after the initial settle. Wait (bounded by
// settle) for it to appear before detecting, rather than failing outright.
_ = waitVisible(ctx, page, `input[type="password"]`, settle)
// Prefer field names detected from the rendered DOM; fall back to type-based
// selectors for SPA inputs that carry no name attribute.
var detected *LoginForm
if html, herr := page.HTML(); herr == nil {
detected, _ = DetectLoginForm(html, loginURL)
}
passEl := locatePasswordField(page, cfg, detected)
if passEl == nil {
return errkit.Wrap(ErrNoLoginForm, "auto-login(headless): no password field found in rendered page")
}
if userEl := locateUsernameField(page, cfg, detected); userEl != nil && cfg.Username != "" {
if err := typeInto(userEl, cfg.Username); err != nil {
return errkit.Wrap(err, "auto-login(headless): failed to type username")
}
}
if err := typeInto(passEl, cfg.Password); err != nil {
return errkit.Wrap(err, "auto-login(headless): failed to type password")
}
// Let the framework register the typed input and validate the form (which
// often gates the submit button) before attempting to submit.
_ = rod.Try(func() { page.Timeout(settle).MustWaitStable() })
return submitForm(page, passEl, settle)
}
// submitForm submits a login form. It prefers clicking a submit control, but
// frameworks frequently keep that control disabled until the form validates, so
// the click is bounded by interactable-wait: if the button never becomes
// interactable (or the click fails) it falls back to pressing Enter in the
// given field, which reliably triggers native/framework form submission.
func submitForm(page *rod.Page, fallbackField *rod.Element, settle time.Duration) error {
clickTimeout := settle
if clickTimeout <= 0 {
clickTimeout = 5 * time.Second
}
if btn := findVisible(page, `button[type="submit"]`, `input[type="submit"]`, `button:not([type])`, `[role="button"]`); btn != nil {
// Bound the interactable-wait so a permanently-disabled button can't hang
// the whole login; on failure we fall through to Enter-to-submit.
clickErr := rod.Try(func() {
b := btn.Timeout(clickTimeout)
b.MustWaitInteractable()
_ = b.ScrollIntoView()
b.MustClick()
})
if clickErr == nil {
return nil
}
if fallbackField == nil {
return errkit.Wrapf(ErrLoginFailed, "auto-login(headless): submit button never became interactable: %s", clickErr)
}
// fall through to Enter-to-submit
}
if fallbackField != nil {
if err := fallbackField.Type(input.Enter); err != nil {
return errkit.Wrap(err, "auto-login(headless): failed to submit form")
}
return nil
}
return errkit.Wrap(ErrLoginFailed, "auto-login(headless): no submit control found and no field to submit")
}
// runLoginSteps executes an explicit multi-step login flow.
func runLoginSteps(ctx context.Context, page *rod.Page, cfg Config, settle time.Duration) error {
for i, step := range cfg.Steps {
action := strings.ToLower(strings.TrimSpace(step.Action))
switch action {
case "navigate":
if err := page.Navigate(step.Value); err != nil {
return errkit.Wrapf(err, "login step %d (navigate): failed", i)
}
_ = page.WaitLoad()
case "fill", "input", "type":
el := findVisible(page, byName(step.Selector), step.Selector)
if el == nil {
return errkit.Newf("login step %d (fill): element not found: %s", i, step.Selector)
}
if err := typeInto(el, expandCredentials(step.Value, cfg)); err != nil {
return errkit.Wrapf(err, "login step %d (fill): failed", i)
}
case "click":
el := findVisible(page, step.Selector)
if el == nil {
return errkit.Newf("login step %d (click): element not found: %s", i, step.Selector)
}
_ = el.ScrollIntoView()
if err := el.Click(proto.InputMouseButtonLeft, 1); err != nil {
return errkit.Wrapf(err, "login step %d (click): failed", i)
}
case "waitvisible":
if err := waitVisible(ctx, page, step.Selector, settle); err != nil {
return errkit.Wrapf(err, "login step %d (waitvisible)", i)
}
case "wait":
d := settle
if step.Value != "" {
if pd, perr := time.ParseDuration(step.Value); perr == nil {
d = pd
}
}
select {
case <-time.After(d):
case <-ctx.Done():
return ctx.Err()
}
case "press":
key, kerr := keyFromName(step.Value)
if kerr != nil {
return errkit.Wrapf(kerr, "login step %d (press)", i)
}
if step.Selector != "" {
el := findVisible(page, byName(step.Selector), step.Selector)
if el == nil {
return errkit.Newf("login step %d (press): element not found: %s", i, step.Selector)
}
if err := el.Type(key); err != nil {
return errkit.Wrapf(err, "login step %d (press): failed", i)
}
} else if err := page.Keyboard.Type(key); err != nil {
return errkit.Wrapf(err, "login step %d (press): failed", i)
}
case "submit":
if err := submitForm(page, findVisible(page, `input[type="password"]`), settle); err != nil {
return errkit.Wrapf(err, "login step %d (submit)", i)
}
default:
return errkit.Newf("login step %d: unknown action %q", i, step.Action)
}
// Let the page settle between steps so the next selector is present.
_ = rod.Try(func() { page.Timeout(settle).MustWaitStable() })
}
return nil
}
// expandCredentials substitutes the {{username}}/{{password}} placeholders in a
// step value with the configured credentials.
func expandCredentials(value string, cfg Config) string {
value = strings.ReplaceAll(value, "{{username}}", cfg.Username)
value = strings.ReplaceAll(value, "{{password}}", cfg.Password)
return value
}
// keyFromName maps a friendly key name to a go-rod input key.
func keyFromName(name string) (input.Key, error) {
switch strings.ToLower(strings.TrimSpace(name)) {
case "", "enter", "return":
return input.Enter, nil
case "tab":
return input.Tab, nil
case "escape", "esc":
return input.Escape, nil
case "space":
return input.Space, nil
default:
return 0, errkit.Newf("unsupported key %q (supported: enter, tab, escape, space)", name)
}
}
// waitVisible polls until the selector becomes visible or the timeout elapses.
func waitVisible(ctx context.Context, page *rod.Page, selector string, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
if findVisible(page, selector) != nil {
return nil
}
select {
case <-time.After(150 * time.Millisecond):
case <-ctx.Done():
return ctx.Err()
}
}
return errkit.Newf("timeout waiting for element to become visible: %s", selector)
}
// locatePasswordField finds the password input, honoring an explicit override
// (treated as a field name first, then as a raw CSS selector), then the
// detected name, then a generic selector.
func locatePasswordField(page *rod.Page, cfg Config, detected *LoginForm) *rod.Element {
if cfg.PasswordField != "" {
if el := findVisible(page, byName(cfg.PasswordField), cfg.PasswordField); el != nil {
return el
}
}
if detected != nil && detected.PasswordField != "" {
if el := findVisible(page, byName(detected.PasswordField)); el != nil {
return el
}
}
return findVisible(page, `input[type="password"]`)
}
// locateUsernameField finds the username/identifier input using the same
// override/detected/heuristic precedence as the password field.
func locateUsernameField(page *rod.Page, cfg Config, detected *LoginForm) *rod.Element {
if cfg.UsernameField != "" {
if el := findVisible(page, byName(cfg.UsernameField), cfg.UsernameField); el != nil {
return el
}
}
if detected != nil && detected.UsernameField != "" {
if el := findVisible(page, byName(detected.UsernameField)); el != nil {
return el
}
}
return findVisible(page,
`input[type="email"]`,
`input[autocomplete="username"]`,
`input[name*="user" i]`,
`input[name*="email" i]`,
`input[name*="login" i]`,
`input[type="text"]`,
)
}
// byName builds a CSS attribute selector matching an input by its name.
func byName(name string) string {
return fmt.Sprintf(`input[name=%q]`, name)
}
// findVisible returns the first visible element matching any of the selectors
// in priority order, or nil if none match.
func findVisible(page *rod.Page, selectors ...string) *rod.Element {
for _, sel := range selectors {
els, err := queryElements(page, sel)
if err != nil {
continue
}
for _, el := range els {
if vis, verr := el.Visible(); verr == nil && vis {
return el
}
}
}
return nil
}
// queryElements resolves a selector that may be CSS (default) or XPath. XPath
// selectors are prefixed with "xpath=" (emitted by the recording importer) or
// written as a raw "//"/"(//" expression. This lets recorded login flows, whose
// most stable selector is often an XPath, replay reliably.
func queryElements(page *rod.Page, sel string) (rod.Elements, error) {
switch {
case strings.HasPrefix(sel, "xpath="):
return page.ElementsX(strings.TrimPrefix(sel, "xpath="))
case strings.HasPrefix(sel, "//"), strings.HasPrefix(sel, "(//"):
return page.ElementsX(sel)
default:
return page.Elements(sel)
}
}
// typeInto focuses the element, clears any existing value and types the text so
// that JS frameworks observing input events (e.g. React controlled inputs) pick
// up the change.
func typeInto(el *rod.Element, text string) error {
_ = el.ScrollIntoView()
if err := el.Focus(); err != nil {
return err
}
// Clear any pre-filled value by selecting all then overwriting.
_ = el.SelectAllText()
return el.Input(text)
}
// capturePageCookies pulls cookies the browser holds for the given URLs and
// converts them to net/http cookies, de-duplicated by name.
func capturePageCookies(page *rod.Page, urls ...string) []*http.Cookie {
filtered := urls[:0]
for _, u := range urls {
if u != "" {
filtered = append(filtered, u)
}
}
cookies, err := page.Cookies(filtered)
if err != nil {
return nil
}
seen := map[string]bool{}
var out []*http.Cookie
for _, c := range cookies {
if c == nil || seen[c.Name] {
continue
}
seen[c.Name] = true
out = append(out, &http.Cookie{Name: c.Name, Value: c.Value, Domain: c.Domain, Path: c.Path, HttpOnly: c.HTTPOnly, Secure: c.Secure})
}
return out
}
// readStorage returns the given web storage area (localStorage/sessionStorage)
// as a map, or nil on failure.
func readStorage(page *rod.Page, area string) map[string]string {
obj, err := page.Eval(fmt.Sprintf(`() => JSON.stringify(window.%s)`, area))
if err != nil || obj == nil {
return nil
}
raw := obj.Value.Str()
if raw == "" {
return nil
}
out := map[string]string{}
if err := json.Unmarshal([]byte(raw), &out); err != nil {
return nil
}
if len(out) == 0 {
return nil
}
return out
}
-478
View File
@@ -1,478 +0,0 @@
package autologin
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/go-rod/rod/lib/launcher"
"github.com/stretchr/testify/require"
)
// requireChrome skips the test unless a system Chrome/Chromium is installed, so
// CI without a managed-browser download does not fail or hang.
func requireChrome(t *testing.T) {
t.Helper()
if _, ok := launcher.LookPath(); !ok {
t.Skip("no system chrome/chromium found; skipping headless auto-login test")
}
}
// jsLoginApp serves a login form that is constructed entirely by JavaScript:
// the raw HTML has no <form>, so the static HTTP engine cannot detect or submit
// it — only a real browser that executes JS can. This is the canonical case
// that motivates headless login.
type jsLoginApp struct {
mu sync.Mutex
sessions map[string]bool
}
func newJSLoginApp() *jsLoginApp { return &jsLoginApp{sessions: map[string]bool{}} }
func (a *jsLoginApp) server() *httptest.Server {
mux := http.NewServeMux()
mux.HandleFunc("/login", a.handleLogin)
mux.HandleFunc("/dashboard", a.handleDashboard)
return httptest.NewServer(mux)
}
const jsLoginPage = `<html><head><title>Login</title></head><body>
<div id="app"></div>
<script>
var f = document.createElement('form');
f.setAttribute('action', '/login');
f.setAttribute('method', 'post');
f.innerHTML = '<input type="email" name="email" autocomplete="username">' +
'<input type="password" name="password">' +
'<button type="submit">Sign in</button>';
document.getElementById('app').appendChild(f);
</script></body></html>`
func (a *jsLoginApp) handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
w.Header().Set("Content-Type", "text/html")
_, _ = fmt.Fprint(w, jsLoginPage)
return
}
_ = r.ParseForm()
if r.PostFormValue("email") != "[email protected]" || r.PostFormValue("password") != "p@ss" {
w.Header().Set("Content-Type", "text/html")
_, _ = fmt.Fprint(w, jsLoginPage) // re-render: still a (JS) login page
return
}
token := "sess-dave"
a.mu.Lock()
a.sessions[token] = true
a.mu.Unlock()
http.SetCookie(w, &http.Cookie{Name: "session", Value: token, Path: "/"})
http.Redirect(w, r, "/dashboard", http.StatusFound)
}
func (a *jsLoginApp) handleDashboard(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie("session")
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
a.mu.Lock()
ok := a.sessions[c.Value]
a.mu.Unlock()
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
_, _ = fmt.Fprint(w, "<html><body><h1>welcome dave</h1></body></html>")
}
func TestLoginHeadless_JSRenderedForm(t *testing.T) {
requireChrome(t)
app := newJSLoginApp()
srv := app.server()
defer srv.Close()
// Sanity: the HTTP engine cannot handle this page because the form only
// exists after JS runs — the raw HTML has no <form>.
_, httpErr := Login(context.Background(), nil, Config{
LoginURL: srv.URL + "/login",
Username: "[email protected]",
Password: "p@ss",
})
require.ErrorIs(t, httpErr, ErrNoLoginForm, "HTTP engine should fail on a JS-rendered form")
// Headless engine renders the JS, fills and submits the form, and captures
// the session.
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
session, err := LoginHeadless(ctx, Config{
LoginURL: srv.URL + "/login",
Username: "[email protected]",
Password: "p@ss",
SettleTime: 1500 * time.Millisecond,
})
require.NoError(t, err)
names := map[string]string{}
for _, c := range session.Cookies {
names[c.Name] = c.Value
}
require.Contains(t, names, "session", "headless login should capture the session cookie")
// The captured session must authenticate against a protected route.
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/dashboard", nil)
req.Header.Set("Cookie", session.CookieHeader)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusOK, resp.StatusCode)
}
// twoStepLoginPage is a username-first flow: the password field is hidden until
// the "Next" button is clicked, mimicking Google/Microsoft-style logins.
const twoStepLoginPage = `<html><head><title>Login</title></head><body>
<form id="f" action="/login" method="post">
<input type="email" name="email" id="email">
<button type="button" id="next">Next</button>
<div id="pwwrap" style="display:none">
<input type="password" name="password" id="password">
<button type="submit" id="submit">Sign in</button>
</div>
</form>
<script>
document.getElementById('next').addEventListener('click', function () {
document.getElementById('pwwrap').style.display = 'block';
});
</script></body></html>`
func TestLoginHeadless_MultiStep(t *testing.T) {
requireChrome(t)
app := newJSLoginApp()
srv := httptest.NewServer(func() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
w.Header().Set("Content-Type", "text/html")
_, _ = fmt.Fprint(w, twoStepLoginPage)
return
}
_ = r.ParseForm()
if r.PostFormValue("email") != "[email protected]" || r.PostFormValue("password") != "p@ss" {
w.Header().Set("Content-Type", "text/html")
_, _ = fmt.Fprint(w, twoStepLoginPage)
return
}
http.SetCookie(w, &http.Cookie{Name: "session", Value: "sess-dave", Path: "/"})
app.mu.Lock()
app.sessions["sess-dave"] = true
app.mu.Unlock()
http.Redirect(w, r, "/dashboard", http.StatusFound)
})
mux.HandleFunc("/dashboard", app.handleDashboard)
return mux
}())
defer srv.Close()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
session, err := LoginHeadless(ctx, Config{
LoginURL: srv.URL + "/login",
Username: "[email protected]",
Password: "p@ss",
SettleTime: 1 * time.Second,
Steps: []LoginStep{
{Action: "fill", Selector: "#email", Value: "{{username}}"},
{Action: "click", Selector: "#next"},
{Action: "waitvisible", Selector: "#password"},
{Action: "fill", Selector: "#password", Value: "{{password}}"},
{Action: "click", Selector: "#submit"},
},
})
require.NoError(t, err)
names := map[string]string{}
for _, c := range session.Cookies {
names[c.Name] = c.Value
}
require.Contains(t, names, "session", "multi-step login should capture the session cookie")
}
// spaTokenPage is a pure-SPA login: on submit JS validates the credentials
// client-side, stores a JWT in localStorage (no cookie at all) and swaps in a
// logged-in view. This is the canonical "token lives in web storage" case that
// motivates storage capture/replay.
const spaTokenPage = `<html><head><title>SPA</title></head><body>
<form id="f">
<input type="email" name="email" id="email">
<input type="password" name="password" id="password">
<button type="submit" id="submit">Sign in</button>
</form>
<script>
document.getElementById('f').addEventListener('submit', function (e) {
e.preventDefault();
var email = document.getElementById('email').value;
var pw = document.getElementById('password').value;
if (email === 'dave@example.com' && pw === 'p@ss') {
window.localStorage.setItem('auth_token', 'eyJhbGciOiJI.payload.sig');
window.sessionStorage.setItem('csrf', 'abc123');
document.body.innerHTML = '<h1>welcome dave</h1>';
} else {
document.body.innerHTML += '<p>bad creds</p>';
}
});
</script></body></html>`
func TestLoginHeadless_LocalStorageToken(t *testing.T) {
requireChrome(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = fmt.Fprint(w, spaTokenPage)
}))
defer srv.Close()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
session, err := LoginHeadless(ctx, Config{
LoginURL: srv.URL + "/login",
Username: "[email protected]",
Password: "p@ss",
TokenRegex: `(eyJ[A-Za-z0-9._-]+)`,
SettleTime: 1500 * time.Millisecond,
})
require.NoError(t, err)
require.Empty(t, session.Cookies, "SPA login sets no cookie")
require.Equal(t, "eyJhbGciOiJI.payload.sig", session.Token, "token must be extracted from localStorage")
require.Equal(t, "eyJhbGciOiJI.payload.sig", session.LocalStorage["auth_token"], "localStorage must be captured")
require.Equal(t, "abc123", session.SessionStorage["csrf"], "sessionStorage must be captured")
}
func TestLoginHeadless_WrongPasswordFails(t *testing.T) {
requireChrome(t)
app := newJSLoginApp()
srv := app.server()
defer srv.Close()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
_, err := LoginHeadless(ctx, Config{
LoginURL: srv.URL + "/login",
Username: "[email protected]",
Password: "wrong",
SettleTime: 1500 * time.Millisecond,
})
require.ErrorIs(t, err, ErrLoginFailed)
}
// disabledSubmitPage mimics a framework-validated form (e.g. Angular/Juice
// Shop): the submit button starts disabled and is only enabled once both fields
// have received input events. A naive click-immediately submitter hangs on the
// disabled button; the engine must settle (so the field input enables the
// button) and wait for the button to become interactable before clicking.
const disabledSubmitPage = `<html><head><title>Login</title></head><body>
<form id="f" action="/login" method="post">
<input type="email" name="email" id="email">
<input type="password" name="password" id="password">
<button type="submit" id="submit" disabled>Sign in</button>
</form>
<script>
var email = document.getElementById('email');
var pw = document.getElementById('password');
var btn = document.getElementById('submit');
function check() { btn.disabled = !(email.value && pw.value); }
email.addEventListener('input', check);
pw.addEventListener('input', check);
</script></body></html>`
func TestLoginHeadless_SubmitDisabledUntilValid(t *testing.T) {
requireChrome(t)
app := newJSLoginApp()
srv := httptest.NewServer(func() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
w.Header().Set("Content-Type", "text/html")
_, _ = fmt.Fprint(w, disabledSubmitPage)
return
}
_ = r.ParseForm()
if r.PostFormValue("email") != "[email protected]" || r.PostFormValue("password") != "p@ss" {
w.Header().Set("Content-Type", "text/html")
_, _ = fmt.Fprint(w, disabledSubmitPage)
return
}
http.SetCookie(w, &http.Cookie{Name: "session", Value: "sess-dave", Path: "/"})
app.mu.Lock()
app.sessions["sess-dave"] = true
app.mu.Unlock()
http.Redirect(w, r, "/dashboard", http.StatusFound)
})
mux.HandleFunc("/dashboard", app.handleDashboard)
return mux
}())
defer srv.Close()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
session, err := LoginHeadless(ctx, Config{
LoginURL: srv.URL + "/login",
Username: "[email protected]",
Password: "p@ss",
SettleTime: 1500 * time.Millisecond,
})
require.NoError(t, err, "engine must wait for the submit button to become interactable")
names := map[string]string{}
for _, c := range session.Cookies {
names[c.Name] = c.Value
}
require.Contains(t, names, "session", "login should succeed once the button is enabled")
}
// enterSubmitPage has no clickable submit control and submits only when Enter is
// pressed in the password field (common in minimal SPA forms). It exercises the
// engine's Enter-to-submit fallback when no submit button is found.
const enterSubmitPage = `<html><head><title>Login</title></head><body>
<form id="f" action="/login" method="post">
<input type="email" name="email" id="email">
<input type="password" name="password" id="password">
</form>
<script>
document.getElementById('password').addEventListener('keydown', function (e) {
if (e.key === 'Enter') { document.getElementById('f').submit(); }
});
</script></body></html>`
func TestLoginHeadless_EnterSubmitFallback(t *testing.T) {
requireChrome(t)
app := newJSLoginApp()
srv := httptest.NewServer(func() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
w.Header().Set("Content-Type", "text/html")
_, _ = fmt.Fprint(w, enterSubmitPage)
return
}
_ = r.ParseForm()
if r.PostFormValue("email") != "[email protected]" || r.PostFormValue("password") != "p@ss" {
w.Header().Set("Content-Type", "text/html")
_, _ = fmt.Fprint(w, enterSubmitPage)
return
}
http.SetCookie(w, &http.Cookie{Name: "session", Value: "sess-dave", Path: "/"})
app.mu.Lock()
app.sessions["sess-dave"] = true
app.mu.Unlock()
http.Redirect(w, r, "/dashboard", http.StatusFound)
})
mux.HandleFunc("/dashboard", app.handleDashboard)
return mux
}())
defer srv.Close()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
session, err := LoginHeadless(ctx, Config{
LoginURL: srv.URL + "/login",
Username: "[email protected]",
Password: "p@ss",
SettleTime: 1500 * time.Millisecond,
})
require.NoError(t, err, "engine must fall back to Enter-to-submit when no submit button exists")
names := map[string]string{}
for _, c := range session.Cookies {
names[c.Name] = c.Value
}
require.Contains(t, names, "session", "Enter-to-submit fallback should log in")
}
func TestDetectStorageJWT(t *testing.T) {
const jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkYXZlIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
t.Run("preferred key wins over other JWT-shaped values", func(t *testing.T) {
other := "eyJraWQiOiJvdGhlciJ9.eyJub3RpdCI6dHJ1ZX0.zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz"
got := detectStorageJWT(map[string]string{"random_blob": other, "token": jwt})
require.Equal(t, jwt, got, "the value under a preferred key should be chosen")
})
t.Run("extracts JWT embedded in JSON", func(t *testing.T) {
got := detectStorageJWT(map[string]string{"auth": `{"access_token":"` + jwt + `","exp":123}`})
require.Equal(t, jwt, got)
})
t.Run("extracts JWT wrapped in quotes", func(t *testing.T) {
got := detectStorageJWT(map[string]string{"id_token": `"` + jwt + `"`})
require.Equal(t, jwt, got)
})
t.Run("falls back to sessionStorage when localStorage has none", func(t *testing.T) {
got := detectStorageJWT(map[string]string{"theme": "dark"}, map[string]string{"jwt": jwt})
require.Equal(t, jwt, got)
})
t.Run("returns empty when no JWT present", func(t *testing.T) {
require.Empty(t, detectStorageJWT(map[string]string{"theme": "dark", "lang": "en"}))
require.Empty(t, detectStorageJWT(nil, nil))
})
t.Run("ignores non-JWT values that merely start with eyJ", func(t *testing.T) {
require.Empty(t, detectStorageJWT(map[string]string{"x": "eyJustabase64ishstring"}))
})
}
// spaAutoTokenPage stores a realistic JWT in localStorage under a conventional
// key and sets no cookie, mirroring a token-in-web-storage SPA. It is used to
// verify that the engine surfaces the token even without an explicit TokenRegex.
const spaAutoTokenPage = `<html><head><title>SPA</title></head><body>
<form id="f">
<input type="email" name="email" id="email">
<input type="password" name="password" id="password">
<button type="submit" id="submit">Sign in</button>
</form>
<script>
document.getElementById('f').addEventListener('submit', function (e) {
e.preventDefault();
var email = document.getElementById('email').value;
var pw = document.getElementById('password').value;
if (email === 'dave@example.com' && pw === 'p@ss') {
window.localStorage.setItem('token', 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkYXZlIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c');
document.body.innerHTML = '<h1>welcome dave</h1>';
} else {
document.body.innerHTML += '<p>bad creds</p>';
}
});
</script></body></html>`
func TestLoginHeadless_AutoDetectStorageJWTWithoutRegex(t *testing.T) {
requireChrome(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
_, _ = fmt.Fprint(w, spaAutoTokenPage)
}))
defer srv.Close()
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
session, err := LoginHeadless(ctx, Config{
LoginURL: srv.URL + "/login",
Username: "[email protected]",
Password: "p@ss",
SettleTime: 1500 * time.Millisecond,
})
require.NoError(t, err)
require.Empty(t, session.Cookies, "SPA login sets no cookie")
require.Equal(t,
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJkYXZlIn0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c",
session.Token,
"JWT in localStorage must be auto-detected as the session token without a TokenRegex")
}
-429
View File
@@ -1,429 +0,0 @@
package autologin
import (
"context"
"io"
"net"
"net/http"
"net/http/cookiejar"
"net/url"
"regexp"
"sort"
"strings"
"time"
"github.com/projectdiscovery/gologger"
"github.com/projectdiscovery/utils/errkit"
"golang.org/x/net/proxy"
)
// maxBodyRead caps how much of the login/landing page we read into memory for
// form detection and success checks.
const maxBodyRead = 4 << 20 // 4MB
var (
// ErrLoginFailed is returned when the login flow completed but produced no
// usable session (no cookies, no token) — typically wrong credentials.
ErrLoginFailed = errkit.New("auto-login produced no session (check credentials)")
)
// Config describes a single auto-login attempt.
type Config struct {
// LoginURL is the page that serves the login form (required).
LoginURL string
// Username / Password are the credentials to submit. Username may be empty
// for password-only forms.
Username string
Password string
// The following are optional overrides for cases where auto-detection would
// guess wrong.
// UsernameField forces the form field name that receives the username.
UsernameField string
// PasswordField forces the form field name that receives the password.
PasswordField string
// ExtraFields are additional form values submitted verbatim; they override
// any detected field of the same name (e.g. a fixed TOTP or tenant id).
ExtraFields map[string]string
// TokenRegex, when set, is applied to the final response body (and, for the
// HTTP engine, response headers) to extract a bearer/session token (first
// capture group). This enables token-based logins that return the token in
// the response body rather than a cookie. For headless logins it is also
// matched against window.localStorage.
TokenRegex string
// Timeout bounds the whole login flow. Defaults to 30s when zero.
Timeout time.Duration
// The following fields apply only to the headless engine (LoginHeadless).
// Headless drives a real browser (go-rod) instead of the HTTP engine,
// enabling JS-rendered / SPA / multi-step login pages.
Headless bool
// ShowBrowser runs the browser headful (useful for debugging).
ShowBrowser bool
// UseInstalledChrome forces using a system-installed Chrome binary.
UseInstalledChrome bool
// Proxy routes browser traffic through the given proxy URL.
Proxy string
// CDPEndpoint connects to an existing browser over the Chrome DevTools
// Protocol instead of launching one.
CDPEndpoint string
// UserAgent overrides the browser's User-Agent for the login navigation.
UserAgent string
// CustomHeaders are extra headers sent with the headless login requests
// (e.g. global -H headers from the scan).
CustomHeaders map[string]string
// SettleTime is how long to wait for the post-submit navigation / SPA to
// settle before capturing the session. Defaults to 5s.
SettleTime time.Duration
// Steps, when non-empty, drives an explicit multi-step headless login
// (e.g. username-first / SSO consent flows) instead of the single-shot
// auto-detect-and-submit path. Ignored by the HTTP engine.
Steps []LoginStep
}
// LoginStep is a single action in an explicit multi-step headless login flow.
// In Value, the placeholders {{username}} and {{password}} are substituted with
// the configured credentials so secrets stay out of the step list.
type LoginStep struct {
// Action is one of: navigate, fill, click, waitvisible, wait, press, submit.
Action string `json:"action" yaml:"action"`
// Selector is a CSS selector (or, for fill/press, an input name) the action
// targets. Unused by navigate/wait.
Selector string `json:"selector" yaml:"selector"`
// Value is the action argument: a URL (navigate), text to type (fill), a key
// name such as "enter"/"tab" (press) or a duration like "2s" (wait).
Value string `json:"value" yaml:"value"`
}
// Session is the captured result of a successful auto-login.
type Session struct {
// Cookies are the session cookies captured after login (name/value only).
Cookies []*http.Cookie
// CookieHeader is the cookies rendered as a single "k=v; k2=v2" header value.
CookieHeader string
// Token is a token extracted via Config.TokenRegex, if any.
Token string
// LocalStorage / SessionStorage hold the browser storage captured after a
// headless login (always empty for the HTTP engine). They let token-based
// SPAs surface their session even when it lives in web storage.
LocalStorage map[string]string
SessionStorage map[string]string
// FinalURL / FinalStatus describe where the login flow landed.
FinalURL string
FinalStatus int
}
// Login performs a form-based auto-login: it fetches the login page, detects the
// login form, submits the supplied credentials (carrying any hidden CSRF tokens
// and the cookie jar), follows redirects and captures the resulting session.
//
// base is an optional template client whose Transport is reused (for proxy/TLS
// settings); its cookie jar and redirect policy are intentionally NOT reused so
// the login runs with a clean, dedicated jar. Pass nil to use defaults.
func Login(ctx context.Context, base *http.Client, cfg Config) (*Session, error) {
if strings.TrimSpace(cfg.LoginURL) == "" {
return nil, errkit.New("auto-login: login-url is required")
}
loginURL, err := url.Parse(cfg.LoginURL)
if err != nil {
return nil, errkit.Wrap(err, "auto-login: invalid login-url")
}
var tokenRe *regexp.Regexp
if cfg.TokenRegex != "" {
tokenRe, err = regexp.Compile(cfg.TokenRegex)
if err != nil {
return nil, errkit.Wrap(err, "auto-login: invalid token-regex")
}
}
jar, err := cookiejar.New(nil)
if err != nil {
return nil, errkit.Wrap(err, "auto-login: failed to create cookie jar")
}
timeout := cfg.Timeout
if timeout == 0 {
timeout = 30 * time.Second
}
client := &http.Client{Jar: jar, Timeout: timeout}
switch {
case base != nil && base.Transport != nil:
// Reuse the template client's transport (proxy/TLS) when provided.
client.Transport = base.Transport
case cfg.Proxy != "":
// Otherwise honor an explicitly configured proxy so a proxied scan does
// not bypass the proxy during HTTP auto-login.
tr, perr := proxyTransport(cfg.Proxy)
if perr != nil {
return nil, perr
}
client.Transport = tr
}
// 1. Fetch the login page (this also seeds the jar with any pre-login CSRF
// cookie the server sets).
pageBody, err := doRequest(ctx, client, http.MethodGet, loginURL.String(), "", "")
if err != nil {
return nil, errkit.Wrap(err, "auto-login: failed to fetch login page")
}
// 2. Detect the login form.
form, err := DetectLoginForm(pageBody, loginURL)
if err != nil {
return nil, err
}
// 3. Build the submission values: detected hidden/default fields first, then
// the credentials, then caller overrides.
values := url.Values{}
for k, v := range form.Fields {
values.Set(k, v)
}
userField := form.UsernameField
if cfg.UsernameField != "" {
userField = cfg.UsernameField
}
passField := form.PasswordField
if cfg.PasswordField != "" {
passField = cfg.PasswordField
}
if userField != "" {
values.Set(userField, cfg.Username)
}
values.Set(passField, cfg.Password)
for k, v := range cfg.ExtraFields {
values.Set(k, v)
}
// 4. Submit the form.
var finalBody, finalURLStr string
var finalStatus int
var finalHeaders http.Header
if form.Method == http.MethodGet {
// A GET login places the password in the query string, where it can leak
// into server/proxy logs and browser history. We still honor it (some
// apps genuinely use GET), but warn so the operator is aware.
if cfg.Password != "" {
gologger.Warning().Msgf("auto-login: login form at %s uses GET; credentials will be sent in the URL query string", form.Action)
}
actionURL, perr := url.Parse(form.Action)
if perr != nil {
return nil, errkit.Wrap(perr, "auto-login: invalid form action")
}
q := actionURL.Query()
for k, vs := range values {
for _, v := range vs {
q.Set(k, v)
}
}
actionURL.RawQuery = q.Encode()
finalBody, finalURLStr, finalStatus, finalHeaders, err = doRequestFull(ctx, client, http.MethodGet, actionURL.String(), "", "")
} else {
finalBody, finalURLStr, finalStatus, finalHeaders, err = doRequestFull(ctx, client, form.Method, form.Action, "application/x-www-form-urlencoded", values.Encode())
}
if err != nil {
return nil, errkit.Wrap(err, "auto-login: failed to submit login form")
}
// 5. Capture the session from the jar across the login/action/final hosts.
session := &Session{FinalURL: finalURLStr, FinalStatus: finalStatus}
session.Cookies = collectCookies(jar, loginURL, form.Action, finalURLStr)
session.CookieHeader = renderCookieHeader(session.Cookies)
// Token extraction: the configured regex against the body first, then the
// response headers (regex + well-known auth headers). Many token-based APIs
// return the session token in a header (Authorization, X-Auth-Token, ...)
// rather than the body or a cookie.
if tokenRe != nil {
if m := tokenRe.FindStringSubmatch(finalBody); len(m) > 1 {
session.Token = m[1]
}
}
if session.Token == "" {
session.Token = detectHeaderToken(finalHeaders, tokenRe)
}
// 6. Decide success.
//
// "Captured a cookie" is NOT a sufficient signal: servers commonly set a
// pre-login CSRF cookie on the GET that survives a failed POST. The strongest
// generic failure signal is being re-prompted — i.e. the page we land on
// after submitting still presents a login form (a password field). When that
// happens and we did not extract a token, the login failed (e.g. wrong
// credentials). NOTE: this can misfire on apps that render a login form on
// every page even when authenticated; such cases can use TokenRegex or field
// overrides to disambiguate.
if session.Token == "" {
var finalURLParsed *url.URL
if u, perr := url.Parse(finalURLStr); perr == nil {
finalURLParsed = u
}
if _, derr := DetectLoginForm(finalBody, finalURLParsed); derr == nil {
return nil, errkit.Wrapf(ErrLoginFailed, "still presented a login form (final status %d at %s)", finalStatus, finalURLStr)
}
}
// A session must ultimately carry at least one cookie or an extracted token.
if len(session.Cookies) == 0 && session.Token == "" {
return nil, errkit.Wrapf(ErrLoginFailed, "final status %d at %s", finalStatus, finalURLStr)
}
return session, nil
}
// proxyTransport builds an http.Transport that routes through the given proxy.
// It supports http/https proxies (via CONNECT) and socks5/socks5h proxies.
func proxyTransport(rawProxy string) (*http.Transport, error) {
u, err := url.Parse(rawProxy)
if err != nil {
return nil, errkit.Wrap(err, "auto-login: invalid proxy url")
}
switch strings.ToLower(u.Scheme) {
case "http", "https":
return &http.Transport{Proxy: http.ProxyURL(u)}, nil
case "socks5", "socks5h":
var auth *proxy.Auth
if u.User != nil {
password, _ := u.User.Password()
auth = &proxy.Auth{User: u.User.Username(), Password: password}
}
dialer, derr := proxy.SOCKS5("tcp", u.Host, auth, proxy.Direct)
if derr != nil {
return nil, errkit.Wrap(derr, "auto-login: invalid socks5 proxy")
}
tr := &http.Transport{}
if ctxDialer, ok := dialer.(proxy.ContextDialer); ok {
tr.DialContext = ctxDialer.DialContext
} else {
tr.DialContext = func(_ context.Context, network, addr string) (net.Conn, error) {
return dialer.Dial(network, addr)
}
}
return tr, nil
default:
return nil, errkit.Newf("auto-login: unsupported proxy scheme %q", u.Scheme)
}
}
// collectCookies gathers unique cookies the jar holds for any of the given URLs.
func collectCookies(jar http.CookieJar, loginURL *url.URL, action, final string) []*http.Cookie {
urls := []*url.URL{loginURL}
if u, err := url.Parse(action); err == nil {
urls = append(urls, u)
}
if u, err := url.Parse(final); err == nil {
urls = append(urls, u)
}
seen := map[string]bool{}
var out []*http.Cookie
for _, u := range urls {
if u == nil {
continue
}
for _, c := range jar.Cookies(u) {
if seen[c.Name] {
continue
}
seen[c.Name] = true
out = append(out, c)
}
}
return out
}
// renderCookieHeader renders cookies as a single Cookie header value.
func renderCookieHeader(cookies []*http.Cookie) string {
parts := make([]string, 0, len(cookies))
for _, c := range cookies {
parts = append(parts, c.Name+"="+c.Value)
}
return strings.Join(parts, "; ")
}
// doRequest issues a request and returns only the body (used for the GET page).
func doRequest(ctx context.Context, client *http.Client, method, rawURL, contentType, body string) (string, error) {
b, _, _, _, err := doRequestFull(ctx, client, method, rawURL, contentType, body)
return b, err
}
// doRequestFull issues a request and returns the (capped) body, the final URL
// after redirects, the final status code and the final response headers.
func doRequestFull(ctx context.Context, client *http.Client, method, rawURL, contentType, body string) (string, string, int, http.Header, error) {
var reader io.Reader
if body != "" {
reader = strings.NewReader(body)
}
req, err := http.NewRequestWithContext(ctx, method, rawURL, reader)
if err != nil {
return "", "", 0, nil, err
}
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
resp, err := client.Do(req)
if err != nil {
return "", "", 0, nil, err
}
defer func() { _ = resp.Body.Close() }()
data, err := io.ReadAll(io.LimitReader(resp.Body, maxBodyRead))
if err != nil {
return "", "", 0, nil, err
}
finalURL := rawURL
if resp.Request != nil && resp.Request.URL != nil {
finalURL = resp.Request.URL.String()
}
return string(data), finalURL, resp.StatusCode, resp.Header, nil
}
// tokenHeaderNames are response headers commonly used to deliver a session /
// bearer token, checked in priority order.
var tokenHeaderNames = []string{
"Authorization",
"X-Auth-Token",
"X-Access-Token",
"Access-Token",
"X-Subject-Token",
"X-Session-Token",
"X-Api-Token",
}
// detectHeaderToken extracts a session/bearer token from response headers. A
// configured token-regex is tried first (so a custom header can be targeted),
// then the well-known token headers in priority order. A leading "Bearer "
// scheme is stripped from the value.
func detectHeaderToken(h http.Header, tokenRe *regexp.Regexp) string {
if len(h) == 0 {
return ""
}
if tokenRe != nil {
names := make([]string, 0, len(h))
for name := range h {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
for _, v := range h[name] {
if m := tokenRe.FindStringSubmatch(v); len(m) > 1 {
return m[1]
}
}
}
}
for _, name := range tokenHeaderNames {
v := strings.TrimSpace(h.Get(name))
if v == "" {
continue
}
// Strip a leading auth scheme (e.g. "Bearer <token>").
if i := strings.IndexByte(v, ' '); i > 0 && strings.EqualFold(v[:i], "bearer") {
v = strings.TrimSpace(v[i+1:])
}
if v != "" {
return v
}
}
return ""
}
-373
View File
@@ -1,373 +0,0 @@
package autologin
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"regexp"
"sync"
"sync/atomic"
"testing"
"github.com/stretchr/testify/require"
)
// loginApp is a hermetic web app with a realistic, CSRF-protected, cookie-based
// login flow used to exercise the auto-login engine end to end:
// - GET /login sets a per-visitor csrf cookie and renders a form echoing it in
// a hidden input.
// - POST /login validates the csrf (hidden field must equal the cookie),
// checks the credentials, and on success sets a `session` cookie and 302s to
// /dashboard.
// - GET /dashboard returns 200 only when a valid session cookie is presented.
type loginApp struct {
mu sync.Mutex
sessions map[string]bool // valid session tokens
user string
pass string
}
func newLoginApp(user, pass string) *loginApp {
return &loginApp{sessions: map[string]bool{}, user: user, pass: pass}
}
func (a *loginApp) server() *httptest.Server {
mux := http.NewServeMux()
mux.HandleFunc("/login", a.handleLogin)
mux.HandleFunc("/dashboard", a.handleDashboard)
return httptest.NewServer(mux)
}
func (a *loginApp) handleLogin(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
// Seed a CSRF cookie and echo it into the form.
csrf := "csrf-" + fmt.Sprint(len(a.sessions)) + "-tok"
http.SetCookie(w, &http.Cookie{Name: "csrftoken", Value: csrf, Path: "/"})
w.Header().Set("Content-Type", "text/html")
_, _ = fmt.Fprintf(w, `<html><body>
<form action="/login" method="post">
<input type="hidden" name="csrf" value="%s">
<input type="text" name="email" autocomplete="username">
<input type="password" name="password">
<input type="submit" name="commit" value="Sign in">
</form>
</body></html>`, csrf)
return
}
// POST: validate CSRF against the cookie, then credentials.
_ = r.ParseForm()
csrfCookie, err := r.Cookie("csrftoken")
if err != nil || r.PostFormValue("csrf") != csrfCookie.Value {
http.Error(w, "csrf mismatch", http.StatusForbidden)
return
}
if r.PostFormValue("email") != a.user || r.PostFormValue("password") != a.pass {
// Re-render the login form on failure (no session set).
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
_, _ = fmt.Fprint(w, `<html><body><p>Invalid credentials</p>
<form action="/login" method="post">
<input type="text" name="email">
<input type="password" name="password">
</form></body></html>`)
return
}
token := "sess-" + r.PostFormValue("email")
a.mu.Lock()
a.sessions[token] = true
a.mu.Unlock()
http.SetCookie(w, &http.Cookie{Name: "session", Value: token, Path: "/"})
http.Redirect(w, r, "/dashboard", http.StatusFound)
}
func (a *loginApp) handleDashboard(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie("session")
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
a.mu.Lock()
ok := a.sessions[c.Value]
a.mu.Unlock()
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
_, _ = fmt.Fprint(w, "welcome to your dashboard")
}
func TestLogin_CSRFFormAndSessionCookie(t *testing.T) {
app := newLoginApp("[email protected]", "s3cr3t")
srv := app.server()
defer srv.Close()
session, err := Login(context.Background(), nil, Config{
LoginURL: srv.URL + "/login",
Username: "[email protected]",
Password: "s3cr3t",
})
require.NoError(t, err)
require.NotEmpty(t, session.Cookies, "should capture session cookie")
// The captured session must actually authenticate against a protected route.
names := map[string]string{}
for _, c := range session.Cookies {
names[c.Name] = c.Value
}
require.Contains(t, names, "session", "session cookie must be captured")
req, _ := http.NewRequest(http.MethodGet, srv.URL+"/dashboard", nil)
req.Header.Set("Cookie", session.CookieHeader)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusOK, resp.StatusCode, "captured session should grant access to /dashboard")
}
func TestLogin_WrongPasswordFails(t *testing.T) {
app := newLoginApp("[email protected]", "s3cr3t")
srv := app.server()
defer srv.Close()
_, err := Login(context.Background(), nil, Config{
LoginURL: srv.URL + "/login",
Username: "[email protected]",
Password: "wrong-password",
})
require.ErrorIs(t, err, ErrLoginFailed)
}
func TestLogin_FieldOverride(t *testing.T) {
// Force the username field name via override; detection would pick "email"
// anyway, but this verifies the override path submits correctly.
app := newLoginApp("[email protected]", "hunter2")
srv := app.server()
defer srv.Close()
session, err := Login(context.Background(), nil, Config{
LoginURL: srv.URL + "/login",
Username: "[email protected]",
Password: "hunter2",
UsernameField: "email",
})
require.NoError(t, err)
require.NotEmpty(t, session.Cookies)
}
// tokenApp returns a JSON token in the login response body instead of setting a
// cookie, to exercise TokenRegex-based capture.
func tokenApp() *httptest.Server {
mux := http.NewServeMux()
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
w.Header().Set("Content-Type", "text/html")
_, _ = fmt.Fprint(w, `<form action="/api/login" method="post">
<input type="text" name="user">
<input type="password" name="password">
</form>`)
return
}
})
mux.HandleFunc("/api/login", func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
if r.PostFormValue("user") == "carol" && r.PostFormValue("password") == "pw" {
w.Header().Set("Content-Type", "application/json")
_, _ = fmt.Fprint(w, `{"access_token":"eyJhbGciOiJI.payload.sig","expires_in":3600}`)
return
}
http.Error(w, "bad creds", http.StatusUnauthorized)
})
return httptest.NewServer(mux)
}
func TestLogin_TokenExtraction(t *testing.T) {
srv := tokenApp()
defer srv.Close()
session, err := Login(context.Background(), nil, Config{
LoginURL: srv.URL + "/login",
Username: "carol",
Password: "pw",
TokenRegex: `"access_token":"([^"]+)"`,
})
require.NoError(t, err)
require.Equal(t, "eyJhbGciOiJI.payload.sig", session.Token)
}
// getFormApp serves a GET-method login form (credentials travel in the query
// string). It exercises the GET submission branch of Login.
func getFormApp(user, pass string) *httptest.Server {
mux := http.NewServeMux()
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
// A submit is any GET carrying the password param.
if q.Get("password") != "" {
if q.Get("user") == user && q.Get("password") == pass && q.Get("realm") == "corp" {
http.SetCookie(w, &http.Cookie{Name: "session", Value: "ok", Path: "/"})
w.Header().Set("Content-Type", "text/html")
_, _ = fmt.Fprint(w, `<html><body>welcome, no form here</body></html>`)
return
}
w.Header().Set("Content-Type", "text/html")
_, _ = fmt.Fprint(w, `<html><body><form method="get" action="/login">
<input type="text" name="user"><input type="password" name="password"></form></body></html>`)
return
}
w.Header().Set("Content-Type", "text/html")
_, _ = fmt.Fprint(w, `<html><body><form method="get" action="/login">
<input type="hidden" name="realm" value="corp">
<input type="text" name="user" autocomplete="username">
<input type="password" name="password">
</form></body></html>`)
})
return httptest.NewServer(mux)
}
func TestLogin_GetMethodForm(t *testing.T) {
srv := getFormApp("dave", "pw123")
defer srv.Close()
session, err := Login(context.Background(), nil, Config{
LoginURL: srv.URL + "/login",
Username: "dave",
Password: "pw123",
})
require.NoError(t, err)
require.NotEmpty(t, session.Cookies, "GET-form login should still capture the session cookie")
// The hidden 'realm' default must have been carried into the query string.
require.Contains(t, session.FinalURL, "realm=corp")
}
// extraFieldApp requires an additional non-form field (e.g. a tenant id) that is
// not present in the markup, so it must be supplied via Config.ExtraFields.
func extraFieldApp(user, pass string) *httptest.Server {
mux := http.NewServeMux()
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
w.Header().Set("Content-Type", "text/html")
_, _ = fmt.Fprint(w, `<form action="/login" method="post">
<input type="text" name="user"><input type="password" name="password"></form>`)
return
}
_ = r.ParseForm()
if r.PostFormValue("user") == user && r.PostFormValue("password") == pass && r.PostFormValue("tenant") == "acme" {
http.SetCookie(w, &http.Cookie{Name: "session", Value: "ok", Path: "/"})
_, _ = fmt.Fprint(w, "<html><body>signed in</body></html>")
return
}
w.Header().Set("Content-Type", "text/html")
_, _ = fmt.Fprint(w, `<form action="/login" method="post"><input type="password" name="password"></form>`)
})
return httptest.NewServer(mux)
}
func TestLogin_ExtraFields(t *testing.T) {
srv := extraFieldApp("erin", "pw")
defer srv.Close()
// Without the extra field the login must fail (re-prompted).
_, err := Login(context.Background(), nil, Config{
LoginURL: srv.URL + "/login", Username: "erin", Password: "pw",
})
require.ErrorIs(t, err, ErrLoginFailed)
// Supplying the tenant via ExtraFields must succeed.
session, err := Login(context.Background(), nil, Config{
LoginURL: srv.URL + "/login",
Username: "erin",
Password: "pw",
ExtraFields: map[string]string{"tenant": "acme"},
})
require.NoError(t, err)
require.NotEmpty(t, session.Cookies)
}
func TestProxyTransport(t *testing.T) {
tr, err := proxyTransport("http://127.0.0.1:8080")
require.NoError(t, err)
require.NotNil(t, tr.Proxy, "http proxy must set Transport.Proxy")
tr, err = proxyTransport("socks5://127.0.0.1:1080")
require.NoError(t, err)
require.NotNil(t, tr.DialContext, "socks5 proxy must set Transport.DialContext")
_, err = proxyTransport("ftp://127.0.0.1:21")
require.Error(t, err, "unsupported proxy scheme must error")
}
// TestLogin_HTTPProxy proves the HTTP engine actually routes through cfg.Proxy:
// the login target host is unresolvable, so the flow can only succeed if every
// request is sent to the proxy (which serves the login app by path).
func TestLogin_HTTPProxy(t *testing.T) {
var proxyHits int32
mux := http.NewServeMux()
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&proxyHits, 1)
if r.Method == http.MethodGet {
w.Header().Set("Content-Type", "text/html")
_, _ = fmt.Fprint(w, `<form action="/login" method="post">
<input type="text" name="user"><input type="password" name="password"></form>`)
return
}
_ = r.ParseForm()
if r.PostFormValue("user") == "frank" && r.PostFormValue("password") == "pw" {
http.SetCookie(w, &http.Cookie{Name: "session", Value: "ok", Path: "/"})
_, _ = fmt.Fprint(w, "<html><body>signed in</body></html>")
return
}
_, _ = fmt.Fprint(w, `<form action="/login" method="post"><input type="password" name="password"></form>`)
})
proxySrv := httptest.NewServer(mux)
defer proxySrv.Close()
session, err := Login(context.Background(), nil, Config{
// Unresolvable host: only reachable if the proxy is honored.
LoginURL: "http://login.invalid.test/login",
Username: "frank",
Password: "pw",
Proxy: proxySrv.URL,
})
require.NoError(t, err, "login should succeed via the proxy")
require.NotEmpty(t, session.Cookies)
require.GreaterOrEqual(t, atomic.LoadInt32(&proxyHits), int32(2), "both GET and POST must traverse the proxy")
}
func TestLogin_NoFormIsError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = fmt.Fprint(w, "<html><body>no form here</body></html>")
}))
defer srv.Close()
_, err := Login(context.Background(), nil, Config{
LoginURL: srv.URL + "/login",
Username: "x",
Password: "y",
})
require.ErrorIs(t, err, ErrNoLoginForm)
}
func TestDetectHeaderToken(t *testing.T) {
t.Run("strips bearer scheme from Authorization", func(t *testing.T) {
h := http.Header{"Authorization": {"Bearer abc.def.ghi"}}
require.Equal(t, "abc.def.ghi", detectHeaderToken(h, nil))
})
t.Run("reads well-known token header", func(t *testing.T) {
h := http.Header{"X-Auth-Token": {"tok-123"}}
require.Equal(t, "tok-123", detectHeaderToken(h, nil))
})
t.Run("priority order prefers Authorization over X-Auth-Token", func(t *testing.T) {
h := http.Header{"Authorization": {"Bearer first"}, "X-Auth-Token": {"second"}}
require.Equal(t, "first", detectHeaderToken(h, nil))
})
t.Run("token-regex targets a custom header", func(t *testing.T) {
h := http.Header{"X-Custom": {"session=zzz999;"}}
re := regexp.MustCompile(`session=([^;]+)`)
require.Equal(t, "zzz999", detectHeaderToken(h, re))
})
t.Run("no token headers yields empty", func(t *testing.T) {
require.Empty(t, detectHeaderToken(http.Header{"Content-Type": {"text/html"}}, nil))
require.Empty(t, detectHeaderToken(nil, nil))
})
}
@@ -1,401 +0,0 @@
package autologin
import (
"context"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/projectdiscovery/nuclei/v3/internal/fuzzplayground"
"github.com/stretchr/testify/require"
)
// playgroundServer starts the in-repo auth playground and returns its base URL.
func playgroundServer(t *testing.T) string {
t.Helper()
srv := httptest.NewServer(fuzzplayground.GetPlaygroundServer())
t.Cleanup(srv.Close)
return srv.URL
}
// assertSessionAuthenticates verifies a captured session actually authenticates
// against the playground's protected /auth/whoami endpoint, trying both the
// cookie and bearer-token representations.
func assertSessionAuthenticates(t *testing.T, base string, s *Session) {
t.Helper()
apply := func(req *http.Request) {
if s.CookieHeader != "" {
req.Header.Set("Cookie", s.CookieHeader)
}
if s.Token != "" {
req.Header.Set("Authorization", "Bearer "+s.Token)
}
}
req, _ := http.NewRequest(http.MethodGet, base+"/auth/whoami", nil)
apply(req)
resp, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
require.Equal(t, http.StatusOK, resp.StatusCode, "whoami should accept the captured session; body=%s", body)
require.Contains(t, string(body), fuzzplayground.AuthUsername, "whoami should identify the logged-in user")
}
func cookieNames(s *Session) map[string]string {
out := map[string]string{}
for _, c := range s.Cookies {
out[c.Name] = c.Value
}
return out
}
// --- HTTP engine styles ----------------------------------------------------
func TestPlayground_HTTPFormLogin(t *testing.T) {
base := playgroundServer(t)
session, err := Login(context.Background(), nil, Config{
LoginURL: base + "/auth/form-login",
Username: fuzzplayground.AuthUsername,
Password: fuzzplayground.AuthPassword,
})
require.NoError(t, err)
require.Contains(t, cookieNames(session), "PSESSION", "form login should set a session cookie")
assertSessionAuthenticates(t, base, session)
}
func TestPlayground_HTTPCSRFLogin(t *testing.T) {
base := playgroundServer(t)
session, err := Login(context.Background(), nil, Config{
LoginURL: base + "/auth/csrf-login",
Username: fuzzplayground.AuthUsername,
Password: fuzzplayground.AuthPassword,
})
require.NoError(t, err, "HTTP engine must capture and replay the hidden CSRF token")
require.Contains(t, cookieNames(session), "PSESSION")
assertSessionAuthenticates(t, base, session)
}
func TestPlayground_HTTPWrongPasswordFails(t *testing.T) {
base := playgroundServer(t)
_, err := Login(context.Background(), nil, Config{
LoginURL: base + "/auth/form-login",
Username: fuzzplayground.AuthUsername,
Password: "wrong",
})
require.ErrorIs(t, err, ErrLoginFailed)
}
// TestPlayground_HTTPHeaderTokenLogin proves the HTTP engine extracts a session
// token delivered in a response header (no cookie, no body token).
func TestPlayground_HTTPHeaderTokenLogin(t *testing.T) {
base := playgroundServer(t)
session, err := Login(context.Background(), nil, Config{
LoginURL: base + "/auth/header-token-login",
Username: fuzzplayground.AuthUsername,
Password: fuzzplayground.AuthPassword,
})
require.NoError(t, err, "the engine must extract the token from the response header")
require.Empty(t, session.Cookies, "header-token login sets no cookie")
require.NotEmpty(t, session.Token, "token must be read from the X-Auth-Token header")
assertSessionAuthenticates(t, base, session)
}
// --- Headless styles -------------------------------------------------------
func TestPlayground_HeadlessSPALogin(t *testing.T) {
requireChrome(t)
base := playgroundServer(t)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
// The static HTTP engine cannot log in here (the form is built by JS).
_, httpErr := Login(context.Background(), nil, Config{
LoginURL: base + "/auth/spa-login",
Username: fuzzplayground.AuthUsername,
Password: fuzzplayground.AuthPassword,
})
require.ErrorIs(t, httpErr, ErrNoLoginForm)
session, err := LoginHeadless(ctx, Config{
LoginURL: base + "/auth/spa-login",
Username: fuzzplayground.AuthUsername,
Password: fuzzplayground.AuthPassword,
SettleTime: 1500 * time.Millisecond,
})
require.NoError(t, err)
require.Contains(t, cookieNames(session), "PSESSION")
assertSessionAuthenticates(t, base, session)
}
func TestPlayground_HeadlessMultiStep(t *testing.T) {
requireChrome(t)
base := playgroundServer(t)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
session, err := LoginHeadless(ctx, Config{
LoginURL: base + "/auth/multistep-login",
Username: fuzzplayground.AuthUsername,
Password: fuzzplayground.AuthPassword,
SettleTime: 1 * time.Second,
Steps: []LoginStep{
{Action: "fill", Selector: "#username", Value: "{{username}}"},
{Action: "click", Selector: "#next"},
{Action: "waitvisible", Selector: "#password"},
{Action: "fill", Selector: "#password", Value: "{{password}}"},
{Action: "click", Selector: "#submit"},
},
})
require.NoError(t, err)
require.Contains(t, cookieNames(session), "PSESSION")
assertSessionAuthenticates(t, base, session)
}
// TestPlayground_HeadlessStrictSubmit is the regression for the disabled-until-
// valid submit button: the engine must settle (so JS enables the button) and/or
// fall back to Enter rather than hang on the disabled control.
func TestPlayground_HeadlessStrictSubmit(t *testing.T) {
requireChrome(t)
base := playgroundServer(t)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
session, err := LoginHeadless(ctx, Config{
LoginURL: base + "/auth/strict-login",
Username: fuzzplayground.AuthUsername,
Password: fuzzplayground.AuthPassword,
SettleTime: 1500 * time.Millisecond,
})
require.NoError(t, err)
require.Contains(t, cookieNames(session), "PSESSION")
assertSessionAuthenticates(t, base, session)
}
func TestPlayground_HeadlessSPAToken(t *testing.T) {
requireChrome(t)
base := playgroundServer(t)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
session, err := LoginHeadless(ctx, Config{
LoginURL: base + "/auth/spa-token-login",
Username: fuzzplayground.AuthUsername,
Password: fuzzplayground.AuthPassword,
TokenRegex: `(eyJ[A-Za-z0-9._-]+\.[A-Za-z0-9._-]+\.[A-Za-z0-9._-]+)`,
SettleTime: 1500 * time.Millisecond,
})
require.NoError(t, err)
require.Empty(t, session.Cookies, "SPA-token login sets no cookie")
require.NotEmpty(t, session.Token, "token must be extracted from localStorage")
require.NotEmpty(t, session.LocalStorage["access_token"], "localStorage must be captured")
// The captured bearer token must authenticate the protected API.
assertSessionAuthenticates(t, base, session)
}
// TestPlayground_HeadlessSPATokenAutoDetect proves the engine surfaces a JWT
// kept in web storage as the session token even when no token-regex is given
// (the JWT is stored under the conventional "access_token" key).
func TestPlayground_HeadlessSPATokenAutoDetect(t *testing.T) {
requireChrome(t)
base := playgroundServer(t)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
session, err := LoginHeadless(ctx, Config{
LoginURL: base + "/auth/spa-token-login",
Username: fuzzplayground.AuthUsername,
Password: fuzzplayground.AuthPassword,
SettleTime: 1500 * time.Millisecond,
// Intentionally no TokenRegex: rely on web-storage JWT auto-detection.
})
require.NoError(t, err)
require.NotEmpty(t, session.Token, "JWT in localStorage must be auto-detected without a token-regex")
require.NotEmpty(t, session.LocalStorage["access_token"], "localStorage must be captured")
assertSessionAuthenticates(t, base, session)
}
// TestPlayground_HTTPMultiFormPicksLogin proves the form detector selects the
// credential form on a page that also contains a decoy search form (no
// password), rather than blindly taking the first <form>.
func TestPlayground_HTTPMultiFormPicksLogin(t *testing.T) {
base := playgroundServer(t)
session, err := Login(context.Background(), nil, Config{
LoginURL: base + "/auth/multiform-login",
Username: fuzzplayground.AuthUsername,
Password: fuzzplayground.AuthPassword,
})
require.NoError(t, err, "detector must pick the login form over the decoy search form")
require.Contains(t, cookieNames(session), "PSESSION")
assertSessionAuthenticates(t, base, session)
}
// TestPlayground_HeadlessJSCookieLogin proves cookies set client-side via
// document.cookie (no Set-Cookie header) are captured from the browser jar.
func TestPlayground_HeadlessJSCookieLogin(t *testing.T) {
requireChrome(t)
base := playgroundServer(t)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
session, err := LoginHeadless(ctx, Config{
LoginURL: base + "/auth/jscookie-login",
Username: fuzzplayground.AuthUsername,
Password: fuzzplayground.AuthPassword,
SettleTime: 1500 * time.Millisecond,
})
require.NoError(t, err)
require.Contains(t, cookieNames(session), "PSESSION", "JS-set cookie must be captured from the browser")
assertSessionAuthenticates(t, base, session)
}
// TestPlayground_HeadlessDelayedFormLogin proves the engine waits for a login
// form that is injected asynchronously instead of failing immediately.
func TestPlayground_HeadlessDelayedFormLogin(t *testing.T) {
requireChrome(t)
base := playgroundServer(t)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
session, err := LoginHeadless(ctx, Config{
LoginURL: base + "/auth/delayed-login",
Username: fuzzplayground.AuthUsername,
Password: fuzzplayground.AuthPassword,
SettleTime: 2 * time.Second,
})
require.NoError(t, err, "engine must wait for the asynchronously-rendered form")
require.Contains(t, cookieNames(session), "PSESSION")
assertSessionAuthenticates(t, base, session)
}
// TestPlayground_HeadlessHeaderTokenLogin proves the headless engine recovers a
// token that is only ever delivered in an XHR response header (never stored in
// a cookie or web storage) via passive response-header interception.
func TestPlayground_HeadlessHeaderTokenLogin(t *testing.T) {
requireChrome(t)
base := playgroundServer(t)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
session, err := LoginHeadless(ctx, Config{
LoginURL: base + "/auth/spa-header-token-login",
Username: fuzzplayground.AuthUsername,
Password: fuzzplayground.AuthPassword,
SettleTime: 1500 * time.Millisecond,
})
require.NoError(t, err)
require.Empty(t, session.Cookies, "header-token SPA sets no cookie")
require.Empty(t, session.LocalStorage["token"], "the SPA does not store the token")
require.NotEmpty(t, session.Token, "token must be captured from the XHR response header")
assertSessionAuthenticates(t, base, session)
}
func TestPlayground_HeadlessOAuthRedirect(t *testing.T) {
requireChrome(t)
base := playgroundServer(t)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
// Pure redirect SSO: visiting authorize logs the user in; there is no form,
// so a single settle step lets the redirect chain complete before capture.
session, err := LoginHeadless(ctx, Config{
LoginURL: base + "/auth/oauth/authorize",
SettleTime: 1 * time.Second,
Steps: []LoginStep{{Action: "wait", Value: "1s"}},
})
require.NoError(t, err)
require.Contains(t, cookieNames(session), "PSESSION")
assertSessionAuthenticates(t, base, session)
}
// TestPlayground_RecordingReplay imports a Chrome Recorder export (with the base
// URL templated in), verifies the recorded credential literals are parameterized
// out, and replays the compiled steps headlessly to obtain a real session.
func TestPlayground_RecordingReplay(t *testing.T) {
requireChrome(t)
base := playgroundServer(t)
raw, err := os.ReadFile(filepath.Join("testdata", "recordings", "spa_login.json"))
require.NoError(t, err)
rec := strings.ReplaceAll(string(raw), "__BASE__", base)
recFile := filepath.Join(t.TempDir(), "spa_login.json")
require.NoError(t, os.WriteFile(recFile, []byte(rec), 0o600))
steps, err := StepsFromRecordingFile(recFile, fuzzplayground.AuthUsername, fuzzplayground.AuthPassword)
require.NoError(t, err)
// Credentials must never survive in the compiled steps.
for _, s := range steps {
require.NotContains(t, s.Value, fuzzplayground.AuthPassword, "password literal leaked into steps")
require.NotContains(t, s.Value, fuzzplayground.AuthUsername, "username literal leaked into steps")
}
require.Contains(t, stepValues(steps), "{{username}}")
require.Contains(t, stepValues(steps), "{{password}}")
// An xpath-only selector must compile to the engine's xpath= form.
require.Contains(t, stepSelectors(steps), "xpath=//input[@id='email']")
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
session, err := LoginHeadless(ctx, Config{
LoginURL: FirstNavigateURL(steps),
Username: fuzzplayground.AuthUsername,
Password: fuzzplayground.AuthPassword,
Steps: steps,
SettleTime: 1500 * time.Millisecond,
})
require.NoError(t, err)
require.Contains(t, cookieNames(session), "PSESSION")
assertSessionAuthenticates(t, base, session)
}
func stepValues(steps []LoginStep) []string {
var out []string
for _, s := range steps {
out = append(out, s.Value)
}
return out
}
func stepSelectors(steps []LoginStep) []string {
var out []string
for _, s := range steps {
out = append(out, s.Selector)
}
return out
}
// --- Authenticated fuzzing target -----------------------------------------
// TestPlayground_AuthenticatedEndpointBehindLogin proves the authenticated,
// fuzzable endpoint is only reachable with a captured session and reflects input
// (a genuine target behind the login wall for authenticated scanning).
func TestPlayground_AuthenticatedEndpointBehindLogin(t *testing.T) {
base := playgroundServer(t)
session, err := Login(context.Background(), nil, Config{
LoginURL: base + "/auth/form-login",
Username: fuzzplayground.AuthUsername,
Password: fuzzplayground.AuthPassword,
})
require.NoError(t, err)
// Unauthenticated: blocked.
resp, err := http.Get(base + "/auth/api/search?q=hello")
require.NoError(t, err)
_ = resp.Body.Close()
require.Equal(t, http.StatusUnauthorized, resp.StatusCode, "search must require auth")
// Authenticated: reachable and reflects the payload unsanitized.
req, _ := http.NewRequest(http.MethodGet, base+"/auth/api/search?q="+payloadXSS, nil)
req.Header.Set("Cookie", session.CookieHeader)
resp2, err := http.DefaultClient.Do(req)
require.NoError(t, err)
defer func() { _ = resp2.Body.Close() }()
body, _ := io.ReadAll(resp2.Body)
require.Equal(t, http.StatusOK, resp2.StatusCode)
require.True(t, strings.Contains(string(body), payloadXSS), "authenticated endpoint should reflect the payload")
}
const payloadXSS = "<script>alert(1)</script>"
-146
View File
@@ -1,146 +0,0 @@
package autologin
import (
"context"
"encoding/json"
"os"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// Real-application harness. These tests are skipped unless NUCLEI_REALAPP_LOGIN_URL
// is set, so normal CI is unaffected. They drive the headless engine against a
// live app to flush out engine bugs that synthetic httptest servers miss
// (heavy SPA bootstrap, XHR logins, hash routes, token-in-web-storage, etc.).
//
// Example (OWASP Juice Shop):
//
// docker run -d -p 3000:3000 bkimminich/juice-shop
// NUCLEI_REALAPP_LOGIN_URL='http://localhost:3000/#/login' \
// NUCLEI_REALAPP_USERNAME='[email protected]' \
// NUCLEI_REALAPP_PASSWORD='admin123' \
// NUCLEI_REALAPP_TOKEN_REGEX='(eyJ[A-Za-z0-9._-]+\.[A-Za-z0-9._-]+\.[A-Za-z0-9._-]+)' \
// NUCLEI_REALAPP_EXPECT_STORAGE_KEY='token' \
// go test ./pkg/authprovider/autologin/ -run TestRealApp_HeadlessLogin -v
func realAppConfig(t *testing.T) (Config, bool) {
t.Helper()
loginURL := os.Getenv("NUCLEI_REALAPP_LOGIN_URL")
if loginURL == "" {
t.Skip("set NUCLEI_REALAPP_LOGIN_URL to run the real-app login harness")
return Config{}, false
}
cfg := Config{
LoginURL: loginURL,
Username: os.Getenv("NUCLEI_REALAPP_USERNAME"),
Password: os.Getenv("NUCLEI_REALAPP_PASSWORD"),
UsernameField: os.Getenv("NUCLEI_REALAPP_USERNAME_FIELD"),
PasswordField: os.Getenv("NUCLEI_REALAPP_PASSWORD_FIELD"),
TokenRegex: os.Getenv("NUCLEI_REALAPP_TOKEN_REGEX"),
Proxy: os.Getenv("NUCLEI_REALAPP_PROXY"),
SettleTime: 8 * time.Second,
Timeout: 90 * time.Second,
}
if steps := os.Getenv("NUCLEI_REALAPP_STEPS"); steps != "" {
require.NoError(t, json.Unmarshal([]byte(steps), &cfg.Steps), "NUCLEI_REALAPP_STEPS must be a JSON array of LoginStep")
}
return cfg, true
}
func logSession(t *testing.T, s *Session) {
t.Helper()
t.Logf("final url: %s", s.FinalURL)
t.Logf("cookies (%d):", len(s.Cookies))
for _, c := range s.Cookies {
v := c.Value
if len(v) > 24 {
v = v[:24] + "...(truncated)"
}
t.Logf(" %s = %s", c.Name, v)
}
if s.Token != "" {
tok := s.Token
if len(tok) > 32 {
tok = tok[:32] + "...(truncated)"
}
t.Logf("token: %s", tok)
}
t.Logf("localStorage keys: %d, sessionStorage keys: %d", len(s.LocalStorage), len(s.SessionStorage))
for k := range s.LocalStorage {
t.Logf(" local[%s]", k)
}
for k := range s.SessionStorage {
t.Logf(" session[%s]", k)
}
}
func TestRealApp_HeadlessLogin(t *testing.T) {
requireChrome(t)
cfg, ok := realAppConfig(t)
if !ok {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
session, err := LoginHeadless(ctx, cfg)
require.NoError(t, err, "headless login against real app failed")
logSession(t, session)
require.True(t, len(session.Cookies) > 0 || session.Token != "" || len(session.LocalStorage) > 0,
"expected to capture a session (cookie, token or web storage)")
if key := os.Getenv("NUCLEI_REALAPP_EXPECT_STORAGE_KEY"); key != "" {
_, inLocal := session.LocalStorage[key]
_, inSession := session.SessionStorage[key]
require.True(t, inLocal || inSession, "expected web storage key %q to be captured", key)
}
if name := os.Getenv("NUCLEI_REALAPP_EXPECT_COOKIE"); name != "" {
found := false
for _, c := range session.Cookies {
if c.Name == name {
found = true
break
}
}
require.True(t, found, "expected cookie %q to be captured", name)
}
}
// TestRealApp_RecordingReplay drives a recorder-JSON login flow (compiled to
// steps, with credential parameterization) against the live app, exercising the
// full record->replay path end to end. Set NUCLEI_REALAPP_RECORDING to a Chrome
// DevTools Recorder export to run it.
func TestRealApp_RecordingReplay(t *testing.T) {
requireChrome(t)
cfg, ok := realAppConfig(t)
if !ok {
return
}
recording := os.Getenv("NUCLEI_REALAPP_RECORDING")
if recording == "" {
t.Skip("set NUCLEI_REALAPP_RECORDING to a recorder json to run the replay harness")
}
steps, err := StepsFromRecordingFile(recording, cfg.Username, cfg.Password)
require.NoError(t, err, "failed to compile recording")
t.Logf("compiled %d steps from recording", len(steps))
for _, s := range steps {
t.Logf(" %s selector=%q value=%q", s.Action, s.Selector, s.Value)
}
cfg.Steps = steps
if cfg.LoginURL == "" {
cfg.LoginURL = FirstNavigateURL(steps)
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
session, err := LoginHeadless(ctx, cfg)
require.NoError(t, err, "recorded login replay against real app failed")
logSession(t, session)
require.True(t, len(session.Cookies) > 0 || session.Token != "" || len(session.LocalStorage) > 0,
"expected recorded login to capture a session")
}
-222
View File
@@ -1,222 +0,0 @@
package autologin
import (
"encoding/json"
"fmt"
"os"
"strings"
"github.com/projectdiscovery/utils/errkit"
)
// recordingFile is the Chrome DevTools Recorder / @puppeteer/replay export
// schema (the two are identical). A user records their real login in Chrome
// (DevTools -> Recorder) and exports it as JSON; we compile it down to the same
// LoginStep list the headless engine already replays, so complex SSO/MFA logins
// can be authored by recording instead of hand-writing steps or templates.
type recordingFile struct {
Title string `json:"title"`
Steps []recordingStep `json:"steps"`
}
// recordingStep is a single recorded user action. Selectors is a list of
// alternative selector strategies for the target element, each itself a list
// (Chrome groups shadow/frame piercing selectors); we use the first entry of
// each group as a candidate.
type recordingStep struct {
Type string `json:"type"`
URL string `json:"url"`
Selectors [][]string `json:"selectors"`
Value string `json:"value"`
Key string `json:"key"`
Target string `json:"target"`
Expression string `json:"expression"`
}
// StepsFromRecordingFile reads and converts a recorder export file into
// LoginSteps. username/password are used to parameterize the recording so the
// captured credential literals are replaced with {{username}}/{{password}}
// placeholders (kept out of the committed flow file).
func StepsFromRecordingFile(path, username, password string) ([]LoginStep, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, errkit.Wrap(err, "auto-login: failed to read recording file")
}
return StepsFromRecording(data, username, password)
}
// StepsFromRecording converts a recorder export (JSON) into LoginSteps.
func StepsFromRecording(data []byte, username, password string) ([]LoginStep, error) {
var rec recordingFile
if err := json.Unmarshal(data, &rec); err != nil {
return nil, errkit.Wrap(err, "auto-login: invalid recording json")
}
if len(rec.Steps) == 0 {
return nil, errkit.New("auto-login: recording contains no steps")
}
var steps []LoginStep
for _, rs := range rec.Steps {
switch strings.ToLower(strings.TrimSpace(rs.Type)) {
case "navigate":
if rs.URL != "" {
steps = append(steps, LoginStep{Action: "navigate", Value: rs.URL})
}
case "click", "doubleclick":
sel := pickSelector(rs.Selectors)
if sel == "" {
continue
}
steps = append(steps, LoginStep{Action: "click", Selector: sel})
case "change":
sel := pickSelector(rs.Selectors)
if sel == "" {
continue
}
steps = append(steps, LoginStep{
Action: "fill",
Selector: sel,
Value: parameterizeValue(rs.Value, sel, username, password),
})
case "keydown":
// Only emit actionable keys; character keys are captured by `change`,
// and the paired keyUp event is ignored to avoid double submission.
if key := normalizeKey(rs.Key); key != "" {
steps = append(steps, LoginStep{Action: "press", Value: key, Selector: pickSelector(rs.Selectors)})
}
case "waitforelement":
sel := pickSelector(rs.Selectors)
if sel == "" {
continue
}
steps = append(steps, LoginStep{Action: "waitvisible", Selector: sel})
case "waitforexpression":
// We can't evaluate arbitrary expressions in the step engine; fall back
// to a settle wait so the page can reach the expected state.
steps = append(steps, LoginStep{Action: "wait"})
case "setviewport", "keyup", "scroll", "close", "emulatenetworkconditions", "hover", "":
// Not relevant to (or not supported for) headless login replay.
continue
default:
// Unknown step types are skipped rather than failing the whole import.
continue
}
}
if len(steps) == 0 {
return nil, errkit.New("auto-login: recording produced no replayable steps")
}
return steps, nil
}
// FirstNavigateURL returns the URL of the first navigate step in a recording, or
// "" if none. It lets the caller derive the login URL from the recording.
func FirstNavigateURL(steps []LoginStep) string {
for _, s := range steps {
if strings.EqualFold(s.Action, "navigate") && s.Value != "" {
return s.Value
}
}
return ""
}
// normalizeKey maps recorder key names to the engine's supported press keys.
// Character keys (length 1) return "" because the text they produce is already
// captured by `change` steps.
func normalizeKey(key string) string {
switch strings.ToLower(strings.TrimSpace(key)) {
case "enter", "return", "numpadenter":
return "enter"
case "tab":
return "tab"
case "escape", "esc":
return "escape"
case "space":
return "space"
default:
return ""
}
}
// parameterizeValue replaces a recorded credential literal with a placeholder so
// secrets never live in the recording file. It masks by exact match against the
// configured credentials and, defensively, masks any value typed into a field
// whose selector looks like a password input.
func parameterizeValue(value, selector, username, password string) string {
if password != "" && value == password {
return "{{password}}"
}
if username != "" && value == username {
return "{{username}}"
}
if looksLikePasswordSelector(selector) {
return "{{password}}"
}
return value
}
func looksLikePasswordSelector(selector string) bool {
s := strings.ToLower(selector)
return strings.Contains(s, "password") || strings.Contains(s, "passwd") || strings.Contains(s, "type=\"password\"") || strings.Contains(s, "type=password")
}
// pickSelector chooses the most engine-friendly selector from the recorder's
// alternatives, preferring native CSS, then XPath, then an aria-label CSS
// approximation, then a text-content XPath. Shadow-piercing (pierce/) selectors
// are skipped because the engine cannot resolve them.
func pickSelector(groups [][]string) string {
var css, xpath, aria, text string
for _, group := range groups {
for _, s := range group {
s = strings.TrimSpace(s)
switch {
case s == "":
continue
case strings.HasPrefix(s, "xpath/"):
if xpath == "" {
xpath = "xpath=" + strings.TrimPrefix(s, "xpath/")
}
case strings.HasPrefix(s, "aria/"):
if aria == "" {
aria = ariaToCSS(strings.TrimPrefix(s, "aria/"))
}
case strings.HasPrefix(s, "text/"):
if text == "" {
text = textToXPath(strings.TrimPrefix(s, "text/"))
}
case strings.HasPrefix(s, "pierce/"):
continue
default:
if css == "" {
css = s
}
}
}
}
for _, candidate := range []string{css, xpath, aria, text} {
if candidate != "" {
return candidate
}
}
return ""
}
// ariaToCSS approximates an accessibility-name selector as an aria-label CSS
// attribute selector. It is best-effort; recordings usually also carry a CSS or
// XPath selector that is preferred over this.
func ariaToCSS(name string) string {
name = strings.TrimSpace(name)
if name == "" || strings.Contains(name, `"`) {
return ""
}
return fmt.Sprintf(`[aria-label="%s"]`, name)
}
// textToXPath converts a text-content selector into an XPath the engine can run.
func textToXPath(t string) string {
t = strings.TrimSpace(t)
if t == "" || strings.Contains(t, `"`) {
return ""
}
return fmt.Sprintf(`xpath=//*[contains(normalize-space(.), "%s")]`, t)
}
Loaded 100 of 277 files, more files were not shown because too many files have changed in this diff. Show more