Compare commits

..

99 Commits

Author SHA1 Message Date
dependabot[bot] 86a22a553d chore(deps): bump github.com/projectdiscovery/nuclei/v3
Bumps [github.com/projectdiscovery/nuclei/v3](https://github.com/projectdiscovery/nuclei) from 3.9.0 to 3.10.0.
- [Release notes](https://github.com/projectdiscovery/nuclei/releases)
- [Commits](https://github.com/projectdiscovery/nuclei/compare/v3.9.0...v3.10.0)

---
updated-dependencies:
- dependency-name: github.com/projectdiscovery/nuclei/v3
  dependency-version: 3.10.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-03 12:43:12 +00:00
Tigah 7ea1cd28d5 feat(modules): add traefik, nomad and portainer exposure modules (#249)
add recon modules for container and proxy control planes that answer without authentication: traefik serves its full routing config at /api/overview when the api is enabled, nomad dumps the agent config at /v1/agent/self when acls are disabled (403 otherwise), and portainer discloses its version and instance id at the public /api/status.
2026-07-02 17:00:50 -07:00
Tigah 9e8fb3a8d5 feat(modules): detect exposed ml platforms (#240)
add recon modules for unauthenticated ml platforms that leak cluster or deployment info without a key: h2o, mindsdb, and zenml.
2026-07-02 17:00:47 -07:00
Tigah abcc22de38 feat(modules): detect exposed rag and knowledge apps (#238)
add recon modules for unauthenticated rag/search apps that leak config or health without a key: perplexica, verba, onyx, and fastgpt.
2026-07-02 17:00:44 -07:00
Tigah 122fe5f94b feat(modules): detect exposed gpu inference servers (#236)
add recon modules for unauthenticated gpu serving stacks that leak model info without a key: triton, sglang, and torchserve.
2026-07-02 17:00:41 -07:00
Tigah e1c8e3948d feat(modules): detect exposed local llm runners (#235)
add recon modules for unauthenticated local inference servers that leak model inventory without a key: ollama, koboldcpp, text-generation-webui (oobabooga), and tabby.
2026-07-02 17:00:38 -07:00
Tigah 24bcab0a8f feat(modules): detect exposed openai-compatible llm servers (#233)
add recon modules for unauthenticated inference servers that expose an openai-style /v1/models inventory without a key: vllm, llama.cpp, lm studio, and infinity.
2026-07-02 17:00:35 -07:00
Tigah 3a289a3ac4 feat(modules): add clickhouse and dgraph exposure modules (#258)
add recon modules for self-hosted databases whose http interface is reachable
without credentials: clickhouse runs arbitrary sql because the default user has
an empty password, confirmed here by reading the server version through the
http interface, and the open-source dgraph alpha has no authentication so its
/health endpoint discloses the cluster while /query and /admin read and drop
all data; a clickhouse that requires a password returns 403 and an alpha behind
an authenticating proxy returns 401 and neither is flagged.
2026-07-02 13:22:24 -07:00
Tigah 6e022d56d9 feat(modules): detect exposed ai orchestration platforms (#242)
add recon modules for self-hosted agent builders and compute
orchestration. ray exposes an unauthenticated compute dashboard whose
job api allows code execution, skypilot exposes an open cloud and
kubernetes control plane when basic auth is disabled, dify flags a
console that allows open registration, and langflow fingerprints a
reachable instance over its public version api.
2026-07-02 13:22:21 -07:00
Tigah 1bb54393f1 feat(httpx): retry 429 and 503 with Retry-After backoff (#227)
the shared transport paced outbound requests but ignored a server
asking it to slow down, so a 429 or 503 came straight back as a failed
probe. back off and retry both through the one chokepoint every scanner
shares, honoring Retry-After (delta-seconds or http-date) and falling
back to capped exponential backoff.

gated behind -max-retries (default 2, 0 disables). bodyless GETs and
GetBody-backed requests replay safely; anything else is returned as-is.
2026-07-02 13:22:17 -07:00
dependabot[bot] f97c67695d chore(deps): bump actions/checkout from 4 to 7 (#267)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-02 13:16:52 -07:00
Tigah 7eb6603e77 feat(modules): add eureka and spring boot admin exposure modules (#263)
add two recon modules detecting anon-by-design service registries that leak the internal service map:

- eureka-registry-exposure (medium): probes /eureka/apps and matches the registry envelope markers apps__hashcode and versions__delta (the EurekaJacksonCodec formats them with a default '_' -> '__' replacement, so they appear verbatim in both the xml and json forms); eureka has no auth, so an open registry discloses every instance's internal hostname, ip and ports and accepts rogue registrations, while a spring-security-secured eureka returns 401; extracts the first instance ip from either form

- spring-boot-admin-exposure (medium): probes /instances and matches the registration/healthUrl/statusInfo shape; the codecentric server ships no security, so an open one discloses every registered app's internal management and health urls (a pivot to their actuators), while a secured one returns 401 or redirects to login; extracts the first internal health url

both modules carry hard-test coverage: eureka is proven on both its xml and json forms, with an N-1 trap (an apps__hashcode-less envelope stays quiet) and a prose trap (a page that merely mentions the word applications stays quiet); sba has an N-1 statusInfo trap; both have 401 secured-instance cases and cross false-positive guards both ways.
2026-07-02 12:56:34 -07:00
Tigah 12cab935ec feat(modules): add zookeeper and hadoop namenode exposure modules (#262)
add two recon modules detecting anon-by-design big-data control surfaces that leak operational topology:

- zookeeper-admin-exposure (medium): probes /commands/monitor on a zookeeper adminserver and matches the command/monitor envelope plus server_state; the adminserver has no auth on reads, so an open one discloses the version, ensemble role, connection counts and data size, while a firewalled or disabled adminserver is not reachable; extracts the version

- hadoop-namenode-exposure (medium): probes /jmx for the NameNodeInfo bean and matches the bean name plus LiveNodes/DeadNodes; the namenode jmx is unauthenticated unless kerberos spnego is set, so an open one discloses the hdfs version and every datanode internal hostname, while a kerberos-secured namenode returns 401; extracts the software version

trino/presto were dropped: /v1/info is ResourceSecurity(PUBLIC) so it answers on secured clusters too and would be a fingerprint, not an exposure.

both modules carry hard-test coverage with N-1 anchor traps (a non-monitor zookeeper command and a non-NameNodeInfo hadoop bean stay quiet), cross false-positive guards both ways and a secured-instance status case.
2026-07-02 12:56:31 -07:00
Tigah 79794638e4 feat(modules): add kafka ui and kafdrop exposure modules (#261)
add two recon modules detecting exposed kafka management consoles that ship without authentication by default:

- kafka-ui-exposure (high): probes /api/clusters on a kafbat/provectus kafka ui and matches the kafka-ui-specific defaultCluster/brokerCount keys plus the online/offline/initializing status enum; an open instance leaks the cluster topology and lets an unauthenticated session browse and produce records, while a login- or oauth-protected instance returns 401 and is not flagged

- kafdrop-exposure (high): probes / with an Accept: application/json header and matches the ClusterInfoVO summary/preferredReplicaPercent/brokers/topics shape; an open instance discloses broker hostnames, topics and messages, while an instance with the optional basic auth returns 401

both modules carry hard-test coverage with cross false-positive guards both ways, non-enum-status and missing-key negative cases, a wire-level check that the kafdrop module sends the Accept header, and 401 secured-instance cases
2026-07-02 12:56:29 -07:00
Tigah ed0cceb75a feat(modules): add n8n settings exposure module (#260)
add a recon module for an n8n instance that serves /rest/settings without
authentication; the endpoint exists so the frontend can load but it discloses
the webhook url structure, instance id, release channel, configured
authentication method and whether owner setup is still pending, which aids
reconnaissance and can indicate a claimable instance; it is unauthenticated by
design so this is reported as information disclosure.
2026-07-02 12:56:26 -07:00
Tigah 551c89ad0d feat(modules): add grafana anonymous access exposure module (#259)
add a recon module for a grafana with anonymous access enabled: by default
grafana requires a login and /api/search returns 401, but when anonymous
access is turned on the endpoint lists every dashboard without credentials,
exposing the internal metrics, hostnames and queries they contain, and the same
anonymous session can reach backend data sources through the data source proxy;
a grafana that requires login returns 401 and is not flagged.
2026-07-02 12:56:23 -07:00
Tigah 2c9bb4bad7 feat(modules): add jenkins and nifi exposure modules (#257)
add recon modules for self-hosted automation servers whose api is reachable
without credentials when misconfigured: jenkins /api/json answers when
anonymous read access is enabled and lists every job, view and node, and
apache nifi /nifi-api/flow/about answers when the instance runs without
security; each open instance reaches a script console or processor that runs
arbitrary code, while a jenkins without anonymous read returns 403 and a
secured nifi returns 401 and neither is flagged.
2026-07-02 12:56:21 -07:00
Tigah 19eb3b4292 feat(modules): add prometheus alertmanager exposure module (#256)
add a recon module for an unauthenticated prometheus alertmanager: the
/api/v2/status endpoint answers without credentials and returns config.original,
the full running configuration, which discloses receiver integrations and any
embedded credentials such as slack, pagerduty and webhook urls and smtp
passwords, along with the build version and cluster peers; an instance behind an
authenticating proxy returns 401 and is not flagged.
2026-07-02 12:56:18 -07:00
Tigah 2a49a72548 feat(modules): add selenium grid and selenoid exposure modules (#255)
add recon modules for self-hosted browser-automation grids that do not
authenticate their status endpoint: selenium grid /status discloses every
registered node with its os and browser slots, and selenoid /status discloses
the running sessions, capacity and browser images; each open grid also lets
anyone create a session that drives a real browser to arbitrary internal urls,
while a grid behind an authenticating proxy returns 401 and is not flagged.
2026-07-02 12:56:15 -07:00
Tigah 855a47c3cf feat(modules): add sidekiq, flower and rq-dashboard exposure modules (#254)
add recon modules for self-hosted background-job dashboards that ship no
authentication of their own and rely on the hosting application to protect
them: sidekiq web /sidekiq/stats discloses the redis server internals and the
job queue, celery flower /api/workers (reachable only when its api is
deliberately opened) discloses every worker's broker config and registered
tasks, and rq-dashboard /0/data/queues.json discloses the redis-backed queue
names and job counts; each open instance also allows killing, retrying or
deleting jobs, while a deployment protected by the application returns a
redirect or 401 and is not flagged.
2026-07-02 12:56:12 -07:00
Tigah b4dec11a52 feat(modules): add dagster and mage exposure modules (#253)
add recon modules for self-hosted data-orchestration webservers that ship no built-in authentication: dagster /server_info discloses the webserver and core versions, and mage /api/status discloses the scheduler status and server repository path; both reach an editor or graphql api on the same instance that can execute arbitrary code.
2026-07-02 12:56:09 -07:00
Tigah c69818ecd0 feat(modules): add caddy and envoy admin exposure modules (#252)
add recon modules for unauthenticated proxy and server admin interfaces that should be loopback-only: caddy /config/ returns the full running configuration and accepts a config-replacing post, and envoy /server_info exposes the build and command-line options of an admin interface that also offers config_dump and shutdown.
2026-07-02 12:56:07 -07:00
Tigah 64e3f540a9 feat(modules): add node-red flow api exposure module (#251)
add a recon module for an exposed node-red admin api; with adminAuth unset the /flows endpoint returns the flow configuration without a token and the open admin api allows flow deployment that runs arbitrary code through function and exec nodes, while a secured instance returns 401.
2026-07-02 12:56:04 -07:00
Tigah b9bca40a06 feat(modules): add concourse, woodpecker and gocd exposure modules (#250)
add recon modules that fingerprint ci/cd pipeline servers via their public version endpoints: concourse /api/v1/info, woodpecker /version (keyed on its source repository) and gocd /go/api/version (keyed on its gocd commit url); each answers without authentication and discloses the server version.
2026-07-02 12:56:01 -07:00
Tigah 16ffcc240b feat(modules): add loki, jaeger and zipkin exposure modules (#248)
add recon modules for unauthenticated observability backends that leak service topology and log data: loki serves its log query api when auth_enabled is false (401 otherwise), and jaeger and zipkin ship no authentication so a reachable instance exposes the service map and trace data.
2026-07-02 12:55:58 -07:00
Tigah 1b0677033c feat(modules): detect exposed one-api llm gateway (#246)
add a recon module for the one-api / new-api self-hosted llm gateway,
whose public status endpoint discloses the system name, version, and
quota configuration.
2026-07-02 12:55:55 -07:00
Tigah 84ae3a7168 feat(modules): detect exposed ai speech servers (#245)
add recon modules for self-hosted speech inference servers that are exposed without authentication: speaches (faster-whisper successor, speech-to-text and text-to-speech) keyed on its non-openai model task field, and xtts-api-server keyed on its get-folders endpoint that discloses local filesystem paths.
2026-07-02 12:55:52 -07:00
Tigah 754739bac7 feat(modules): detect exposed vector search engines (#244)
add recon modules for self-hosted vector and semantic search engines
reachable without auth: marqo, vespa, and meilisearch each allow
unauthenticated read and write of the indexed data, and their root or
version endpoints disclose the build.
2026-07-02 12:55:50 -07:00
Tigah fcccff5532 feat(modules): detect exposed ml experiment trackers (#243)
add recon modules for self-hosted training and experiment-tracking
platforms reachable without auth: mlflow, tensorboard, aim, and
determined disclose experiments, the artifact store, training run paths,
and cluster topology over unauthenticated apis.
2026-07-02 12:55:47 -07:00
Tigah a549102bb0 feat(modules): detect exposed ai image generation servers (#241)
add recon modules for self-hosted image generation servers reachable
without auth: comfyui, automatic1111, fooocus-api, and iopaint each
expose unauthenticated generation or editing and disclose the installed
models.
2026-07-02 12:55:44 -07:00
Tigah 7e3648e06d feat(modules): detect exposed data labeling tools (#239)
add recon modules for unauthenticated annotation servers that leak projects or config without a key: cvat and label studio.
2026-07-02 12:55:41 -07:00
Tigah 9e2965b777 feat(modules): detect exposed llm chat frontends (#237)
add recon modules for unauthenticated chat uis that leak config or allow open signup without a key: open webui, librechat, anythingllm, and nextchat.
2026-07-02 12:55:39 -07:00
Tigah 37a1a9e0ec feat(modules): detect exposed huggingface inference servers (#234)
add recon modules for unauthenticated text-generation-inference (tgi) and text-embeddings-inference (tei) servers that leak model info without a key.
2026-07-02 12:55:36 -07:00
Tigah 6575c2e5f7 fix(frameworks): stop false positives and version mis-extraction (#247)
a detector accuracy audit surfaced two classes of bug in the framework
detectors.

bare-brand header false positives: header-only signatures matched a
brand name as a substring across every header name and value, so a
detector fired on any response that merely referenced the brand (a
vendor cdn named in a link or csp value, a cookie sharing the prefix).
add an optional Header field to Signature that scopes a header-only
match to one named header's value, and apply it (or a structural
anchor) per detector:

- express: "Express" scoped to x-powered-by, was firing on an
  express_checkout cookie.
- flask: "Werkzeug" scoped to the server header.
- symfony: dropped the bare "symfony" word (symfony sets no such
  header, it fired on symfony.com links); the x-debug-token header is
  the marker.
- shopify: key on the x-shopify response headers instead of the bare
  "Shopify" word, which fired on a cdn.shopify.com link.
- remix: dropped the bare "remix"/"_remix" substrings that fired on a
  track_remix.mp3 asset; window.__remixContext is the definitive
  marker.
- spring boot: anchor the whitelabel title in its h1 tag context so a
  tutorial discussing the error does not fire.

the gin and fastapi detectors are removed: gin keyed on the
"gin-gonic" import-path string (appears in tutorials, never in a real
gin response) and fastapi on bare words matching the projects' doc
domains. neither framework advertises itself in a response header or a
non-prose body marker, so there is no clean passive signal to anchor
on.

version mis-extraction: drop the low-confidence ".*?" version
fallbacks (rails, django, laravel, spring), whose unbounded gap
grabbed the first version-shaped number after the framework word and
reported an unrelated asset's cache-buster when no real version was
present. let isValidVersionString accept a single integer so a bare
major such as drupal's "Drupal 10" is no longer rejected as "unknown".

each false positive and version bug is covered by a regression test.
2026-07-02 12:55:34 -07:00
dependabot[bot] 0caca05467 chore(deps): bump reviewdog/action-yamllint from 1.21.0 to 1.22.0 (#266)
Bumps [reviewdog/action-yamllint](https://github.com/reviewdog/action-yamllint) from 1.21.0 to 1.22.0.
- [Release notes](https://github.com/reviewdog/action-yamllint/releases)
- [Commits](https://github.com/reviewdog/action-yamllint/compare/v1.21.0...v1.22.0)

---
updated-dependencies:
- dependency-name: reviewdog/action-yamllint
  dependency-version: 1.22.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-02 12:55:31 -07:00
dependabot[bot] 61c4d33f5c chore(deps): bump reviewdog/action-markdownlint from 0.26.2 to 0.27.0 (#265)
Bumps [reviewdog/action-markdownlint](https://github.com/reviewdog/action-markdownlint) from 0.26.2 to 0.27.0.
- [Release notes](https://github.com/reviewdog/action-markdownlint/releases)
- [Commits](https://github.com/reviewdog/action-markdownlint/compare/v0.26.2...v0.27.0)

---
updated-dependencies:
- dependency-name: reviewdog/action-markdownlint
  dependency-version: 0.27.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-02 12:55:29 -07:00
dependabot[bot] 3971d37190 chore(deps): bump github.com/tidwall/gjson from 1.18.0 to 1.19.0 (#264)
Bumps [github.com/tidwall/gjson](https://github.com/tidwall/gjson) from 1.18.0 to 1.19.0.
- [Commits](https://github.com/tidwall/gjson/compare/v1.18.0...v1.19.0)

---
updated-dependencies:
- dependency-name: github.com/tidwall/gjson
  dependency-version: 1.19.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-02 12:55:26 -07:00
celeste 9d95c5b74c ci(claude-review): skip fork and dependabot PRs where auth is unavailable (#268)
pull_request runs from forks and dependabot don't receive OIDC tokens or
repo secrets, so claude-code-action can't authenticate and the check fails
on every external contributor PR. Guard the job to same-repo, non-dependabot
PRs so it skips (rather than fails) those cases.
2026-07-02 12:53:38 -07:00
jan ee4ade207d docs(readme): fix invalid discord shield invite link (#231)
* docs(readme): fix invalid discord shield invite link

- Replaced the invalid Discord invite link badge to the correct one
from the top of the README

* fix: discord invite in contributing.md
2026-07-02 12:35:03 -07:00
jan 96092dafab style: apply gofmt to source tree (#232)
Ran `gofmt -w .` accross the repo to fix formatting drift.

Mechanical `gofmt -w .` only. No functional or behavioural changes.

CONTRIBUTING.md requires gofmt-clean code; these files had slipped.
2026-06-25 18:19:17 -07:00
Tigah 39b333320e chore: migrate module path to github.com/vmfunc/sif (#194)
rename the go module path from github.com/dropalldatabases/sif to
github.com/vmfunc/sif across go.mod, all imports, the golangci exclude
list, release install docs and docs. pure string rename, no logic change.
2026-06-22 22:25:39 -07:00
celeste 7c0eb0bd4d test(scan): fix integration_test SQL arity after calibrate param (#230)
#180 added the calibrate bool to SQL but the integration-tagged test
(only built under -tags=integration, outside normal CI) still called the
4-arg form. pass false (no calibration) to restore behavior.
2026-06-22 22:10:46 -07:00
Tigah fb9b92a5bf fix(frameworks): make CodeIgniter detection specific (#156) 2026-06-22 22:00:26 -07:00
Tigah 184842f734 feat(modules): add wordlist file support to http modules (#158)
a wordlist file fuzzes the {{word}} path placeholder, one request per
non-empty line, reusing the dirlist scanner's line reader. composes with
the existing attack modes and matchers-condition; updates attack-mode
tests for the new signature.
2026-06-22 21:44:04 -07:00
Tigah 0c6a8db5a7 feat(modules): add favicon fingerprint demo module (#184)
a favicon-gitlab info module showing the favicon hash matcher in use,
with a sync test pinning the module's hash to the shared fingerprint pkg.
2026-06-22 21:42:43 -07:00
Tigah 54d1be288b fix(frameworks): make Spring Boot detection specific (#157) 2026-06-22 21:39:59 -07:00
celeste 17cf26cd82 test(modules): fix favicon_test checkMatchers arity after matchers-condition (#228)
#189 added the condition param to checkMatchers; the favicon matcher test
(#183) still called the 3-arg form, breaking the build once both landed.
pass "" (default and-condition) to restore single-matcher semantics.
2026-06-22 21:23:12 -07:00
Tigah 672858b1fe fix(frameworks): make Shopify detection specific (#155) 2026-06-22 20:49:05 -07:00
Tigah 0422b8b413 feat(modules): add favicon hash matcher (#183)
match the shodan-style mmh3 favicon hash of the response body; signed or
unsigned 32-bit values both accepted. validated at parse time.
2026-06-22 20:42:32 -07:00
Tigah f37094c9ee feat(modules): support or logic via matchers-condition (#189)
add matchers-condition (and default, or) so a module fires when any
matcher hits, not only when all do. validated at parse time.
2026-06-22 20:33:14 -07:00
Tigah d34db5582f fix(frameworks): drop bare-substring signatures that false-positive (#133)
replace bare gin/api/cake/svelte/ember/backbone/meteor substrings with
specific markers (Backbone.Model, ember-application, __meteor_runtime_config__,
CAKEPHP cookie, svelte/internal) so prose and CORS headers stop matching.
adds paired false-positive/positive coverage per framework.
2026-06-22 20:31:32 -07:00
Tigah 9cf7854ed8 feat(modules): add json extractor (#191)
extract values from a json body by gjson path; the first path that
exists is stored under the extractor name. gjson was already vendored.
2026-06-22 20:26:28 -07:00
Tigah af337bd094 fix(scan): apply reflected-path tolerance to soft-404 calibration (#180)
calibrate against reflecting catch-alls whose body size tracks path
length so exact-shape calibration no longer misses them; -ac now drives
both dirlist and sql. updates the admin-panel query test to the new SQL
signature. adds soft-404 + calibration coverage.
2026-06-22 20:26:19 -07:00
Tigah 7e104ac8d4 fix(scan): detect modern drupal by its headers (#170)
CMS only flagged Drupal on X-Drupal-Cache: HIT or the Drupal 7 Drupal.settings
marker, so Drupal 8-11 went undetected: a MISS cache header was ignored, and
cdn-fronted sites serve none of those markers in the body at all. verified live
that london.gov.uk and georgia.gov (both Drupal) were missed.

key on the Drupal-specific headers instead: any X-Drupal-Cache or
X-Drupal-Dynamic-Cache, plus X-Generator naming Drupal. these survive cdn
caching. drupalSettings (8+) and Drupal.settings (7) cover uncached bodies.
2026-06-22 20:20:36 -07:00
Tigah 5dc14ecf22 fix(scan): drop subdomain-takeover fingerprints for mitigated and generic services (#165)
prune fingerprints for providers can-i-take-over-xyz marks not-vulnerable
(fastly, zendesk, uservoice, acquia) and generic-content matches that
false-positive (kajabi/thinkific/tave 404s, activecampaign lighttpd page,
teamwork). keeps still-vulnerable detections intact; adds coverage.
2026-06-22 20:15:47 -07:00
Tigah b31234c1bc feat(modules): add netdata and cadvisor exposure modules (#217)
modules/recon/netdata-api-exposure.yaml flags an exposed Netdata agent through its
unauthenticated /api/v1/info endpoint, keyed on the mirrored_hosts and cores_total
fields a generic info response does not carry, then extracts the agent version.

modules/recon/cadvisor-api-exposure.yaml flags an exposed cAdvisor container monitor
through its /api/v1.3/machine endpoint, keyed on the machine_id and cpu_frequency_khz
fields, then extracts the machine id.

internal/modules/metrics_exposure_test.go drives both modules through
ExecuteHTTPModule and asserts the leak alongside the near misses a strict review
wants pinned: each service with one keying field missing, a generic json, a plain 200
and a 404.

verify: go test ./internal/modules, each matcher and extractor proven to bite
(break -> red, restore -> green).
2026-06-22 19:52:30 -07:00
Tigah caeff3944d feat(modules): add docker, kubernetes and kubelet api exposure modules (#212)
modules/recon/docker-api-exposure.yaml flags an unauthenticated Docker Engine
api, keyed on the api version paired with the minimum api version that a generic
version endpoint does not carry, then extracts the engine version.

modules/recon/kubernetes-api-exposure.yaml flags an internet reachable Kubernetes
api server through its anonymous version endpoint, keyed on the git version
paired with a build field, then extracts the version.

modules/recon/kubelet-api-exposure.yaml flags an exposed kubelet whose pod list
leaks the cluster workload, keyed on the PodList kind paired with an api version,
then extracts a pod namespace.

internal/modules/runtime_api_exposure_test.go drives the three modules end to end
through ExecuteHTTPModule and asserts the leak alongside the near misses a strict
review wants pinned: a generic version response, each service with one keying
field missing, a service list that is not a pod list, a plain 200 and a 404.

verify: go test ./internal/modules, each matcher and extractor proven to bite
(break -> red, restore -> green).
2026-06-22 19:52:25 -07:00
Tigah 8c8f8afba3 feat(modules): add maven, gradle and nuget credential exposure modules (#209)
modules/recon/maven-settings-exposure.yaml flags an exposed settings.xml through
the settings or servers structure paired with a password element, so a mirror
only config is not reported, then extracts the server username.

modules/recon/gradle-properties-exposure.yaml flags an exposed gradle.properties
through a password, secret or token property with a value on the same line,
skipping comments and empty assignments, then extracts the property name.

modules/recon/nuget-config-exposure.yaml flags an exposed nuget.config through a
packageSourceCredentials section paired with a cleartext password key, so a
plain package source list or an appsettings password is not reported, then
extracts the feed username.

internal/modules/buildtool_credential_exposure_test.go drives the three modules
end to end through ExecuteHTTPModule and asserts the leak alongside the near
misses a strict review wants pinned: a mirror only settings, a non credential
properties file, a commented password, an empty value, a plain source list, an
appsettings password, an html tutorial for each file, a plain 200 and a 404.

verify: go test ./internal/modules, each matcher and extractor proven to bite
(break -> red, restore -> green).
2026-06-22 19:52:21 -07:00
Tigah 1e47b6547e feat(modules): add terraform, kubeconfig and compose exposure modules (#201)
modules/recon/terraform-state-exposure.yaml flags an exposed terraform state
file on the terraform_version key paired with a state structure key, then
extracts the version. the structure key keeps a document that merely mentions
terraform_version from matching.

modules/recon/kubeconfig-exposure.yaml flags an exposed kubeconfig on the
kind: Config marker paired with a cluster or credential key, then extracts the
cluster api endpoint. it catches an exec auth kubeconfig with no embedded key
since the cluster block alone is a leak.

modules/recon/docker-compose-exposure.yaml flags an exposed compose file on the
services key paired with a service definition key, then extracts the first
image reference to surface the stack and its versions.

each module pairs a unique marker with a structure key and rejects an html
body, so a page that only names the marker is not a leak.

internal/modules/infra_config_exposure_test.go drives the three modules end to
end through ExecuteHTTPModule and asserts the leak alongside the near misses a
strict review wants pinned: a bare terraform_version mention, a bare
kind: Config mention, a bare services key, an html page carrying the markers, a
plain 200 body and a 404, none of which may match.

verify: go test ./internal/modules, each marker, structure gate, guard and
extractor proven to bite (break -> red, restore -> green).
2026-06-22 19:52:16 -07:00
Tigah 368d658882 feat(modules): add grafana, kibana and jenkins login panel modules (#187)
* feat(modules): add grafana, kibana and jenkins login panel modules

* test(modules): cover the login panel modules
2026-06-22 19:52:12 -07:00
Tigah c6cedf3f55 feat(modules): add env file exposure module (#185)
* feat(modules): add env file exposure module

* test(modules): cover the env file exposure module
2026-06-22 19:52:07 -07:00
celeste 6dd1d9e7fe Add Claude Code GitHub Workflow (#226)
* "Claude PR Assistant workflow"

* "Claude Code Review workflow"
2026-06-22 18:38:05 -07:00
dependabot[bot] 3f78eabf6d chore(deps): bump golang.org/x/time from 0.14.0 to 0.15.0 (#142)
Bumps [golang.org/x/time](https://github.com/golang/time) from 0.14.0 to 0.15.0.
- [Commits](https://github.com/golang/time/compare/v0.14.0...v0.15.0)

---
updated-dependencies:
- dependency-name: golang.org/x/time
  dependency-version: 0.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-22 18:36:04 -07:00
dependabot[bot] 7a915d5ae7 chore(deps): bump github.com/projectdiscovery/nuclei/v3 (#141)
Bumps [github.com/projectdiscovery/nuclei/v3](https://github.com/projectdiscovery/nuclei) from 3.8.0 to 3.9.0.
- [Release notes](https://github.com/projectdiscovery/nuclei/releases)
- [Commits](https://github.com/projectdiscovery/nuclei/compare/v3.8.0...v3.9.0)

---
updated-dependencies:
- dependency-name: github.com/projectdiscovery/nuclei/v3
  dependency-version: 3.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-22 18:35:58 -07:00
Tigah 1b41b5ed65 feat(modules): add ignition, profiler and heapdump exposure modules (#196)
modules/recon/laravel-ignition-exposure.yaml probes the live
/_ignition/health-check endpoint and extracts can_execute_commands, the flag
that marks the CVE-2021-3129 remote code execution surface. this is an active
probe, complementary to the version based ignition entry in the framework cve
map.

modules/recon/symfony-profiler-exposure.yaml flags an exposed web profiler on
its structural markers and extracts a request token to pivot to a captured
request.

modules/recon/spring-heapdump-exposure.yaml flags an exposed actuator heap
dump on the hprof magic anchored at the start of the body, which a json marker
module cannot see because the dump is binary, and extracts the hprof version.
the anchor keeps a page that merely quotes the magic from matching.

internal/modules/debug_exposure_test.go drives the three modules end to end
through ExecuteHTTPModule and asserts the leak alongside the near misses a
strict review wants pinned: a prose mention of ignition, the hprof magic away
from the start, a plain 200 body and a 404, none of which may match, plus an
exposed ignition with command execution disabled that still flags and reports
the false flag.

verify: go test ./internal/modules, each matcher, anchor and extractor proven
to bite (break -> red, restore -> green).
2026-06-22 18:29:10 -07:00
Tigah d17f7c2074 fix(scan): skip built-in module when its legacy flag already ran (#223)
four built-in go modules (nuclei-scan, framework-detection, shodan-lookup,
whois-lookup) wrap a legacy per-target scan that also has its own flag, so
running -am alongside one of those flags (e.g. -am -nuclei) ran the same
per-target scan twice. skip the wrapper module in the run loop when its flag
already ran. securitytrails-lookup is not deduped: its -securitytrails flag
expands the target list rather than scanning a target, so the module is not a
duplicate of it.
2026-06-22 18:26:42 -07:00
Tigah 301f758053 feat(modules): add server status page exposure module (#150)
apache mod_status and nginx stub_status pages expose worker state,
client addresses and request urls. match the three real shapes (the
apache html "Apache Server Status for" page, the apache auto Scoreboard
line, and the nginx "Active connections" plus "server accepts handled
requests" block) and extract the apache version when present.
2026-06-22 18:26:19 -07:00
Tigah 9f3b9eaa55 feat(frameworks): add config-defined custom detectors (#160)
load yaml-defined detectors from ~/.config/sif/signatures (AppData\Local
on windows), mirroring the user-modules convention, so a framework sif
does not ship can be detected without a rebuild. they load lazily once
per run from DetectFramework and register alongside the built-ins.

each file is one detector, scored by the same weighted signature match as
the built-ins. confidence is linear rather than their sigmoid (importing
it would cycle), so a detector clears the 0.5 threshold once its matched
weights pass half. a name matching a built-in overrides it and inherits
that built-in's version patterns and cves, the same as a user module. a
single unparseable file warns and is skipped rather than failing the scan.

implements the custom signature support help-wanted item in contributing.
2026-06-22 18:24:02 -07:00
Tigah 9be84be908 feat(config): add scan templates via -template (#154)
* refactor(config): extract registerFlags from Parse

split flag registration into a registerFlags helper so callers, including
tests, can build and inspect the flag set without parsing os.Args. pure
move, no behavior change.

* feat(config): add scan templates via -template

wire up the existing -template flag to load a batch of scan settings from
a built-in preset or a local yaml file, so a run does not have to pass
every flag by hand.

a value is a named preset (minimal, recon, full) embedded in the binary,
or a path to a local yaml file keyed by flag long-names. it merges as the
goflags config before parsing, so command-line flags still win and it
replaces the ambient config for that run.

implements #5.
2026-06-22 18:23:54 -07:00
Tigah d7d669e300 feat(modules): add spring boot actuator exposure module (#144)
probe /actuator and the env, health and metrics endpoints for an
exposed actuator, which leaks environment variables, config and
runtime internals. sif already fingerprints spring boot as a framework
but never checks whether its actuator endpoints are left open.

the matchers key on structural shapes rather than bare tokens: the env
propertySources array, a hal index whose links resolve under /actuator,
detailed health components, and jvm metric names. a bare {"status":"UP"}
health check, a generic hateoas api and prose mentions do not match.

a custom management base-path (actuator moved off /actuator) and spring
boot 1.x root endpoints are not covered.
2026-06-22 18:23:48 -07:00
Tigah db862992b5 feat(modules): add joomla, drupal and magento config exposure modules (#211)
modules/recon/joomla-config-exposure.yaml flags an exposed configuration.php
backup through the JConfig class paired with the password property, so a generic
php class is not reported, then extracts the database password.

modules/recon/drupal-config-exposure.yaml flags an exposed settings.php backup
through the databases array paired with a literal password value, so an array
that lacks the marker or resolves the password from the environment is not
reported, then extracts the password.

modules/recon/magento-config-exposure.yaml flags an exposed app/etc/env.php
backup through the crypt or mode marker paired with a literal key or password
value, so a generic return array or a cloud placeholder is not reported, then
extracts the crypt key.

internal/modules/cms_config_exposure_test.go drives the three modules end to end
through ExecuteHTTPModule and asserts the leak alongside the near misses a strict
review wants pinned: a config missing its password, a generic php class, an array
without the databases marker, a databases array with no password, an env
indirection password, a return array without a magento marker, a magento config
with no credential, a cloud placeholder key, an html tutorial for each file, a
plain 200 and a 404.

verify: go test ./internal/modules, each matcher and extractor proven to bite
(break -> red, restore -> green).
2026-06-22 18:21:32 -07:00
Tigah 0255e2d3ac fix(scan): require a wordpress marker on probed wp paths (#169)
detectWordPress treated any 2xx on /wp-login.php, /wp-admin/ or /wp-config.php
as wordpress, but the http client follows redirects and many sites soft-404
(200 for every path) or catch-all redirect to their homepage. a probed path
then returns 200 without being wordpress at all, so plain static sites and
marketing pages were flagged as wordpress.

read the probed response and require one of the existing wordpress markers in
its body before counting it. a real /wp-login.php still references wp-includes
assets, so genuine detection holds.
2026-06-22 18:21:19 -07:00
Tigah 7c635bae9f fix(scan): don't flag quotes reflected in text as attribute xss (#203)
classifyXSSContext defaulted any non-html, non-script reflection to attribute context. a reflection echoed into element text with the angle brackets escaped but quotes left raw (common for encoders limited to < > &) was then reported as a high-severity attribute injection, even though a quote in text content has no delimiter to break out of.

classify a reflection as attribute only when the canary actually lands inside an open tag; everything else is inert element text and yields no finding. the in-tag test is a byte-scan, not a parser, so rare malformed markup can still mis-bucket.
2026-06-22 18:21:09 -07:00
Tigah 0c9419b374 fix(scan): crack weak hmac secrets on hs384 and hs512 tokens (#197) 2026-06-22 18:21:02 -07:00
Tigah af759c7073 fix(scan): update stale ghost and pantheon takeover fingerprints (#168)
the ghost and pantheon entries keyed on each platform's older unclaimed-domain
copy, which the live pages no longer serve, so a real dangling subdomain went
undetected. verified against live unclaimed subdomains: <random>.ghost.io now
returns "Failed to resolve DNS path for this host" and <random>.pantheonsite.io
returns a "404 - Unknown site" page. key on those instead.

ghost also serves a bare "Site unavailable." on some hosts; the body match holds
one substring per service, so it keys on the distinctive arm and a page showing
only "Site unavailable." is not detected.
2026-06-22 18:20:54 -07:00
Tigah 273dcdc30d feat(modules): add adminer and phpmyadmin database panel modules (#186)
* feat(modules): add adminer and phpmyadmin database panel modules

* test(modules): cover the database panel modules
2026-06-22 18:18:37 -07:00
Tigah be56a90af1 fix(scan): de-duplicate selected modules before running them (#224)
-mt builds the run list by appending modules.ByTag per tag and -m by appending
each id, neither de-duplicated, so a module matching two requested tags
(nuclei-scan is tagged both vuln and cve, so -mt vuln,cve) or listed twice ran
twice. de-duplicate toRun by module id, preserving first-seen order, before
executing.
2026-06-22 18:18:22 -07:00
Tigah cf159ad4a9 feat(modules): add svn, mercurial and bazaar exposure modules (#210)
modules/recon/svn-exposure.yaml flags an exposed .svn working copy through the
wc.db sqlite header anchored at the first byte paired with a working copy table
name, so a generic sqlite database is not reported, then extracts the
repository url.

modules/recon/mercurial-exposure.yaml flags an exposed .hg repository through
the revlog format requirements that the requires file lists, so prose that
names mercurial is not reported, then extracts the requirement.

modules/recon/bazaar-exposure.yaml flags an exposed .bzr repository through the
Bazaar meta directory signature, so a page that names a bazaar is not reported,
then extracts the format.

internal/modules/vcs_metadata_exposure_test.go drives the three modules end to
end through ExecuteHTTPModule and asserts the leak alongside the near misses a
strict review wants pinned: a generic sqlite database, an unanchored magic,
prose naming mercurial, a marketplace page, an html tutorial for the text
formats, a plain 200 and a 404.

verify: go test ./internal/modules, each matcher and extractor proven to bite
(break -> red, restore -> green).
2026-06-22 18:18:16 -07:00
Tigah f22aa9e161 fix(scan): don't follow redirects when probing cors (#200)
The cors probe followed up to three redirects, then read the cors headers off the final response. Its own comment said the cap existed so the headers came from the host we asked about, so the code contradicted its intent: a target that only redirects to a permissive third party had that party's misconfiguration reported against it.

Stop following redirects, matching the open-redirect scanner. The redirect cap was a const shared with the xss probe, so xss now owns its own xssMaxRedirects with its follow behavior unchanged.
2026-06-22 18:18:09 -07:00
Tigah 4a84790f02 feat(modules): add netrc, pgpass and my.cnf exposure modules (#208)
modules/recon/netrc-exposure.yaml flags an exposed .netrc through the machine
login password grammar, requiring the keywords in order so prose that names
them out of order does not match, then extracts the machine host.

modules/recon/pgpass-exposure.yaml flags an exposed .pgpass through a single
line host:port:database:user:password record with a numeric or wildcard port,
which a yaml config or a multi line body does not satisfy, then extracts the
host.

modules/recon/mysql-client-config-exposure.yaml flags an exposed .my.cnf
through a client section paired with a cleartext password key, so a section
without a credential is not reported, then extracts the client user.

internal/modules/dotfile_credential_exposure_test.go drives the three modules
end to end through ExecuteHTTPModule and asserts the leak alongside the near
misses a strict review wants pinned: out of order prose, a yaml db config, a
non numeric port, a multi line body, a section without a password, a password
without a section, an html tutorial for each file, a plain 200 and a 404.

verify: go test ./internal/modules, each matcher and extractor proven to bite
(break -> red, restore -> green).
2026-06-22 18:15:51 -07:00
Tigah e2f59637ec feat(modules): add docker registry and harbor exposure modules (#218)
modules/recon/docker-registry-api-exposure.yaml flags a Docker registry reachable
anonymously through its /v2/ base, keyed on a 200 paired with the
Docker-Distribution-Api-Version: registry/2.0 response header (the header rides on a
401 too, so the 200 gate is what proves anonymous reach), then extracts the api
version.

modules/recon/harbor-api-exposure.yaml flags an exposed Harbor registry through its
unauthenticated /api/v2.0/systeminfo endpoint, keyed on the harbor_version and
auth_mode fields, then extracts the harbor version.

internal/modules/registry_exposure_test.go drives both modules through
ExecuteHTTPModule and asserts the leak alongside the near misses: docker registry on
a header-less 200 and on a 401 that still carries the header, harbor with one keying
field missing, a plain 200 and a 404.

verify: go test ./internal/modules, each matcher and extractor proven to bite
(break -> red, restore -> green).
2026-06-22 18:13:20 -07:00
Tigah c68b077a22 feat(modules): add phpinfo exposure module (#145)
probe phpinfo.php, info.php, php_info.php, test.php and i.php for an
exposed phpinfo() page, which leaks the full php config, environment,
loaded extensions and $_SERVER (often credentials).

a finding requires both a phpinfo header (the version-stamped title or
the zend engine credit) and a config table row (the PHP Version or
System cell), so a page that only quotes one of those in prose does not
match. the php version is read from the config table.
2026-06-22 18:08:12 -07:00
Tigah 8c732f9955 feat(modules): add prometheus metrics exposure module (#147)
an exposed /metrics endpoint leaks process, runtime and request
internals that aid recon. match the prometheus text exposition format
structurally (a # HELP line plus a # TYPE line ending in one of the
known metric types) so a json /metrics or prose that mentions the
format does not trip it. extract the go runtime version from go_info
when it is present.
2026-06-22 18:01:02 -07:00
Tigah 27a8a27880 feat(modules): add werkzeug debugger exposure module (#149)
a flask app left on debug=True wraps the wsgi app in werkzeug's
DebuggedApplication, which serves its debugger assets unauthenticated:
GET /?__debugger__=yes&cmd=resource&f=debugger.js returns the debugger
javascript with no pin and no live exception required. that exposes the
interactive console (an rce vector) and tracebacks that leak source and
config.

probe that asset path and match two javascript anchors stable across
werkzeug 0.14 through 3.0 so a page that only references the debugger
does not match, then read the werkzeug version from the server header.
2026-06-22 17:58:22 -07:00
Tigah 9340a8be0e feat(modules): add graphql introspection detection module (#139)
add a yaml module that posts a minimal introspection query to common
graphql paths and flags endpoints whose schema is exposed. the matcher
keys on the json result shape ("__schema":{ and "queryType":{) instead of
the bare __schema/queryType substrings, so a disabled endpoint that echoes
the query in its error does not false-positive. scoped to post+json
requests; get-only and persisted-query endpoints are out of scope.
2026-06-22 17:53:28 -07:00
Tigah 77f203e47c feat(scan): broaden cloudstorage bucket name candidates (#162)
the extractPotentialBuckets TODO asked to handle non-adjacent label
combos and strip the tld. strip the trailing tld label so we stop
guessing it ("com", "com-s3", "example-com"), and pair every label
with every other rather than only adjacent ones, so a deep host like
shop.cdn.example yields shop-example too.

this widens the candidate set (and the s3 probes) per host. multi-part
suffixes like .co.uk still leave a junk label; publicsuffix would
refine that as a follow-up.
2026-06-22 17:48:45 -07:00
Tigah 733578e6ec feat(modules): add django debug page exposure module (#148)
a django app left on DEBUG=True renders a technical 404 or 500 page
that leaks settings, the url config, the traceback and request details.
a non-existent path triggers the 404 page on such apps; match the
"seeing this error because you have DEBUG = True" footer together with
the page chrome so a normal 404 does not match, then extract the django
version.
2026-06-22 17:48:36 -07:00
Tigah 0fa3d03eb7 fix(scan): surface dirlist wordlist read errors (#164)
scanLines used a default bufio.Scanner (64k token cap) and never
checked scanner.Err(), so a wordlist line past the cap silently halted
the scan and dropped that line plus everything after it. return the
error so the wordlist loaders fail loud instead of running against a
truncated list.
2026-06-22 17:45:57 -07:00
Tigah 1bbc564170 feat(modules): add elasticsearch exposure module (#151)
an elasticsearch node left without authentication answers its root
banner to any client, and versions before 8.0 ship with no auth by
default, so a 200 at / means every index is readable without
credentials. match the "You Know, for Search" tagline together with
the lucene_version field so a page that only quotes the tagline in
prose does not match, then read the cluster version from the
version.number field.
2026-06-22 17:45:49 -07:00
Tigah 0f11283b1e refactor(scan): extract favicon hashing into internal/fingerprint (#182)
move FaviconHash and its base64 chunking into a leaf package so the scan check and the module engine share one implementation instead of each carrying a copy. no behavior change; the golden test moves with it.
2026-06-22 17:43:30 -07:00
Tigah f5ad97ac57 fix(scan): drop the bare wordpress detection marker (#171)
detectWordPress flagged any page whose body merely contained the word
"wordpress", so sites that only mention it (wp-hosting marketing, comparison
pages) were misreported: acquia.com, a Drupal site, was detected as WordPress.

wp-content, wp-includes and wp-json already identify a real install and appear
on every wordpress page, so the bare word adds only false positives. drop it.
2026-06-22 17:41:28 -07:00
Tigah 12b56661ac feat(modules): add couchdb exposure module (#152)
probe /_all_dbs instead of the / welcome banner: couchdb serves the
banner publicly even on a secured 3.x, so a 200 there only proves an
instance is reachable. /_all_dbs is admin-gated by default since 3.0
(admin_only_all_dbs), so a 200 listing means the database names are
readable without auth on every version, while a secured server returns
401. the match requires a json array carrying a system database
(_users, _replicator or _global_changes), which keeps non-couchdb
arrays and prose clean.

no version is extracted: the /_all_dbs array carries no version string.
instances with renamed or deleted system databases are not matched.
2026-06-22 17:41:17 -07:00
Tigah 798f53d3f4 fix(scan): flag openapi operations reachable without authentication (#175)
specToResult computed an operation's auth status with len(op.Security) == 0,
which a value slice cannot tell apart from an absent block: an operation that
overrides the global requirement with an empty array (security: []) or an
empty requirement object (security: [{}]) decoded the same as one that simply
inherits the global default, so those deliberately-public endpoints were
reported as authenticated. decode the operation security into a pointer to
keep absent distinct from explicit-empty, and treat both empty forms as
anonymous-reachable.
2026-06-22 17:32:58 -07:00
Tigah a7f769b35a fix(scan): read full body in js scanners (#166)
the page scanner and the next.js manifest parser both reassembled the
response body line by line with a default bufio.Scanner (64k token
cap), so a line past the cap (common with minified or inlined js)
silently halted the read and dropped every script and route reference
after it. read the whole body instead, capped at 5mb.

parsing the raw page bytes also fixes script tags split across a
newline, which the old line-joined reassembly merged and missed.
2026-06-22 17:30:22 -07:00
Tigah fa3223ab31 fix(frameworks): drop the dead X-Powered-By signature from ASP.NET (#159)
the asp.net detector carried {Pattern: "X-Powered-By: ASP.NET", HeaderOnly:
true}, but containsHeader matches a signature against each header name and each
value separately, never against a joined "name: value" string. so that pattern
could never match anything. it only added its 0.4 to the detector's total
weight, diluting every real signal: a genuine response (an X-AspNetMvc-Version
header, an X-Powered-By: ASP.NET header, an .aspx link) scored 1.1/3.8 = 0.47,
just under the 0.5 threshold, and went undetected.

the live "ASP.NET" header signature already matches the X-Powered-By value, so
removing the dead one loses no coverage and lifts that same response to
1.1/3.4 = 0.56. adds a regression test for it.
2026-06-22 17:30:15 -07:00
Tigah 657cb0cbb8 feat(frameworks): add htmx detector (#188) 2026-06-22 17:28:24 -07:00
Tigah c03fa7d336 feat(modules): add portainer, traefik, keycloak and rabbitmq panel modules (#190) 2026-06-22 17:26:16 -07:00
Tigah 45f5302e1f feat(modules): add aws, npmrc and docker credential file exposure modules (#195)
modules/recon/aws-credentials-exposure.yaml flags exposed .aws/credentials,
.s3cfg and .boto files on the access and secret key markers, and extracts
the AKIA/ASIA access key id.

modules/recon/npmrc-exposure.yaml flags a .npmrc only when it carries an
auth token or password, not a bare registry config, and extracts the
registry the token belongs to.

modules/recon/docker-config-exposure.yaml flags .docker/config.json and the
legacy .dockercfg on the base64 auth field, and extracts the registry host.

each module ands a negative matcher on the usual html markers so a 200 page
that merely names a key is not a hit, the same guard the env exposure module
uses.

internal/modules/credential_exposure_test.go drives the three modules end to
end through ExecuteHTTPModule and asserts the leak alongside the near misses
a strict review wants pinned: an html doc that only names a key, a plain 200
body, a 404, and a jwt shaped docker auth value, none of which may match.

verify: go test ./internal/modules, each matcher, guard and extractor proven
to bite (break -> red, restore -> green).
2026-06-22 17:24:06 -07:00
celeste 78a2ec364f ci(pr-bot): run on pull_request_target so fork PRs get labeled (#225)
fork PRs get a read-only token on pull_request, so the label, size and
ci-summary jobs 403 and the summary check shows red on every external
PR. run on pull_request_target (write token, base-repo context), key the
concurrency group on the PR number so runs don't collide, and drop the
size job's unused checkout. none of these jobs check out or run PR code,
they only call the github API with the event payload, so this is the
safe labeler pattern.

supersedes #146 (same fix by @TBX3D, which conflicted after the checkout
bump in #143).
2026-06-22 17:21:32 -07:00
301 changed files with 16215 additions and 731 deletions
+48
View File
@@ -0,0 +1,48 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# OIDC tokens and repo secrets are withheld from pull_request runs that
# originate from forks or dependabot, so the action cannot authenticate
# there and the check fails for every external PR. Skip those cases (the
# job simply does not run) instead of failing. Same-repo branch PRs still
# get reviewed. To review fork PRs too, switch the trigger to
# pull_request_target (has a security trade-off) rather than loosening this.
if: >-
github.event.pull_request.head.repo.full_name == github.repository &&
github.actor != 'dependabot[bot]'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
+50
View File
@@ -0,0 +1,50 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v7
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr *)'
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
steps:
- uses: actions/checkout@v7
- name: markdownlint
uses: reviewdog/action-markdownlint@v0.26.2
uses: reviewdog/action-markdownlint@v0.27.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
reporter: github-pr-review
+1 -1
View File
@@ -171,7 +171,7 @@ jobs:
**go install**
```bash
go install github.com/dropalldatabases/sif/cmd/sif@v${{ env.VERSION }}
go install github.com/vmfunc/sif/cmd/sif@v${{ env.VERSION }}
```
**binary download** - grab the right archive from below.
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
steps:
- uses: actions/checkout@v7
- name: yamllint
uses: reviewdog/action-yamllint@v1.21.0
uses: reviewdog/action-yamllint@v1.22.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
reporter: github-pr-review
+1 -1
View File
@@ -36,7 +36,7 @@ linters:
check-blank: false
exclude-functions:
# log writes are best-effort
- github.com/dropalldatabases/sif/internal/logger.Write
- github.com/vmfunc/sif/internal/logger.Write
# Close on io.Closer is idiomatic best-effort
- (io.Closer).Close
- (*os.File).Close
+1 -1
View File
@@ -24,7 +24,7 @@ If you like the project, but don't have time to contribute, that's okay too! Her
## Reporting issues
If you believe you've found a bug, or you have a new feature to request, please hop on the [Discord server](https://discord.com/invite/sifcli) first to discuss it.
If you believe you've found a bug, or you have a new feature to request, please hop on the [Discord server](https://discord.gg/Yksy9J2BvE) first to discuss it.
This way, if it's an easy fix, we could help you solve it more quickly, and if it's a feature request we could workshop it together into something more mature.
When opening an issue, please use the search tool and make sure that the issue has not been discussed before. In the case of a bug report, run sif with the `-d/-debug` flag for full debug logs.
+2 -2
View File
@@ -176,7 +176,7 @@ sif has a modular architecture. modules are defined in yaml and can be extended
| `-fs` | dirlist: filter out responses of these body sizes (comma list) |
| `-fw` | dirlist: filter out responses with these word counts (comma list) |
| `-fr` | dirlist: filter out responses whose body matches this regex |
| `-ac` | dirlist: auto-calibrate the soft-404 wildcard baseline |
| `-ac` | auto-calibrate the soft-404 wildcard baseline (dirlist, sql) |
| `-w` | dirlist: custom wordlist (local file or url; overrides `-dirlist` size) |
| `-e` | dirlist: extensions appended to each word (comma list, e.g. php,bak,env) |
| `-dnslist` | subdomain enumeration (small/medium/large) |
@@ -375,7 +375,7 @@ go test ./...
join our discord for support, feature discussions, and pentesting tips:
[![discord](https://img.shields.io/badge/join%20our%20discord-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/sifcli)
[![discord](https://img.shields.io/badge/join%20our%20discord-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/Yksy9J2BvE)
## contributors
+5 -5
View File
@@ -17,13 +17,13 @@ import (
"os"
"github.com/charmbracelet/log"
"github.com/dropalldatabases/sif"
"github.com/dropalldatabases/sif/internal/config"
"github.com/dropalldatabases/sif/internal/patchnotes"
ver "github.com/dropalldatabases/sif/internal/version"
"github.com/vmfunc/sif"
"github.com/vmfunc/sif/internal/config"
"github.com/vmfunc/sif/internal/patchnotes"
ver "github.com/vmfunc/sif/internal/version"
// Register framework detectors
_ "github.com/dropalldatabases/sif/internal/scan/frameworks/detectors"
_ "github.com/vmfunc/sif/internal/scan/frameworks/detectors"
)
// version is stamped at release time via -ldflags "-X main.version=...";
+1 -1
View File
@@ -31,7 +31,7 @@ welcome to the sif documentation. sif is a modular pentesting toolkit designed t
```bash
# install
git clone https://github.com/dropalldatabases/sif.git && cd sif && make
git clone https://github.com/vmfunc/sif.git && cd sif && make
# basic scan
./sif -u https://example.com
+38
View File
@@ -57,6 +57,12 @@ enable verbose logging:
./sif -u https://example.com -d
```
### templates
`-template` loads a batch of scan settings from a built-in preset or a local yaml file, so a run does not have to pass every flag. see the [usage guide](usage.md) for the presets and file format. command-line flags still take precedence over the template.
sif also reads an ambient config at `~/.config/sif/config.yaml` (created on first run) keyed by the same flag names. passing `-template` uses that template as the config for the run instead of the ambient file.
## user modules
place custom modules in:
@@ -92,6 +98,38 @@ info:
# ...
```
## custom signatures
framework detection (`-framework`) also loads user-defined detectors from yaml
files, so a framework sif does not ship can be detected without rebuilding:
- linux/macos: `~/.config/sif/signatures/`
- windows: `%LOCALAPPDATA%\sif\signatures\`
each file defines one detector; place them directly in the directory, as
subdirectories are not scanned. `header: true` matches a response header name or
value (case-insensitive) instead of the body; the optional `version` block pulls
a version out of the body.
```yaml
# ~/.config/sif/signatures/ghost.yaml
name: Ghost
signatures:
- pattern: 'content="Ghost'
weight: 0.6
- pattern: 'X-Ghost-Cache'
weight: 0.4
header: true
version:
regex: 'content="Ghost ([0-9.]+)'
group: 1
```
a detector reports a match once its matched signature weights sum past half, so
weight your signatures to total about `1.0`. a name matching a built-in detector
overrides it and inherits that built-in's version patterns and known cves, the
same as user modules.
## performance tuning
### fast scans
+1 -1
View File
@@ -11,7 +11,7 @@ setting up a development environment for sif.
## clone and build
```bash
git clone https://github.com/dropalldatabases/sif.git
git clone https://github.com/vmfunc/sif.git
cd sif
make
```
+1 -1
View File
@@ -39,7 +39,7 @@ download `sif-windows-amd64.exe` from releases and add to your PATH.
requires go 1.25+
```bash
git clone https://github.com/dropalldatabases/sif.git
git clone https://github.com/vmfunc/sif.git
cd sif
make
```
+69
View File
@@ -127,6 +127,17 @@ http:
- `clusterbomb` (default) - every path is tried with every payload
- `pitchfork` - path and payload are paired by index, stopping at the shorter list
#### wordlist
a local file whose non-empty lines fuzz the `{{word}}` placeholder, one request
per word. paths without `{{word}}` are still requested as-is.
```yaml
http:
wordlist: /usr/share/wordlists/dirs.txt
paths:
- "{{BaseURL}}/{{word}}"
```
#### headers
custom headers to send.
@@ -223,6 +234,30 @@ matchers:
- 1337
```
### favicon matcher
match the shodan-style mmh3 hash of the response body. point the module at a
favicon and list the hashes of the tech you want to fingerprint.
```yaml
http:
paths:
- "{{BaseURL}}/favicon.ico"
matchers:
- type: status
status:
- 200
- type: favicon
hash:
- -235701012 # jenkins
- 1278322581 # grafana
```
the hash is shodan's `http.favicon.hash` value. paste it signed or unsigned;
both 32-bit forms are accepted, so values from shodan or any favicon-hash tool
drop in without conversion. pair it with a `status: 200` matcher so an error
page served for `/favicon.ico` is not hashed. a finding fires when the body
hashes to any listed value.
### combining matchers
multiple matchers are combined with AND logic by default.
@@ -242,6 +277,25 @@ matchers:
this matches responses with status 200 AND containing "ref: refs/".
to require any matcher instead of all, set `matchers-condition: or` on the http
block; the module then reports a finding when any one matcher matches.
```yaml
http:
matchers-condition: or
matchers:
- type: status
status:
- 401
- type: status
status:
- 403
```
this matches a 401 OR a 403 response. `matchers-condition` accepts `and` (the
default) or `or`; any other value fails at load.
## extractors
extractors pull data from responses.
@@ -271,6 +325,21 @@ extractors:
part: header
```
### json extractor
extract values from a json body by gjson path (github.com/tidwall/gjson); the
first path that exists is stored under name.
```yaml
extractors:
- type: json
name: version
part: body
json:
- "version"
- "data.version"
```
## examples
### exposed git repository
+27
View File
@@ -378,6 +378,33 @@ enable debug logging:
./sif -u https://example.com -d
```
### --template
load a batch of scan settings from a template instead of passing each flag. the value is either a built-in preset or a local yaml file keyed by flag long-names:
```bash
./sif -u https://example.com --template recon
./sif -u https://example.com --template ./my-scans.yaml
```
built-in presets:
- `minimal`: liveness and fingerprint only (probe, headers, favicon)
- `recon`: broad non-intrusive discovery, no attack payloads
- `full`: every scan except the api-key ones (shodan, securitytrails), including the intrusive probes (xss, sql, lfi, redirect)
`full` sends attack payloads, so only run it against targets you are authorized to test.
a local template lists flag long-names, for example:
```yaml
cms: true
dirlist: medium
threads: 20
```
flags passed on the command line take precedence over the template, so `--template recon -xss` runs the recon preset with an added xss probe.
## http options
these apply to every outbound request across all scanners (proxy, custom headers, cookie and rate limiting share one client). a scanner that sets a header explicitly still wins over the global default.
+53 -49
View File
@@ -1,4 +1,4 @@
module github.com/dropalldatabases/sif
module github.com/vmfunc/sif
go 1.25.7
@@ -10,13 +10,14 @@ require (
github.com/gocolly/colly/v2 v2.3.0
github.com/likexian/whois v1.15.7
github.com/projectdiscovery/goflags v0.1.74
github.com/projectdiscovery/nuclei/v3 v3.8.0
github.com/projectdiscovery/retryabledns v1.0.114
github.com/projectdiscovery/utils v0.10.1
github.com/projectdiscovery/nuclei/v3 v3.10.0
github.com/projectdiscovery/retryabledns v1.0.115
github.com/projectdiscovery/utils v0.11.1
github.com/rocketlaunchr/google-search v1.1.6
github.com/tidwall/gjson v1.19.0
github.com/twmb/murmur3 v1.1.8
golang.org/x/net v0.56.0
golang.org/x/time v0.14.0
golang.org/x/time v0.15.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -33,16 +34,16 @@ require (
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.1.0 // indirect
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/FalconOpsLLC/goexec v0.3.0 // indirect
github.com/Masterminds/semver/v3 v3.5.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/Mzack9999/gcache v0.0.0-20230410081825-519e28eab057 // indirect
github.com/Mzack9999/go-http-digest-auth-client v0.6.1-0.20220414142836-eb8883508809 // indirect
github.com/Mzack9999/go-rsync v0.0.0-20250821180103-81ffa574ef4d // indirect
github.com/Mzack9999/goja v0.0.0-20250507184235-e46100e9c697 // indirect
github.com/Mzack9999/goja_nodejs v0.0.0-20250507184139-66bcbf65c883 // indirect
github.com/Mzack9999/goimpacket v0.0.0-20260422121140-7085336a0415 // indirect
github.com/ProtonMail/go-crypto v1.1.6 // indirect
github.com/PuerkitoBio/goquery v1.11.0 // indirect
github.com/RedTeamPentesting/adauth v0.5.4-0.20260511073005-3d18e8a5a687 // 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
@@ -82,7 +83,6 @@ require (
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/bits-and-blooms/bloom/v3 v3.5.0 // indirect
github.com/bluele/gcache v0.0.2 // indirect
github.com/bodgit/plumbing v1.3.0 // indirect
github.com/bodgit/sevenzip v1.6.1 // indirect
github.com/bodgit/windows v1.0.1 // indirect
@@ -117,8 +117,8 @@ require (
github.com/ditashi/jsbeautifier-go v0.0.0-20141206144643-2520a8026a9c // indirect
github.com/djherbis/times v1.6.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/docker/docker v28.3.3+incompatible // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/dlclark/regexp2/v2 v2.2.1 // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
@@ -131,8 +131,8 @@ require (
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/free5gc/util v1.0.5-0.20230511064842-2e120956883b // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gaissmai/bart v0.26.1 // indirect
github.com/geoffgarside/ber v1.1.0 // indirect
github.com/gaissmai/bart v0.28.0 // indirect
github.com/geoffgarside/ber v1.2.0 // indirect
github.com/getkin/kin-openapi v0.132.0 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/gin-gonic/gin v1.9.1 // indirect
@@ -141,7 +141,7 @@ require (
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.9.0 // indirect
github.com/go-git/go-git/v5 v5.19.1 // indirect
github.com/go-ldap/ldap/v3 v3.4.11 // indirect
github.com/go-ldap/ldap/v3 v3.4.12 // indirect
github.com/go-logfmt/logfmt v0.6.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
@@ -163,7 +163,6 @@ require (
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
@@ -172,7 +171,6 @@ require (
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/go-github v17.0.0+incompatible // indirect
github.com/google/go-github/v30 v30.1.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect
@@ -191,14 +189,15 @@ require (
github.com/hdm/jarm-go v0.0.7 // 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
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/invopop/yaml v0.3.1 // indirect
github.com/itchyny/gojq v0.12.17 // indirect
github.com/itchyny/timefmt-go v0.1.6 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
github.com/jcmturner/gofork v1.7.6 // indirect
github.com/jcmturner/goidentity/v6 v6.0.1 // indirect
github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
@@ -211,12 +210,10 @@ require (
github.com/kennygrant/sanitize v1.2.4 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/kitabisa/go-ci v1.0.3 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/compress v1.18.5 // 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
github.com/labstack/echo/v4 v4.13.4 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/leslie-qiwa/flat v0.0.0-20230424180412-f9d1cf014baa // indirect
github.com/lib/pq v1.11.2 // indirect
@@ -229,7 +226,7 @@ require (
github.com/mackerelio/go-osstat v0.2.6 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-runewidth v0.0.17 // indirect
github.com/mattn/go-sqlite3 v1.14.28 // indirect
github.com/maypok86/otter/v2 v2.2.1 // indirect
@@ -243,7 +240,8 @@ require (
github.com/minio/selfupdate v0.6.1-0.20230907112617-f11e74f84ca7 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/moby/api v1.54.2 // indirect
github.com/moby/moby/client v0.4.1 // 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
@@ -254,6 +252,9 @@ require (
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/oiweiwei/go-msrpc v1.2.12 // 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
github.com/olekukonko/ll v0.0.9 // indirect
github.com/olekukonko/tablewriter v1.0.8 // indirect
@@ -269,40 +270,44 @@ require (
github.com/praetorian-inc/fingerprintx v1.1.15 // indirect
github.com/projectdiscovery/asnmap v1.1.1 // indirect
github.com/projectdiscovery/blackrock v0.0.1 // indirect
github.com/projectdiscovery/cdncheck v1.2.31 // indirect
github.com/projectdiscovery/clistats v0.1.1 // indirect
github.com/projectdiscovery/dsl v0.8.14 // indirect
github.com/projectdiscovery/fastdialer v0.5.6 // indirect
github.com/projectdiscovery/cdncheck v1.2.42 // indirect
github.com/projectdiscovery/clistats v0.1.4 // indirect
github.com/projectdiscovery/dsl v0.8.20 // indirect
github.com/projectdiscovery/fastdialer v0.5.11 // indirect
github.com/projectdiscovery/fasttemplate v0.0.2 // indirect
github.com/projectdiscovery/freeport v0.0.7 // indirect
github.com/projectdiscovery/gcache v0.0.0-20241015120333-12546c6e3f4c // indirect
github.com/projectdiscovery/go-smb2 v0.0.0-20240129202741-052cc450c6cb // indirect
github.com/projectdiscovery/gologger v1.1.68 // indirect
github.com/projectdiscovery/goja v0.0.0-20260618133720-acb73e419534 // indirect
github.com/projectdiscovery/goja_nodejs v0.0.0-20260618132410-8519f75f703d // indirect
github.com/projectdiscovery/gologger v1.1.71 // indirect
github.com/projectdiscovery/gostruct v0.0.2 // indirect
github.com/projectdiscovery/gozero v0.1.1-0.20251027191944-a4ea43320b81 // indirect
github.com/projectdiscovery/hmap v0.0.100 // indirect
github.com/projectdiscovery/govaluate v0.0.0-20260615100919-5ee2581bbf7e // indirect
github.com/projectdiscovery/gozero v0.1.1-0.20260530071156-fa1dad563d76 // indirect
github.com/projectdiscovery/hmap v0.0.101 // indirect
github.com/projectdiscovery/httpx v1.9.0 // indirect
github.com/projectdiscovery/interactsh v1.3.1 // indirect
github.com/projectdiscovery/ldapserver v1.0.2-0.20240219154113-dcc758ebc0cb // indirect
github.com/projectdiscovery/machineid v0.0.0-20250715113114-c77eb3567582 // indirect
github.com/projectdiscovery/mapcidr v1.1.97 // indirect
github.com/projectdiscovery/n3iwf v0.0.0-20230523120440-b8cd232ff1f5 // indirect
github.com/projectdiscovery/networkpolicy v0.1.36 // indirect
github.com/projectdiscovery/ratelimit v0.0.85 // indirect
github.com/projectdiscovery/networkpolicy v0.1.41 // indirect
github.com/projectdiscovery/ratelimit v0.0.88 // indirect
github.com/projectdiscovery/rawhttp v0.1.90 // indirect
github.com/projectdiscovery/rdap v0.9.1-0.20221108103045-9865884d1917 // indirect
github.com/projectdiscovery/retryablehttp-go v1.3.8 // indirect
github.com/projectdiscovery/sarif v0.0.1 // indirect
github.com/projectdiscovery/retryablehttp-go v1.3.16 // indirect
github.com/projectdiscovery/sarif v0.1.0 // indirect
github.com/projectdiscovery/tlsx v1.2.2 // indirect
github.com/projectdiscovery/uncover v1.2.0 // indirect
github.com/projectdiscovery/useragent v0.0.107 // indirect
github.com/projectdiscovery/wappalyzergo v0.2.76 // indirect
github.com/projectdiscovery/uncover v1.2.1 // indirect
github.com/projectdiscovery/useragent v0.0.108 // indirect
github.com/projectdiscovery/wappalyzergo v0.2.87 // indirect
github.com/projectdiscovery/yamldoc-go v1.0.6 // indirect
github.com/redis/go-redis/v9 v9.11.0 // indirect
github.com/refraction-networking/utls v1.8.2 // indirect
github.com/remeh/sizedwaitgroup v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/rs/zerolog v1.34.0 // indirect
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect
github.com/sashabaranov/go-openai v1.37.0 // indirect
github.com/segmentio/ksuid v1.0.4 // indirect
@@ -311,17 +316,17 @@ require (
github.com/shirou/gopsutil/v4 v4.26.3 // indirect
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 // indirect
github.com/sijms/go-ora/v2 v2.9.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
github.com/sorairolake/lzip-go v0.3.8 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.9.2 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/syndtr/goleveldb v1.0.0 // indirect
github.com/temoto/robotstxt v1.1.2 // indirect
github.com/tidwall/btree v1.8.1 // indirect
github.com/tidwall/buntdb v1.3.2 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/grect v0.1.4 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
@@ -336,7 +341,6 @@ require (
github.com/ugorji/go/codec v1.2.11 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/vmihailenco/bufpool v0.1.11 // indirect
github.com/vmihailenco/msgpack/v5 v5.3.4 // indirect
github.com/vmihailenco/tagparser v0.1.2 // indirect
@@ -364,14 +368,14 @@ require (
github.com/zmap/rc2 v0.0.0-20190804163417-abaa70531248 // indirect
github.com/zmap/zcrypto v0.0.0-20240803002437-3a861682ac77 // indirect
github.com/zmap/zgrab2 v0.1.8 // indirect
gitlab.com/gitlab-org/api/client-go v0.130.1 // indirect
gitlab.com/gitlab-org/api/client-go v1.9.1 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
go.mongodb.org/mongo-driver v1.17.9 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
go.opentelemetry.io/otel v1.41.0 // indirect
go.opentelemetry.io/otel/metric v1.41.0 // indirect
go.opentelemetry.io/otel/trace v1.41.0 // 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.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
go.uber.org/zap/exp v0.3.0 // indirect
@@ -379,7 +383,7 @@ require (
goftp.io/server/v2 v2.0.1 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/exp v0.0.0-20260527015227-08cc5374adb3 // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sync v0.21.0 // indirect
@@ -388,11 +392,11 @@ require (
golang.org/x/text v0.38.0 // indirect
golang.org/x/tools v0.45.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/protobuf v1.36.10 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/alecthomas/kingpin.v2 v2.2.6 // indirect
gopkg.in/corvus-ch/zbase32.v1 v1.0.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
mellium.im/sasl v0.3.2 // indirect
moul.io/http2curl v1.0.0 // indirect
software.sslmate.com/src/go-pkcs12 v0.7.0 // indirect
)
+128 -137
View File
@@ -70,13 +70,13 @@ github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mo
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 h1:oygO0locgZJe7PpYPXT5A29ZkwJaPqcva7BVeemZOZs=
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v1.3.2 h1:o7IhLm0Msx3BaB+n3Ag7L8EVlByGnpq14C4YWiu/gL8=
github.com/BurntSushi/toml v1.3.2/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible h1:1G1pk05UrOh0NlF1oeaaix1x8XzrfjIDK47TY0Zehcw=
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible/go.mod h1:r7JcOSlj0wfOMncg0iLm8Leh48TZaKVeNIfJntJ2wa0=
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/FalconOpsLLC/goexec v0.3.0 h1:ryLMkrGT6asnkqdc5rFMNOSTYdMH/iCfyEuwu0D6ZhA=
github.com/FalconOpsLLC/goexec v0.3.0/go.mod h1:kiyxVbmFCGbbwXRyZmOSKlOy7PiK+JH2gq07Ztag/k8=
github.com/Masterminds/semver/v3 v3.5.0 h1:kQceYJfbupGfZOKZQg0kou0DgAKhzDg2NZPAwZ/2OOE=
github.com/Masterminds/semver/v3 v3.5.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
@@ -86,10 +86,8 @@ github.com/Mzack9999/go-http-digest-auth-client v0.6.1-0.20220414142836-eb888350
github.com/Mzack9999/go-http-digest-auth-client v0.6.1-0.20220414142836-eb8883508809/go.mod h1:upgc3Zs45jBDnBT4tVRgRcgm26ABpaP7MoTSdgysca4=
github.com/Mzack9999/go-rsync v0.0.0-20250821180103-81ffa574ef4d h1:DofPB5AcjTnOU538A/YD86/dfqSNTvQsAXgwagxmpu4=
github.com/Mzack9999/go-rsync v0.0.0-20250821180103-81ffa574ef4d/go.mod h1:uzdh/m6XQJI7qRvufeBPDa+lj5SVCJO8B9eLxTbtI5U=
github.com/Mzack9999/goja v0.0.0-20250507184235-e46100e9c697 h1:54I+OF5vS4a/rxnUrN5J3hi0VEYKcrTlpc8JosDyP+c=
github.com/Mzack9999/goja v0.0.0-20250507184235-e46100e9c697/go.mod h1:yNqYRqxYkSROY1J+LX+A0tOSA/6soXQs5m8hZSqYBac=
github.com/Mzack9999/goja_nodejs v0.0.0-20250507184139-66bcbf65c883 h1:+Is1AS20q3naP+qJophNpxuvx1daFOx9C0kLIuI0GVk=
github.com/Mzack9999/goja_nodejs v0.0.0-20250507184139-66bcbf65c883/go.mod h1:K+FhM7iKGKtalkeXGEviafPPwyVjDv1a/ehomabLF2w=
github.com/Mzack9999/goimpacket v0.0.0-20260422121140-7085336a0415 h1:lpSZPcbowbxvKFaFvE1reLTCStezWXcRVk0zzBtUatg=
github.com/Mzack9999/goimpacket v0.0.0-20260422121140-7085336a0415/go.mod h1:Wvb2f1Aq6NVL4Fza/dPNxv6/canpeizpgmiTCBGMD50=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk=
github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE=
@@ -101,6 +99,8 @@ github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBK
github.com/PuerkitoBio/goquery v1.8.1/go.mod h1:Q8ICL1kNUJ2sXGoAhPGUdYDJvgQgHzJsnnd3H7Ho5jQ=
github.com/PuerkitoBio/goquery v1.11.0 h1:jZ7pwMQXIITcUXNH83LLk+txlaEy6NVOfTuP43xxfqw=
github.com/PuerkitoBio/goquery v1.11.0/go.mod h1:wQHgxUOU3JGuj3oD/QFfxUdlzW6xPHfqyHre6VMY4DQ=
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=
github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4=
github.com/STARRY-S/zip v0.2.3/go.mod h1:lqJ9JdeRipyOQJrYSOtpNAiaesFO6zVDsE8GIGFaoSk=
@@ -125,8 +125,8 @@ github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRF
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAuRjVTiNNhvNRfY2Wxp9nhfyel4rklc=
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137/go.mod h1:OMCwj8VM1Kc9e19TLln2VL61YJF0x1XFtfdL4JdbSyE=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/alexsnet/go-vnc v0.1.0 h1:vBCwPNy79WEL8V/Z5A0ngEFCvTWBAjmS048lkR2rdmY=
github.com/alexsnet/go-vnc v0.1.0/go.mod h1:bbRsg41Sh3zvrnWsw+REKJVGZd8Of2+S0V1G0ZaBhlU=
github.com/alitto/pond v1.9.2 h1:9Qb75z/scEZVCoSU+osVmQ0I0JOeLfdTDafrbcJ8CLs=
@@ -217,8 +217,6 @@ github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoG
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bits-and-blooms/bloom/v3 v3.5.0 h1:AKDvi1V3xJCmSR6QhcBfHbCN4Vf8FfxeWkMNQfmAGhY=
github.com/bits-and-blooms/bloom/v3 v3.5.0/go.mod h1:Y8vrn7nk1tPIlmLtW2ZPV+W7StdVMor6bC1xgpjMZFs=
github.com/bluele/gcache v0.0.2 h1:WcbfdXICg7G/DGBh1PFfcirkWOQV+v077yF1pSy3DGw=
github.com/bluele/gcache v0.0.2/go.mod h1:m15KV+ECjptwSPxKhOhQoAFQVtUFjTVkc3H8o0t/fp0=
github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU=
github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs=
github.com/bodgit/sevenzip v1.6.1 h1:kikg2pUMYC9ljU7W9SaqHXhym5HyKm8/M/jd31fYan4=
@@ -246,8 +244,6 @@ github.com/caddyserver/zerossl v0.1.3 h1:onS+pxp3M8HnHpN5MMbOMyNjmTheJyWRaZYwn+Y
github.com/caddyserver/zerossl v0.1.3/go.mod h1:CxA0acn7oEGO6//4rtrRjYgEoa4MFw/XofZnrYwGqG4=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/censys/censys-sdk-go v0.19.1 h1:CG8rQKgwrKuoICd3oU0uddALMfJnboeMkDg/e74HYyc=
github.com/censys/censys-sdk-go v0.19.1/go.mod h1:DgPz5NgL+EfoueXLPG9UG1e7hS0OhtlywgpkIuu3ZRE=
@@ -306,8 +302,7 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE=
github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -329,12 +324,12 @@ github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYC
github.com/dlclark/regexp2 v1.4.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
github.com/dlclark/regexp2/v2 v2.2.1/go.mod h1:avUrQvPaLz2DrFNHJF0taWAFFX2C1GMSSoeiqFjcBmU=
github.com/docker/cli v29.2.0+incompatible h1:9oBd9+YM7rxjZLfyMGxjraKBKE4/nVyvVfN4qNl9XRM=
github.com/docker/cli v29.2.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c=
github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4=
@@ -374,10 +369,10 @@ github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
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.26.1 h1:+w4rnLGNlA2GDVn382Tfe3jOsK5vOr5n4KmigJ9lbTo=
github.com/gaissmai/bart v0.26.1/go.mod h1:GREWQfTLRWz/c5FTOsIw+KkscuFkIV5t8Rp7Nd1Td5c=
github.com/geoffgarside/ber v1.1.0 h1:qTmFG4jJbwiSzSXoNJeHcOprVzZ8Ulde2Rrrifu5U9w=
github.com/geoffgarside/ber v1.1.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc=
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/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/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
@@ -406,8 +401,8 @@ github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
github.com/go-kit/log v0.2.1/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
github.com/go-ldap/ldap/v3 v3.4.11 h1:4k0Yxweg+a3OyBLjdYn5OKglv18JNvfDykSoI8bW0gU=
github.com/go-ldap/ldap/v3 v3.4.11/go.mod h1:bY7t0FLK8OAVpp/vV6sSlpz3EQDGcQwc8pF0ujLgKvM=
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
@@ -464,14 +459,15 @@ github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gocolly/colly v1.2.0/go.mod h1:Hof5T3ZswNVsOHYmba1u03W65HDWgpV5HifSuueE0EA=
github.com/gocolly/colly/v2 v2.1.0/go.mod h1:I2MuhsLjQ+Ex+IzK3afNS8/1qP3AedHOusRPcRdC5o0=
github.com/gocolly/colly/v2 v2.3.0 h1:HSFh0ckbgVd2CSGRE+Y/iA4goUhGROJwyQDCMXGFBWM=
github.com/gocolly/colly/v2 v2.3.0/go.mod h1:Qp54s/kQbwCQvFVx8KzKCSTXVJ1wWT4QeAKEu33x1q8=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gofrs/uuid v3.3.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
@@ -536,7 +532,6 @@ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-github v17.0.0+incompatible h1:N0LgJ1j65A7kfXrZnUDaYCs/Sf4rEjNlfyDHW9dolSY=
github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ=
github.com/google/go-github/v30 v30.1.0 h1:VLDx+UolQICEOKu2m4uAoMti1SxuEBAl7RSEG16L+Oo=
github.com/google/go-github/v30 v30.1.0/go.mod h1:n8jBpHl45a/rlBUtRJMOG4GhNADUQFEufcolZ95JfU8=
@@ -571,13 +566,15 @@ github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORR
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/gorilla/sessions v1.4.0 h1:kpIYOp/oi6MG/p5PgxApU8srsSw9tuFbt46Lt7auzqQ=
github.com/gorilla/sessions v1.4.0/go.mod h1:FLWm50oby91+hl7p/wRxDth9bWSuk0qVL2emc7lT5ik=
github.com/gosimple/slug v1.15.0 h1:wRZHsRrRcs6b0XnxMUBM6WK1U1Vg5B0R7VkIf1Xzobo=
github.com/gosimple/slug v1.15.0/go.mod h1:UiRaFH+GEilHstLUmcBgWcI42viBN7mAb818JrYOeFQ=
github.com/gosimple/unidecode v1.0.1 h1:hZzFTMMqSswvf0LBJZCZgThIZrpDHFXux9KeGmn6T/o=
github.com/gosimple/unidecode v1.0.1/go.mod h1:CP0Cr1Y1kogOtx0bJblKzsVWrqYaqfNOnHzpgWw4Awc=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg=
github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
@@ -610,10 +607,10 @@ github.com/ianlancetaylor/demangle v0.0.0-20230524184225-eabc099b10ab/go.mod h1:
github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4=
github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY=
github.com/indece-official/go-ebcdic v1.2.0 h1:nKCubkNoXrGvBp3MSYuplOQnhANCDEY512Ry5Mwr4a0=
github.com/indece-official/go-ebcdic v1.2.0/go.mod h1:RBddVJt0Ks0eDLRG5dhPwBDRiTNA7n+yv0dVFpSs46Q=
github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=
github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=
github.com/invopop/yaml v0.3.1 h1:f0+ZpmhfBSS4MhG+4HYseMdJhoeeopbSKbq5Rpeelso=
github.com/invopop/yaml v0.3.1/go.mod h1:PMOp3nn4/12yEZUFfmOuNHJsZToEEOwoWsT+D81KkeA=
github.com/itchyny/gojq v0.12.17 h1:8av8eGduDb5+rvEdaOO+zQUjA04MS0m3Ps8HiD+fceg=
github.com/itchyny/gojq v0.12.17/go.mod h1:WBrEMkgAfAGO1LUcGOckBl5O726KPp+OlkKug0I/FEY=
github.com/itchyny/timefmt-go v0.1.6 h1:ia3s54iciXDdzWzwaVKXZPbiXzxxnv1SPGFfM/myJ5Q=
@@ -667,13 +664,12 @@ github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
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.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
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/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=
@@ -692,10 +688,6 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
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=
@@ -724,10 +716,13 @@ github.com/mackerelio/go-osstat v0.2.6 h1:gs4U8BZeS1tjrL08tt5VUliVvSWP26Ai2Ob8Lr
github.com/mackerelio/go-osstat v0.2.6/go.mod h1:lRy8V9ZuHpuRVZh+vyTkODeDPl3/d5MgXHtLSaqG8bA=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ=
github.com/mattn/go-runewidth v0.0.17/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
@@ -760,14 +755,10 @@ github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/moby/api v1.53.0 h1:PihqG1ncw4W+8mZs69jlwGXdaYBeb5brF6BL7mPIS/w=
github.com/moby/moby/api v1.53.0/go.mod h1:8mb+ReTlisw4pS6BRzCMts5M49W5M7bKt1cJy/YbAqc=
github.com/moby/moby/client v0.2.2 h1:Pt4hRMCAIlyjL3cr8M5TrXCwKzguebPAc2do2ur7dEM=
github.com/moby/moby/client v0.2.2/go.mod h1:2EkIPVNCqR05CMIzL1mfA07t0HvVUUOl85pasRz/GmQ=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/moby/api v1.54.2 h1:wiat9QAhnDQjA7wk1kh/TqHz2I1uUA7M7t9SAl/JNXg=
github.com/moby/moby/api v1.54.2/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
github.com/moby/moby/client v0.4.1 h1:DMQgisVoMkmMs7fp3ROSdiBnoAu8+vo3GggFl06M/wY=
github.com/moby/moby/client v0.4.1/go.mod h1:z52C9O2POPOsnxZAy//WtKcQ32P+jT/NGeXu/7nfjGQ=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
@@ -783,8 +774,6 @@ github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9
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/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/mreiferson/go-httpclient v0.0.0-20160630210159-31f0106b4474/go.mod h1:OQA4XLvDbMgS8P0CevmM4m9Q3Jq4phKUzcocxuGJ5m8=
github.com/mreiferson/go-httpclient v0.0.0-20201222173833-5e475fde3a4d/go.mod h1:OQA4XLvDbMgS8P0CevmM4m9Q3Jq4phKUzcocxuGJ5m8=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
@@ -803,6 +792,12 @@ github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//J
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/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=
github.com/oiweiwei/go-smb2.fork v1.0.0/go.mod h1:h0CzLVvGAmq39izdYVHKyI5cLv6aHdbQAMKEe4dz4N8=
github.com/oiweiwei/gokrb5.fork/v9 v9.0.6 h1:ZMXO5OtzPPSqZ7KPgknVuvHE5iAbSXq5JLgzrkiXknM=
github.com/oiweiwei/gokrb5.fork/v9 v9.0.6/go.mod h1:KEnkAYUYqZ5VwzxLFbv3JHlRhCvdFahjrdjjssMJJkI=
github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/ll v0.0.9 h1:Y+1YqDfVkqMWuEQMclsF9HUR5+a82+dxJuL1HHSRpxI=
@@ -821,8 +816,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/opencontainers/runc v1.2.8 h1:RnEICeDReapbZ5lZEgHvj7E9Q3Eex9toYmaGBsbvU5Q=
github.com/opencontainers/runc v1.2.8/go.mod h1:cC0YkmZcuvr+rtBZ6T7NBoVbMGNAdLa/21vIElJDOzI=
github.com/opencontainers/runc v1.3.6 h1:SLGIymCtsk80iNPWgbc8dtjI30r+5mTVV+4dN8/17Sk=
github.com/opencontainers/runc v1.3.6/go.mod h1:o1wyv76EDlTkcf0KTFgN8bMWLPvgF/HfX709lDv+rr4=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/ory/dockertest/v3 v3.12.0 h1:3oV9d0sDzlSQfHtIaB5k6ghUCVMVLpAY8hwrqoCyRCw=
github.com/ory/dockertest/v3 v3.12.0/go.mod h1:aKNDTva3cp8dwOWwb9cWuX84aH5akkxXRvO7KCwWVjE=
@@ -851,14 +846,14 @@ 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.31 h1:8iD/MLDdMdMziM3RA5FkjUxO6kIwwgAoxWaL6RBIIl0=
github.com/projectdiscovery/cdncheck v1.2.31/go.mod h1:6/B6caF1+97hR9cICMlzIYR8hpAN/y3AlJPHI2q48PQ=
github.com/projectdiscovery/clistats v0.1.1 h1:8mwbdbwTU4aT88TJvwIzTpiNeow3XnAB72JIg66c8wE=
github.com/projectdiscovery/clistats v0.1.1/go.mod h1:4LtTC9Oy//RiuT1+76MfTg8Hqs7FQp1JIGBM3nHK6a0=
github.com/projectdiscovery/dsl v0.8.14 h1:g9szcXk2RRdVf2rsHEzbTXOPxiny3haKonSncU6pg2w=
github.com/projectdiscovery/dsl v0.8.14/go.mod h1:LYImt/EiBzqTWG1RswT3Yl0DZbfjUP93Nvq2Z/G7dcE=
github.com/projectdiscovery/fastdialer v0.5.6 h1:kIBFmzbXrua41uf4fGsQClTZmT7cm7E3vVgcSj8gs6Q=
github.com/projectdiscovery/fastdialer v0.5.6/go.mod h1:QxvCe02Jii+j8vA3hWYkymgZIY8cqMgs2s3Jbz6mvbs=
github.com/projectdiscovery/cdncheck v1.2.42 h1:Y1Q9MPq7uuv25+aGlgjA5nToOcsk+9gNEKjicyhIwQI=
github.com/projectdiscovery/cdncheck v1.2.42/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.11 h1:eI7jfwz0i73Ot1cowIBezQLxbg0i6INdAsFGJjfwPa0=
github.com/projectdiscovery/fastdialer v0.5.11/go.mod h1:W1ZkULr9mMR6i0oRFTztANnpVyEEzPUovK8sUM4eAw8=
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=
@@ -869,14 +864,20 @@ github.com/projectdiscovery/go-smb2 v0.0.0-20240129202741-052cc450c6cb h1:rutG90
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/gologger v1.1.68 h1:KfdIO/3X7BtHssWZuqhxPZ+A946epCCx2cz+3NnRAnU=
github.com/projectdiscovery/gologger v1.1.68/go.mod h1:Xae0t4SeqJVa0RQGK9iECx/+HfXhvq70nqOQp2BuW+o=
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=
github.com/projectdiscovery/goja_nodejs v0.0.0-20260618132410-8519f75f703d/go.mod h1:Ezmbgdaw4EunGGBU4MQViLoGMJc37LA3ip55YV3KeRI=
github.com/projectdiscovery/gologger v1.1.71 h1:IYU4mw9viKdSzMTIGVpYuw1Gtg7QIHIStqAQgeNXcBQ=
github.com/projectdiscovery/gologger v1.1.71/go.mod h1:mJwODZcFDg70ihINpOvZevmBtgvpP8H9/l8Y+OPhZPY=
github.com/projectdiscovery/gostruct v0.0.2 h1:s8gP8ApugGM4go1pA+sVlPDXaWqNP5BBDDSv7VEdG1M=
github.com/projectdiscovery/gostruct v0.0.2/go.mod h1:H86peL4HKwMXcQQtEa6lmC8FuD9XFt6gkNR0B/Mu5PE=
github.com/projectdiscovery/gozero v0.1.1-0.20251027191944-a4ea43320b81 h1:yHh46pJovYbyiaHCV7oIDinFmy+Fyq36H1BowJgb0M0=
github.com/projectdiscovery/gozero v0.1.1-0.20251027191944-a4ea43320b81/go.mod h1:9lmGPBDGZVANzCGjQg+V32n8Y3Cgjo/4kT0E88lsVTI=
github.com/projectdiscovery/hmap v0.0.100 h1:DBZ3Req9lWf4P1YC9PRa4eiMvLY0Uxud43NRBcocPfs=
github.com/projectdiscovery/hmap v0.0.100/go.mod h1:2O06pR8pHOP9wSmxAoxuM45U7E+UqOqOdlSIeddM0bA=
github.com/projectdiscovery/govaluate v0.0.0-20260615100919-5ee2581bbf7e h1:vxzgQlz2Cy/YvizYDQx9OhucBcmBotfDhbQ4yCY2vfA=
github.com/projectdiscovery/govaluate v0.0.0-20260615100919-5ee2581bbf7e/go.mod h1:xH7bPwHxUlz1yx9UlVeTF+UVCUaKhTnZgaxHb5z362E=
github.com/projectdiscovery/gozero v0.1.1-0.20260530071156-fa1dad563d76 h1:AN70bbi6BBs7KpIM9w0LxygUN7uzT/oH+owDIQ+Fz/k=
github.com/projectdiscovery/gozero v0.1.1-0.20260530071156-fa1dad563d76/go.mod h1:cWHYnRXoYWHtTpOYyAp5laGYX8GH8ITUhgQaP8G/8FA=
github.com/projectdiscovery/hmap v0.0.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/interactsh v1.3.1 h1:5HzeVGVCAX/cjTguJ+7ClOmML5r97Ty7op9s+/F7BiM=
@@ -889,34 +890,34 @@ github.com/projectdiscovery/mapcidr v1.1.97 h1:7FkxNNVXp+m1rIu5Nv/2SrF9k4+LwP8Qu
github.com/projectdiscovery/mapcidr v1.1.97/go.mod h1:9dgTJh1SP02gYZdpzMjm6vtYFkEHQHoTyaVNvaeJ7lA=
github.com/projectdiscovery/n3iwf v0.0.0-20230523120440-b8cd232ff1f5 h1:L/e8z8yw1pfT6bg35NiN7yd1XKtJap5Nk6lMwQ0RNi8=
github.com/projectdiscovery/n3iwf v0.0.0-20230523120440-b8cd232ff1f5/go.mod h1:pGW2ncnTxTxHtP9wzcIJAB+3/NMp6IiuQWd2NK7K+oc=
github.com/projectdiscovery/networkpolicy v0.1.36 h1:88EAYvEplBmn4vlGKenZJtzsGkEWALX3QzPiY930GtA=
github.com/projectdiscovery/networkpolicy v0.1.36/go.mod h1:lrm+DXxtH0cGpM4OKhILC+9ktnzrXVYcM0S2Jk+gQcc=
github.com/projectdiscovery/nuclei/v3 v3.8.0 h1:UfIDjoHBsvACtvO4x8XIp6COffH+0G4sqco1qrijZqw=
github.com/projectdiscovery/nuclei/v3 v3.8.0/go.mod h1:xBCCFK5nMafAuf3sWyOojzL9pKN91tj4Uwj2TK7HhOM=
github.com/projectdiscovery/ratelimit v0.0.85 h1:TrqYis/+6Djac20n3kgFXQbN/xj7ywObJpH3xDOd+40=
github.com/projectdiscovery/ratelimit v0.0.85/go.mod h1:enLZ8XGL02WPBhuoHAhgvMgOpuU9ALhFpFgCps5lxmM=
github.com/projectdiscovery/networkpolicy v0.1.41 h1:6daf4A8Vj1+iQ7nH2FR4+sOYd7q1WH1qfN61EyQU74c=
github.com/projectdiscovery/networkpolicy v0.1.41/go.mod h1:9ULLaMbdv9UnT0C5rmuK4nIwYs0o776xMnkPUb8TtaE=
github.com/projectdiscovery/nuclei/v3 v3.10.0 h1:hKWXhfqKvpxGa1vFaS0TlkSPY7EZy4VG7ml4JAMJavo=
github.com/projectdiscovery/nuclei/v3 v3.10.0/go.mod h1:pWZNFVtdIHHSWCV6ouGHUPzvdARD+OGN5K9WrVw0plQ=
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/rdap v0.9.1-0.20221108103045-9865884d1917 h1:m03X4gBVSorSzvmm0bFa7gDV4QNSOWPL/fgZ4kTXBxk=
github.com/projectdiscovery/rdap v0.9.1-0.20221108103045-9865884d1917/go.mod h1:JxXtZC9e195awe7EynrcnBJmFoad/BNDzW9mzFkK8Sg=
github.com/projectdiscovery/retryabledns v1.0.114 h1:COyNKzhA7oa3C/1639WRXeXsKrUJx06paVbN64IHZ3E=
github.com/projectdiscovery/retryabledns v1.0.114/go.mod h1:+DyanDr8naxQ2dRO9c4Ezo3NHHXhz8L0tTSRYWhiwyA=
github.com/projectdiscovery/retryablehttp-go v1.3.8 h1:TA075ioaVyaM65R3dSzKSbOCiJSvFrlGScxzScu4ik8=
github.com/projectdiscovery/retryablehttp-go v1.3.8/go.mod h1:/vas835LvB4aqK9vCPGSgKF7Q7hY/BRcIJ/TgM2sPAY=
github.com/projectdiscovery/sarif v0.0.1 h1:C2Tyj0SGOKbCLgHrx83vaE6YkzXEVrMXYRGLkKCr/us=
github.com/projectdiscovery/sarif v0.0.1/go.mod h1:cEYlDu8amcPf6b9dSakcz2nNnJsoz4aR6peERwV+wuQ=
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.16 h1:M/xICwSaiSHhd5OIarU3+5JoU7VmbiSAAwYUCK7CTjw=
github.com/projectdiscovery/retryablehttp-go v1.3.16/go.mod h1:s0azLAqAbcVCjHI9t0ezPhamevYGM1eoOvFkn4QmpZ8=
github.com/projectdiscovery/sarif v0.1.0 h1:O541T+a448nSJsmIMnXXSOeDQEzpnCAYvRfe0eG5h74=
github.com/projectdiscovery/sarif v0.1.0/go.mod h1:LBC+reM3bkI3qIIhE0rZaINaYX6VG+En6u2hHa5mA7E=
github.com/projectdiscovery/stringsutil v0.0.2 h1:uzmw3IVLJSMW1kEg8eCStG/cGbYYZAja8BH3LqqJXMA=
github.com/projectdiscovery/stringsutil v0.0.2/go.mod h1:EJ3w6bC5fBYjVou6ryzodQq37D5c6qbAYQpGmAy+DC0=
github.com/projectdiscovery/tlsx v1.2.2 h1:Y96QBqeD2anpzEtBl4kqNbwzXh2TrzJuXfgiBLvK+SE=
github.com/projectdiscovery/tlsx v1.2.2/go.mod h1:ZJl9F1sSl0sdwE+lR0yuNHVX4Zx6tCSTqnNxnHCFZB4=
github.com/projectdiscovery/uncover v1.2.0 h1:31tjYa0v8FB8Ch8hJTxb+2t63vsljdOo0OSFylJcX4M=
github.com/projectdiscovery/uncover v1.2.0/go.mod h1:ozqKb++p39Kmh1SmwIpbQ9p0aVGPXuwsb4/X2Kvx6ms=
github.com/projectdiscovery/useragent v0.0.107 h1:45gSBda052fv2Gtxtnpx7cu2rWtUpZEQRGAoYGP6F5M=
github.com/projectdiscovery/useragent v0.0.107/go.mod h1:yv5ZZLDT/kq6P+NvBcCPq6sjEVQtZGgO+OvvHzZ+WtY=
github.com/projectdiscovery/utils v0.10.1 h1:9luYfL7PpN1L/cLO4bAES4+ltDaEBKOUnRiTn920XfM=
github.com/projectdiscovery/utils v0.10.1/go.mod h1:x3jGS2YIxnUYxlpB9HWBKf0k+AE83nYCGRX/YStC8G8=
github.com/projectdiscovery/wappalyzergo v0.2.76 h1:6zQt6Jmi/hIwD8InWswkk1yhJGWaVEAEzshTGiTGbeM=
github.com/projectdiscovery/wappalyzergo v0.2.76/go.mod h1:hRsnKNleH693FFJsBOD5NMUDbxw/Q94f0Oq2OV04Q6M=
github.com/projectdiscovery/uncover v1.2.1 h1:8U46T/96CLT7BPoXBgkTvWqB06lOyeTSLvh5+UjzATE=
github.com/projectdiscovery/uncover v1.2.1/go.mod h1:0p8onrWxfpXQEYs90ZDzTSpu1107gWmodX1NWqu/+z4=
github.com/projectdiscovery/useragent v0.0.108 h1:fb+uLuFJvC+MHZjCtxQJxtvp1X6A8n98CUGPyFcg3NE=
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.87 h1:KuUpeRSQ80L6tx9YaAPhfYWTF47bpEURYbAymr76zto=
github.com/projectdiscovery/wappalyzergo v0.2.87/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=
@@ -960,6 +961,8 @@ github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0t
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
github.com/saintfish/chardet v0.0.0-20120816061221-3af4cd4741ca/go.mod h1:uugorj2VCxiV1x+LzaIdVa9b4S4qGAcH6cbhh4qVxOU=
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d h1:hrujxIzL1woJ7AwssoOcM/tq5JjjG2yYOc8odClEiXA=
@@ -984,8 +987,9 @@ github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6Mwd
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM=
@@ -1000,15 +1004,18 @@ github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0b
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/spf13/cast v1.9.2 h1:SsGfm7M8QOFtEzumm7UZrZdLLquNdzFYfIbEXntcFbE=
github.com/spf13/cast v1.9.2/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
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/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.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
@@ -1037,8 +1044,8 @@ github.com/tidwall/btree v1.8.1/go.mod h1:jBbTdUWhSZClZWoDg54VnvV7/54modSOzDN7VX
github.com/tidwall/buntdb v1.3.2 h1:qd+IpdEGs0pZci37G4jF51+fSKlkuUTMXuHhXL1AkKg=
github.com/tidwall/buntdb v1.3.2/go.mod h1:lZZrZUWzlyDJKlLQ6DKAy53LnG7m5kHyrEHvvcDmBpU=
github.com/tidwall/gjson v1.12.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
github.com/tidwall/grect v0.1.4 h1:dA3oIgNgWdSspFzn1kS4S/RDpZFLrIxAZOdJKjYapOg=
github.com/tidwall/grect v0.1.4/go.mod h1:9FBsaYRaR0Tcy4UwefBX/UDcDcDy9V5jUcxHzv2jd5Q=
github.com/tidwall/lotsa v1.0.2 h1:dNVBH5MErdaQ/xd9s769R31/n2dXavsQ0Yf4TMEHHw8=
@@ -1075,8 +1082,6 @@ github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/vmihailenco/bufpool v0.1.11 h1:gOq2WmBrq0i2yW5QJ16ykccQ4wH9UyEsgLm6czKAd94=
github.com/vmihailenco/bufpool v0.1.11/go.mod h1:AFf/MOy3l2CFTKbxwt0mp2MwnqjNEs5H/UxrkA5jxTQ=
github.com/vmihailenco/msgpack/v5 v5.3.4 h1:qMKAwOV+meBw2Y8k9cVwAy7qErtYCwBzZ2ellBfvnqc=
@@ -1138,7 +1143,6 @@ github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
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.2.1/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=
@@ -1168,8 +1172,8 @@ github.com/zmap/zflags v1.4.0-beta.1.0.20200204220219-9d95409821b6/go.mod h1:HXD
github.com/zmap/zgrab2 v0.1.8 h1:PFnXrIBcGjYFec1JNbxMKQuSXXzS+SbqE89luuF4ORY=
github.com/zmap/zgrab2 v0.1.8/go.mod h1:5d8HSmUwvllx4q1qG50v/KXphkg45ZzWdaQtgTFnegE=
github.com/zmap/zlint/v3 v3.0.0/go.mod h1:paGwFySdHIBEMJ61YjoqT4h7Ge+fdYG4sUQhnTb1lJ8=
gitlab.com/gitlab-org/api/client-go v0.130.1 h1:1xF5C5Zq3sFeNg3PzS2z63oqrxifne3n/OnbI7nptRc=
gitlab.com/gitlab-org/api/client-go v0.130.1/go.mod h1:ZhSxLAWadqP6J9lMh40IAZOlOxBLPRh7yFOXR/bMJWM=
gitlab.com/gitlab-org/api/client-go v1.9.1 h1:tZm+URa36sVy8UCEHQyGGJ8COngV4YqMHpM6k9O5tK8=
gitlab.com/gitlab-org/api/client-go v1.9.1/go.mod h1:71yTJk1lnHCWcZLvM5kPAXzeJ2fn5GjaoV8gTOPd4ME=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.mongodb.org/mongo-driver v1.17.9 h1:IexDdCuuNJ3BHrELgBlyaH9p60JXAvdzWR128q+U5tU=
@@ -1181,24 +1185,18 @@ go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 h1:Hf9xI/XLML9ElpiHVDNwvqI0hIFlzV8dgIr35kV1kRU=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY=
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0 h1:OeNbIYk/2C15ckl7glBlOBp5+WlYsOElzTNmiPW/x60=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.34.0/go.mod h1:7Bept48yIeqxP2OZ9/AqIpYS94h2or0aB4FypJTc8ZM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4=
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc=
go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps=
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
go.opentelemetry.io/proto/otlp v1.8.0 h1:fRAZQDcAFHySxpJ1TwlA1cJ4tvcrw7nXl9xWWC8N5CE=
go.opentelemetry.io/proto/otlp v1.8.0/go.mod h1:tIeYOeNBU4cvmPqpaji1P+KbB4Oloai8wN4rWzRrFF0=
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/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.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=
@@ -1252,8 +1250,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/exp v0.0.0-20260527015227-08cc5374adb3 h1:VHEvKbpgPXcPXn40t9cDTGK3JZwMikIEyF/CTrFfu7k=
golang.org/x/exp v0.0.0-20260527015227-08cc5374adb3/go.mod h1:d2fgXJLVs4dYDHUk5lwMIfzRzSrWCfGZb0ZqeLa/Vcw=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -1316,7 +1314,6 @@ golang.org/x/net v0.0.0-20200602114024-627f9648deb9/go.mod h1:qpuaurCH72eLCgpAm/
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
@@ -1359,7 +1356,6 @@ golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -1424,6 +1420,7 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220615213510-4f61da869c0c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -1481,8 +1478,8 @@ golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxb
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -1522,11 +1519,9 @@ golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roY
golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
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=
@@ -1591,11 +1586,6 @@ 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 v0.0.0-20250122153221-138b5a5a4fd4 h1:Pw6WnI9W/LIdRxqK7T6XGugGbHIRl5Q7q3BssH6xk4s=
google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb h1:p31xT4yrYrSM/G4Sn2+TNUkVhFCbG9y8itM2S6Th950=
google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:jbe3Bkdp+Dh2IrslsFCklNhweNTBgSYanP1UXhJDhKg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb h1:TLPQVbx1GJ8VKZxz52VAxl1EBgKXXbTiU9Fc5fZeLn4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I=
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=
@@ -1608,8 +1598,6 @@ 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.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
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=
@@ -1626,8 +1614,8 @@ google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqw
google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/alecthomas/kingpin.v2 v2.2.6 h1:jMFz6MfLP0/4fUyZle81rXUoxOBFi19VUFKVDOQfozc=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -1650,7 +1638,6 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
@@ -1668,6 +1655,10 @@ mellium.im/sasl v0.3.2 h1:PT6Xp7ccn9XaXAnJ03FcEjmAn7kK1x7aoXV6F+Vmrl0=
mellium.im/sasl v0.3.2/go.mod h1:NKXDi1zkr+BlMHLQjY3ofYuU4KSPFxknb8mfEu6SveY=
moul.io/http2curl v1.0.0 h1:6XwpyZOYsgZJrU8exnG87ncVkU1FVCcTRpwzOkTDUi8=
moul.io/http2curl v1.0.0/go.mod h1:f6cULg+e4Md/oW1cYmwW4IWQOVl2lGbmCNGOHvzX2kE=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
software.sslmate.com/src/go-pkcs12 v0.7.0 h1:Db8W44cB54TWD7stUFFSWxdfpdn6fZVcDl0w3R4RVM0=
software.sslmate.com/src/go-pkcs12 v0.7.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI=
+35 -8
View File
@@ -13,6 +13,7 @@
package config
import (
"os"
"time"
"github.com/charmbracelet/log"
@@ -26,7 +27,7 @@ type Settings struct {
DirFilterSizes string // -fs dirlist: body sizes to drop
DirFilterWords string // -fw dirlist: word counts to drop
DirFilterRegex string // -fr dirlist: regex; body match drops response
DirCalibrate bool // -ac dirlist: auto-calibrate soft-404 baseline
Calibrate bool // -ac auto-calibrate the soft-404 baseline (dirlist, sql)
DirWordlist string // -w dirlist: custom wordlist (file path or url)
DirExtensions string // -e dirlist: extensions appended to each word
Dnslist string
@@ -79,6 +80,7 @@ type Settings struct {
Header goflags.StringSlice // custom request headers ("Key: Value")
Cookie string
RateLimit int
MaxRetries int // -max-retries: retries on 429/503 (0 = off)
Notify bool // -notify: ship findings to configured providers
NotifySeverity string // -notify-severity: minimum severity to send (info..critical)
NotifyConfig string // -notify-config: path to a notify-compatible yaml file
@@ -110,9 +112,9 @@ const (
Full
)
func Parse() *Settings {
settings := &Settings{}
// registerFlags builds the flag set for the given settings without parsing it,
// so callers (Parse and tests) can inspect the registered flags.
func registerFlags(settings *Settings) *goflags.FlagSet {
flagSet := goflags.NewFlagSet()
flagSet.SetDescription("a blazing-fast pentesting (recon/exploitation) suite")
@@ -130,7 +132,7 @@ func Parse() *Settings {
flagSet.StringVar(&settings.DirFilterSizes, "fs", "", "Dirlist: filter out responses of these body sizes (comma list)"),
flagSet.StringVar(&settings.DirFilterWords, "fw", "", "Dirlist: filter out responses with these word counts (comma list)"),
flagSet.StringVar(&settings.DirFilterRegex, "fr", "", "Dirlist: filter out responses whose body matches this regex"),
flagSet.BoolVar(&settings.DirCalibrate, "ac", false, "Dirlist: auto-calibrate the soft-404 wildcard baseline"),
flagSet.BoolVar(&settings.Calibrate, "ac", false, "Auto-calibrate the soft-404 wildcard baseline (dirlist, sql)"),
flagSet.StringVar(&settings.DirWordlist, "w", "", "Dirlist: custom wordlist (local file path or url; overrides -dirlist size)"),
flagSet.StringVar(&settings.DirExtensions, "e", "", "Dirlist: extensions appended to each word (comma list, e.g. php,bak,env)"),
flagSet.EnumVar(&settings.Dnslist, "dnslist", Nil, "DNS fuzzing scan size (small/medium/large)", listSizes),
@@ -169,7 +171,7 @@ func Parse() *Settings {
flagSet.DurationVarP(&settings.Timeout, "timeout", "t", 10*time.Second, "HTTP request timeout"),
flagSet.StringVarP(&settings.LogDir, "log", "l", "", "Directory to store logs in"),
flagSet.IntVar(&settings.Threads, "threads", 10, "Number of threads to run scans on"),
flagSet.StringVar(&settings.Template, "template", "", "Sif runtime template to use"),
flagSet.StringVar(&settings.Template, "template", "", "Load scan settings from a template (preset minimal/recon/full, or a local yaml file)"),
)
flagSet.CreateGroup("http", "HTTP",
@@ -177,6 +179,7 @@ func Parse() *Settings {
flagSet.StringSliceVarP(&settings.Header, "header", "H", nil, "Custom header to send (repeatable or comma-separated, \"Key: Value\")", goflags.CommaSeparatedStringSliceOptions),
flagSet.StringVar(&settings.Cookie, "cookie", "", "Cookie header to send with every request"),
flagSet.IntVar(&settings.RateLimit, "rate-limit", 0, "Max requests per second (0 = unlimited)"),
flagSet.IntVar(&settings.MaxRetries, "max-retries", 2, "Retries on 429/503 with Retry-After backoff (0 = off)"),
)
flagSet.CreateGroup("output", "Output",
@@ -204,8 +207,32 @@ func Parse() *Settings {
flagSet.BoolVarP(&settings.ListModules, "list-modules", "lm", false, "List available modules and exit"),
)
if err := flagSet.Parse(); err != nil {
log.Fatalf("Could not parse flags: %s", err)
return flagSet
}
func Parse() *Settings {
settings := &Settings{}
flagSet := registerFlags(settings)
// -template presets a batch of scans from a yaml file or named preset; point
// goflags at it before Parse so it merges as config (cli flags still win) and
// replaces the ambient config for this run.
templatePath, cleanup, err := templateConfigPath(os.Args[1:])
if err != nil {
log.Fatalf("Could not load template: %s", err)
}
if templatePath != "" {
flagSet.SetConfigFilePath(templatePath)
}
// Parse merges the template config synchronously, so a temp preset file can
// be removed right after, before any fatal exit (no leaking defer).
parseErr := flagSet.Parse()
if cleanup != nil {
cleanup()
}
if parseErr != nil {
log.Fatalf("Could not parse flags: %s", parseErr)
}
// threads feeds wg.Add directly; floor it so 0 isn't a silent no-op and a
+116
View File
@@ -0,0 +1,116 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package config
import (
"embed"
"fmt"
"os"
"strings"
)
//go:embed templates/*.yaml
var embeddedTemplates embed.FS
// presetNames are the templates shipped in the binary, listed in help and
// error text. each presets a batch of scans without listing every flag.
var presetNames = []string{"minimal", "recon", "full"}
// templateConfigPath resolves the -template value into a config file path for
// goflags to merge, plus a cleanup to run after Parse (embedded presets are
// written to a temp file). it returns "" when -template is unset.
func templateConfigPath(args []string) (string, func(), error) {
value := templateFlagValue(args)
if value == "" {
return "", nil, nil
}
return resolveTemplate(value)
}
// templateFlagValue pulls the -template value out of raw args; the config path
// has to be known before Parse, so it cannot come from the parsed flag itself.
func templateFlagValue(args []string) string {
for i, arg := range args {
if arg == "-template" || arg == "--template" {
if i+1 < len(args) {
return args[i+1]
}
return ""
}
if v, ok := strings.CutPrefix(arg, "-template="); ok {
return v
}
if v, ok := strings.CutPrefix(arg, "--template="); ok {
return v
}
}
return ""
}
// resolveTemplate turns the -template value into a config file path. an existing
// local file wins; a named preset is materialized from the embedded set; a
// path-shaped miss or an unknown name is a hard error.
func resolveTemplate(value string) (string, func(), error) {
info, err := os.Stat(value) //nolint:gosec // G304: user-supplied local template path, by design (same as the -f/-w wordlist paths)
switch {
case err == nil && info.IsDir():
return "", nil, fmt.Errorf("template path %q is a directory", value)
case err == nil:
return value, nil, nil
}
if data, ok := embeddedPreset(value); ok {
return materializePreset(data)
}
if looksLikePath(value) {
return "", nil, fmt.Errorf("template file %q not found", value)
}
return "", nil, fmt.Errorf("unknown template %q; use a local yaml file or one of: %s",
value, strings.Join(presetNames, ", "))
}
// embeddedPreset returns the bytes of a named preset shipped in the binary.
func embeddedPreset(name string) ([]byte, bool) {
data, err := embeddedTemplates.ReadFile("templates/" + name + ".yaml")
if err != nil {
return nil, false
}
return data, true
}
// materializePreset writes preset bytes to a temp file so goflags, which merges
// a config by path, can read it; the cleanup removes the file after Parse.
func materializePreset(data []byte) (string, func(), error) {
file, err := os.CreateTemp("", "sif-template-*.yaml")
if err != nil {
return "", nil, err
}
cleanup := func() { _ = os.Remove(file.Name()) }
if _, err := file.Write(data); err != nil {
cleanup()
return "", nil, err
}
if err := file.Close(); err != nil {
cleanup()
return "", nil, err
}
return file.Name(), cleanup, nil
}
// looksLikePath reports whether the value addresses a file rather than a named
// preset: a path separator or a yaml suffix marks a file.
func looksLikePath(value string) bool {
if strings.ContainsAny(value, `/\`) {
return true
}
return strings.HasSuffix(value, ".yaml") || strings.HasSuffix(value, ".yml")
}
+211
View File
@@ -0,0 +1,211 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package config
import (
"flag"
"os"
"path/filepath"
"testing"
"github.com/projectdiscovery/goflags"
"gopkg.in/yaml.v3"
)
// writeTemplate drops a yaml template in a temp dir and returns its path.
func writeTemplate(t *testing.T, body string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "tmpl.yaml")
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write template: %s", err)
}
return path
}
// loadPreset registers the real flags, merges the named embedded preset, and
// returns the resulting settings (no cli scan flags set).
func loadPreset(t *testing.T, name string) *Settings {
t.Helper()
goflags.DisableAutoConfigMigration = true
path, cleanup, err := resolveTemplate(name)
if err != nil {
t.Fatalf("resolve %s: %s", name, err)
}
if cleanup != nil {
defer cleanup()
}
settings := &Settings{}
flagSet := registerFlags(settings)
flagSet.SetConfigFilePath(path)
if err := flagSet.Parse("-silent"); err != nil {
t.Fatalf("parse %s: %s", name, err)
}
return settings
}
// every key in an embedded preset must be a real flag long-name. goflags drops
// unknown config keys silently, so a typo would otherwise ship as a dead no-op.
func TestPresetKeysAreRegisteredFlags(t *testing.T) {
valid := map[string]bool{}
registerFlags(&Settings{}).CommandLine.VisitAll(func(f *flag.Flag) {
valid[f.Name] = true
})
for _, name := range presetNames {
data, ok := embeddedPreset(name)
if !ok {
t.Errorf("preset %q is not embedded", name)
continue
}
var keys map[string]any
if err := yaml.Unmarshal(data, &keys); err != nil {
t.Errorf("preset %q is not valid yaml: %s", name, err)
continue
}
for key := range keys {
if !valid[key] {
t.Errorf("preset %q references unknown flag %q", name, key)
}
}
}
}
func TestPresetMinimal(t *testing.T) {
s := loadPreset(t, "minimal")
if !s.Probe || !s.Headers || !s.Favicon {
t.Errorf("minimal should enable probe/headers/favicon, got probe=%v headers=%v favicon=%v",
s.Probe, s.Headers, s.Favicon)
}
if s.XSS || s.SQL || s.Nuclei {
t.Error("minimal should not enable heavy or intrusive scans")
}
}
func TestPresetReconIsNonIntrusive(t *testing.T) {
s := loadPreset(t, "recon")
if !s.Passive || !s.Whois || !s.CMS || !s.Probe {
t.Errorf("recon should enable passive/whois/cms/probe, got %v/%v/%v/%v",
s.Passive, s.Whois, s.CMS, s.Probe)
}
if s.XSS || s.SQL || s.LFI || s.Redirect {
t.Errorf("recon must not enable payload-injecting scans: xss=%v sql=%v lfi=%v redirect=%v",
s.XSS, s.SQL, s.LFI, s.Redirect)
}
}
func TestPresetFull(t *testing.T) {
s := loadPreset(t, "full")
if !s.XSS || !s.SQL || !s.LFI || !s.Redirect {
t.Error("full should enable the intrusive scans")
}
if s.Dirlist != "large" || s.Ports != "full" {
t.Errorf("full should set dirlist=large ports=full, got dirlist=%q ports=%q",
s.Dirlist, s.Ports)
}
}
// the template merges as the goflags config: it fills flags left at their
// default, an explicit cli flag still wins, and an untouched flag stays put.
func TestTemplateConfigPrecedence(t *testing.T) {
goflags.DisableAutoConfigMigration = true
tmpl := writeTemplate(t, "cms: true\nthreads: 99\n")
var cms, sql bool
var threads int
flagSet := goflags.NewFlagSet()
flagSet.BoolVar(&cms, "cms", false, "")
flagSet.BoolVar(&sql, "sql", false, "")
flagSet.IntVar(&threads, "threads", 10, "")
flagSet.SetConfigFilePath(tmpl)
if err := flagSet.Parse("-threads", "5"); err != nil {
t.Fatalf("parse: %s", err)
}
if !cms {
t.Error("expected template to set cms=true")
}
if threads != 5 {
t.Errorf("expected cli threads 5 to win over template, got %d", threads)
}
if sql {
t.Error("expected sql left untouched to stay false")
}
}
func TestTemplateFlagValue(t *testing.T) {
cases := []struct {
name string
args []string
want string
}{
{"long with space", []string{"-template", "a.yaml"}, "a.yaml"},
{"double dash with space", []string{"--template", "b.yaml"}, "b.yaml"},
{"long with equals", []string{"-template=c.yaml"}, "c.yaml"},
{"double dash with equals", []string{"--template=d.yaml"}, "d.yaml"},
{"absent", []string{"-u", "x"}, ""},
{"trailing without value", []string{"-u", "x", "-template"}, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := templateFlagValue(tc.args); got != tc.want {
t.Errorf("expected %q, got %q", tc.want, got)
}
})
}
}
func TestResolveTemplateExistingFile(t *testing.T) {
path := writeTemplate(t, "cms: true\n")
got, cleanup, err := resolveTemplate(path)
if cleanup != nil {
defer cleanup()
}
if err != nil {
t.Fatalf("resolveTemplate: %s", err)
}
if got != path {
t.Errorf("expected %q, got %q", path, got)
}
}
func TestResolveTemplateNamedPreset(t *testing.T) {
path, cleanup, err := resolveTemplate("recon")
if cleanup != nil {
defer cleanup()
}
if err != nil {
t.Fatalf("recon preset should resolve: %s", err)
}
if path == "" {
t.Fatal("expected a materialized preset path")
}
}
func TestResolveTemplateMissingFile(t *testing.T) {
if _, _, err := resolveTemplate("./does-not-exist.yaml"); err == nil {
t.Fatal("expected an error for a missing template file")
}
}
func TestResolveTemplateDirectory(t *testing.T) {
if _, _, err := resolveTemplate(t.TempDir()); err == nil {
t.Fatal("expected an error for a directory")
}
}
func TestResolveTemplateUnknownName(t *testing.T) {
if _, _, err := resolveTemplate("bogus"); err == nil {
t.Fatal("expected an error for an unknown template name")
}
}
+28
View File
@@ -0,0 +1,28 @@
# full: thorough and active, including intrusive probes that inject payloads
# (xss, sql, lfi, redirect). api-key scans (shodan, securitytrails) stay opt-in
# via their own flags.
passive: true
whois: true
dork: true
favicon: true
headers: true
security-headers: true
cms: true
framework: true
probe: true
git: true
js: true
nuclei: true
openapi: true
cors: true
jwt: true
c3: true
st: true
crawl: true
sql: true
lfi: true
xss: true
redirect: true
dirlist: large
dnslist: large
ports: full
+4
View File
@@ -0,0 +1,4 @@
# minimal: fast liveness + fingerprint, a handful of benign GETs per target.
probe: true
headers: true
favicon: true
+11
View File
@@ -0,0 +1,11 @@
# recon: broad non-intrusive discovery (light traffic, no attack payloads).
passive: true
whois: true
dork: true
favicon: true
headers: true
security-headers: true
cms: true
framework: true
probe: true
dnslist: small
+4 -4
View File
@@ -21,11 +21,11 @@ import (
"fmt"
"strings"
"github.com/dropalldatabases/sif/internal/modules"
"github.com/dropalldatabases/sif/internal/scan"
"github.com/dropalldatabases/sif/internal/scan/frameworks"
"github.com/dropalldatabases/sif/internal/scan/js"
"github.com/projectdiscovery/nuclei/v3/pkg/output"
"github.com/vmfunc/sif/internal/modules"
"github.com/vmfunc/sif/internal/scan"
"github.com/vmfunc/sif/internal/scan/frameworks"
"github.com/vmfunc/sif/internal/scan/js"
)
// Finding is the normalized shape every scanner result collapses to. one
+4 -4
View File
@@ -16,13 +16,13 @@ import (
"strings"
"testing"
"github.com/dropalldatabases/sif/internal/modules"
"github.com/dropalldatabases/sif/internal/scan"
"github.com/dropalldatabases/sif/internal/scan/frameworks"
"github.com/dropalldatabases/sif/internal/scan/js"
"github.com/projectdiscovery/nuclei/v3/pkg/model"
"github.com/projectdiscovery/nuclei/v3/pkg/model/types/severity"
"github.com/projectdiscovery/nuclei/v3/pkg/output"
"github.com/vmfunc/sif/internal/modules"
"github.com/vmfunc/sif/internal/scan"
"github.com/vmfunc/sif/internal/scan/frameworks"
"github.com/vmfunc/sif/internal/scan/js"
)
// scanResultType mirrors the minimal interface the scan packages implement; the
+56
View File
@@ -0,0 +1,56 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
// Package fingerprint holds small response-fingerprinting primitives shared by
// the scan checks and the module engine, so both compute identical values.
package fingerprint
import (
"encoding/base64"
"strings"
"github.com/twmb/murmur3"
)
// b64LineLen is python's base64.encodebytes line width. mmh3/shodan hash the
// chunked base64 (newline every 76 chars, trailing newline), so we must wrap at
// exactly this width to land on the same hash.
const b64LineLen = 76
// FaviconHash computes shodan's favicon hash: murmur3 32-bit over the python
// base64.encodebytes encoding of the raw icon (newline every 76 chars plus a
// trailing newline), reinterpreted as a signed int32 (both load-bearing, golden-pinned).
func FaviconHash(data []byte) int32 {
encoded := encodeFaviconBase64(data)
return int32(murmur3.Sum32(encoded)) //nolint:gosec // shodan stores the signed reinterpretation on purpose
}
// encodeFaviconBase64 mirrors python's base64.encodebytes: standard base64 with
// a newline inserted every 76 output characters and a trailing newline. this is
// the exact byte stream shodan feeds to mmh3, so it must match byte-for-byte.
func encodeFaviconBase64(data []byte) []byte {
raw := base64.StdEncoding.EncodeToString(data)
var b strings.Builder
// final size: the base64 body plus one '\n' per (full or partial) 76-char
// line. preallocate so the builder never regrows mid-loop.
b.Grow(len(raw) + len(raw)/b64LineLen + 1)
for i := 0; i < len(raw); i += b64LineLen {
end := i + b64LineLen
if end > len(raw) {
end = len(raw)
}
b.WriteString(raw[i:end])
b.WriteByte('\n')
}
return []byte(b.String())
}
+68
View File
@@ -0,0 +1,68 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package fingerprint
import (
"strings"
"testing"
)
// goldenFaviconBytes is a fixed payload long enough to span multiple base64
// lines, so the python-style 76-char chunking is actually exercised by the hash.
var goldenFaviconBytes = []byte(strings.Repeat("sif-favicon-golden-test-bytes-", 8))
// goldenFaviconHash is the pinned shodan mmh3 hash of goldenFaviconBytes: the python
// base64.encodebytes byte stream (76-char lines + trailing newline) through murmur3-32,
// reinterpreted as a signed int32. if the chunking or signedness regress, this test fails.
const goldenFaviconHash int32 = -1554620260
// goldenHelloHash pins a short single-line case so a regression in the trailing
// newline (which the small case still has) is caught independently.
const goldenHelloHash int32 = 1155597304
func TestFaviconHashGolden(t *testing.T) {
tests := []struct {
name string
in []byte
want int32
}{
{name: "multi-line fixture", in: goldenFaviconBytes, want: goldenFaviconHash},
{name: "single-line hello", in: []byte("hello"), want: goldenHelloHash},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := FaviconHash(tt.in); got != tt.want {
t.Errorf("FaviconHash = %d, want %d", got, tt.want)
}
})
}
}
// TestFaviconBase64Chunking pins the encode step against python's
// base64.encodebytes: a 60-byte input encodes to 80 base64 chars, so it must
// wrap into two newline-terminated lines.
func TestFaviconBase64Chunking(t *testing.T) {
in := []byte(strings.Repeat("A", 60))
got := string(encodeFaviconBase64(in))
lines := strings.Split(strings.TrimRight(got, "\n"), "\n")
if len(lines) != 2 {
t.Fatalf("expected 2 wrapped lines, got %d: %q", len(lines), got)
}
if len(lines[0]) != b64LineLen {
t.Errorf("first line = %d chars, want %d", len(lines[0]), b64LineLen)
}
if !strings.HasSuffix(got, "\n") {
t.Errorf("encoding must end in a trailing newline, got %q", got)
}
}
+113 -17
View File
@@ -16,11 +16,13 @@
package httpx
import (
"context"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
@@ -74,6 +76,8 @@ type Options struct {
Cookie string
UserAgent string
RateLimit int
// MaxRetries is how many 429/503 responses to retry with backoff (0 = off).
MaxRetries int
// Threads is the scan worker count; it sizes the per-host idle pool so
// concurrent workers hitting one target reuse conns instead of dialing fresh.
Threads int
@@ -107,11 +111,12 @@ func Configure(opts Options) error {
}
rt := &roundTripper{
base: base,
headers: headers,
cookie: opts.Cookie,
userAgent: opts.UserAgent,
limiter: limiter,
base: base,
headers: headers,
cookie: opts.Cookie,
userAgent: opts.UserAgent,
limiter: limiter,
maxRetries: opts.MaxRetries,
}
mu.Lock()
@@ -226,20 +231,15 @@ func parseHeaders(raw []string) (map[string]string, error) {
// roundTripper paces and decorates each request before delegating to base.
type roundTripper struct {
base *http.Transport
headers map[string]string
cookie string
userAgent string
limiter *rate.Limiter
base *http.Transport
headers map[string]string
cookie string
userAgent string
limiter *rate.Limiter
maxRetries int
}
func (rt *roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
if rt.limiter != nil {
if err := rt.limiter.Wait(req.Context()); err != nil {
return nil, fmt.Errorf("rate limiter: %w", err)
}
}
// only set what the caller hasn't already; a scanner that explicitly sets a
// header (e.g. an api key) must win over the global default.
for key, value := range rt.headers {
@@ -254,5 +254,101 @@ func (rt *roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
req.Header.Set("User-Agent", rt.userAgent)
}
return rt.base.RoundTrip(req)
for attempt := 0; ; attempt++ {
if rt.limiter != nil {
if err := rt.limiter.Wait(req.Context()); err != nil {
return nil, fmt.Errorf("rate limiter: %w", err)
}
}
resp, err := rt.base.RoundTrip(req)
if err != nil || attempt >= rt.maxRetries || !retryableStatus(resp.StatusCode) {
return resp, err
}
// back off and retry, unless the body can't be replayed.
if !rewind(req) {
return resp, nil
}
wait := retryAfter(resp, attempt)
DrainClose(resp)
if err := sleepCtx(req.Context(), wait); err != nil {
return nil, err
}
}
}
func retryableStatus(code int) bool {
return code == http.StatusTooManyRequests || code == http.StatusServiceUnavailable
}
const (
retryAfterCap = 20 * time.Second
retryBackoffBase = 500 * time.Millisecond
// clamp the shift so a large -max-retries can't overflow the duration.
retryBackoffMaxShift = 16
)
// retryAfter honors a Retry-After header (delta-seconds or HTTP-date) and
// otherwise falls back to capped exponential backoff.
func retryAfter(resp *http.Response, attempt int) time.Duration {
if v := strings.TrimSpace(resp.Header.Get("Retry-After")); v != "" {
if secs, err := strconv.Atoi(v); err == nil && secs >= 0 {
return capDuration(time.Duration(secs) * time.Second)
}
if t, err := http.ParseTime(v); err == nil {
return capDuration(time.Until(t))
}
}
shift := attempt
if shift > retryBackoffMaxShift {
shift = retryBackoffMaxShift
}
return capDuration(retryBackoffBase << shift)
}
// capDuration clamps d to [0, retryAfterCap].
func capDuration(d time.Duration) time.Duration {
switch {
case d < 0:
return 0
case d > retryAfterCap:
return retryAfterCap
default:
return d
}
}
// rewind restores req.Body for a resend. Only a GetBody-backed body (set by
// net/http for the in-memory bodies sif uses) is replayable; a nil or NoBody
// request needs nothing, anything else can't be retried.
func rewind(req *http.Request) bool {
if req.Body == nil || req.Body == http.NoBody {
return true
}
if req.GetBody == nil {
return false
}
body, err := req.GetBody()
if err != nil {
return false
}
req.Body = body
return true
}
// sleepCtx waits for d or until ctx is cancelled, whichever comes first.
func sleepCtx(ctx context.Context, d time.Duration) error {
if d <= 0 {
return nil
}
timer := time.NewTimer(d)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}
+226
View File
@@ -20,6 +20,7 @@ import (
"net/http/httptest"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
@@ -489,3 +490,228 @@ func resetBench() {
configured = nil
mu.Unlock()
}
// retrySequenceServer serves codes[n] on the n-th hit, repeating the last once
// the slice runs out; retryable codes carry Retry-After: 0 to keep tests fast.
func retrySequenceServer(t *testing.T, hits *atomic.Int64, codes ...int) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
n := int(hits.Add(1)) - 1
code := codes[len(codes)-1]
if n < len(codes) {
code = codes[n]
}
if code == http.StatusTooManyRequests || code == http.StatusServiceUnavailable {
w.Header().Set("Retry-After", "0")
}
w.WriteHeader(code)
}))
t.Cleanup(srv.Close)
return srv
}
// getStatus performs a GET and returns the final status code the caller sees.
func getStatus(t *testing.T, client *http.Client, url string) int {
t.Helper()
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, http.NoBody)
if err != nil {
t.Fatalf("new request: %v", err)
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("do request: %v", err)
}
resp.Body.Close()
return resp.StatusCode
}
func TestRetryRecoversAfter429(t *testing.T) {
resetConfig(t)
var hits atomic.Int64
srv := retrySequenceServer(t, &hits, http.StatusTooManyRequests, http.StatusOK)
if err := Configure(Options{MaxRetries: 2}); err != nil {
t.Fatalf("Configure: %v", err)
}
if code := getStatus(t, Client(5*time.Second), srv.URL); code != http.StatusOK {
t.Errorf("status = %d, want 200 after retry", code)
}
if got := hits.Load(); got != 2 {
t.Errorf("server hits = %d, want 2 (initial + one retry)", got)
}
}
func TestRetryRecoversAfter503(t *testing.T) {
resetConfig(t)
var hits atomic.Int64
srv := retrySequenceServer(t, &hits, http.StatusServiceUnavailable, http.StatusOK)
if err := Configure(Options{MaxRetries: 2}); err != nil {
t.Fatalf("Configure: %v", err)
}
if code := getStatus(t, Client(5*time.Second), srv.URL); code != http.StatusOK {
t.Errorf("status = %d, want 200 after retry", code)
}
if got := hits.Load(); got != 2 {
t.Errorf("server hits = %d, want 2", got)
}
}
func TestRetryDisabled(t *testing.T) {
resetConfig(t)
var hits atomic.Int64
srv := retrySequenceServer(t, &hits, http.StatusTooManyRequests, http.StatusOK)
if err := Configure(Options{MaxRetries: 0}); err != nil {
t.Fatalf("Configure: %v", err)
}
if code := getStatus(t, Client(5*time.Second), srv.URL); code != http.StatusTooManyRequests {
t.Errorf("status = %d, want 429 with retries off", code)
}
if got := hits.Load(); got != 1 {
t.Errorf("server hits = %d, want 1 (no retry)", got)
}
}
func TestRetryExhausted(t *testing.T) {
resetConfig(t)
var hits atomic.Int64
srv := retrySequenceServer(t, &hits, http.StatusTooManyRequests) // always 429
if err := Configure(Options{MaxRetries: 2}); err != nil {
t.Fatalf("Configure: %v", err)
}
if code := getStatus(t, Client(5*time.Second), srv.URL); code != http.StatusTooManyRequests {
t.Errorf("status = %d, want 429 after exhausting retries", code)
}
if got := hits.Load(); got != 3 {
t.Errorf("server hits = %d, want 3 (initial + 2 retries)", got)
}
}
func TestRetryIgnoresNonRetryableStatus(t *testing.T) {
resetConfig(t)
var hits atomic.Int64
srv := retrySequenceServer(t, &hits, http.StatusInternalServerError, http.StatusOK)
if err := Configure(Options{MaxRetries: 2}); err != nil {
t.Fatalf("Configure: %v", err)
}
if code := getStatus(t, Client(5*time.Second), srv.URL); code != http.StatusInternalServerError {
t.Errorf("status = %d, want 500 (not retried)", code)
}
if got := hits.Load(); got != 1 {
t.Errorf("server hits = %d, want 1 (500 not retried)", got)
}
}
func TestRetryReplaysRequestBody(t *testing.T) {
resetConfig(t)
var hits atomic.Int64
var bmu sync.Mutex
var bodies []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
bmu.Lock()
bodies = append(bodies, string(body))
bmu.Unlock()
if hits.Add(1) == 1 {
w.Header().Set("Retry-After", "0")
w.WriteHeader(http.StatusTooManyRequests)
return
}
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
if err := Configure(Options{MaxRetries: 2}); err != nil {
t.Fatalf("Configure: %v", err)
}
req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, srv.URL, strings.NewReader("payload"))
if err != nil {
t.Fatalf("new request: %v", err)
}
resp, err := Client(5 * time.Second).Do(req)
if err != nil {
t.Fatalf("do request: %v", err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 after body replay", resp.StatusCode)
}
bmu.Lock()
defer bmu.Unlock()
if len(bodies) != 2 {
t.Fatalf("server saw %d requests, want 2", len(bodies))
}
for i, body := range bodies {
if body != "payload" {
t.Errorf("body[%d] = %q, want %q (rewind dropped the body)", i, body, "payload")
}
}
}
func TestRetryAfterHeader(t *testing.T) {
noHeader := &http.Response{Header: http.Header{}}
if got := retryAfter(noHeader, 0); got != retryBackoffBase {
t.Errorf("missing header: attempt 0 = %v, want %v", got, retryBackoffBase)
}
if got := retryAfter(noHeader, 1); got != 2*retryBackoffBase {
t.Errorf("missing header: attempt 1 = %v, want %v", got, 2*retryBackoffBase)
}
if got := retryAfter(noHeader, 1000); got != retryAfterCap {
t.Errorf("missing header: attempt 1000 = %v, want cap %v", got, retryAfterCap)
}
withSeconds := func(v string) *http.Response {
return &http.Response{Header: http.Header{"Retry-After": {v}}}
}
if got := retryAfter(withSeconds("3"), 0); got != 3*time.Second {
t.Errorf("Retry-After 3 = %v, want 3s", got)
}
if got := retryAfter(withSeconds("0"), 5); got != 0 {
t.Errorf("Retry-After 0 = %v, want 0", got)
}
if got := retryAfter(withSeconds("9999"), 0); got != retryAfterCap {
t.Errorf("Retry-After 9999 = %v, want cap %v", got, retryAfterCap)
}
if got := retryAfter(withSeconds("soon"), 0); got != retryBackoffBase {
t.Errorf("Retry-After junk = %v, want backoff %v", got, retryBackoffBase)
}
future := time.Now().Add(5 * time.Second).UTC().Format(http.TimeFormat)
if got := retryAfter(withSeconds(future), 0); got <= 0 || got > 5*time.Second {
t.Errorf("Retry-After http-date = %v, want (0, 5s]", got)
}
}
func TestCapDuration(t *testing.T) {
cases := []struct{ in, want time.Duration }{
{-time.Second, 0},
{0, 0},
{5 * time.Second, 5 * time.Second},
{retryAfterCap, retryAfterCap},
{retryAfterCap + time.Second, retryAfterCap},
}
for _, c := range cases {
if got := capDuration(c.in); got != c.want {
t.Errorf("capDuration(%v) = %v, want %v", c.in, got, c.want)
}
}
}
func TestSleepCtxCancelled(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := sleepCtx(ctx, time.Hour); err == nil {
t.Error("sleepCtx on a cancelled context should return its error, not block")
}
}
@@ -0,0 +1,216 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
func runOrchestrationModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func orchestrationExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestAIOrchestrationExposureModules(t *testing.T) {
const langflow = "../../modules/recon/langflow-exposure.yaml"
const dify = "../../modules/recon/dify-console-exposure.yaml"
const ray = "../../modules/recon/ray-dashboard-exposure.yaml"
const skypilot = "../../modules/recon/skypilot-api-exposure.yaml"
langflowVersion := `{"version":"1.0.19","main_version":"1.0.19","package":"Langflow"}`
difyFeatures := `{"enable_app_deploy":true,"sso_enforced_for_signin":false,` +
`"sso_enforced_for_signin_protocol":"","enable_marketplace":true,"enable_email_code_login":false,` +
`"enable_email_password_login":true,"enable_social_oauth_login":false,"is_allow_register":true,` +
`"is_allow_create_workspace":false,"is_email_setup":true,"license":{"status":"none","expired_at":""}}`
rayVersion := `{"version":"4","ray_version":"2.9.3","ray_commit":"a1b2c3d4e5","session_name":"session_2024"}`
skypilotHealth := `{"status":"healthy","api_version":"14","version":"0.9.3","version_on_disk":"0.9.3",` +
`"commit":"abc1234def","basic_auth_enabled":false}`
t.Run("a langflow version api is flagged", func(t *testing.T) {
res := runOrchestrationModule(t, langflow, 200, langflowVersion)
if len(res.Findings) == 0 {
t.Fatal("expected a langflow finding")
}
if v := orchestrationExtract(res, "langflow_version"); v != "1.0.19" {
t.Errorf("langflow_version=%q, want 1.0.19", v)
}
})
t.Run("a langflow base build is still flagged", func(t *testing.T) {
body := `{"version":"1.0.19","main_version":"1.0.19","package":"Langflow Base"}`
if res := runOrchestrationModule(t, langflow, 200, body); len(res.Findings) == 0 {
t.Fatal("expected a finding for a langflow base build")
}
})
t.Run("a version api from another package is not flagged as langflow", func(t *testing.T) {
body := `{"version":"1.0","main_version":"1.0","package":"SomeApp"}`
if res := runOrchestrationModule(t, langflow, 200, body); len(res.Findings) > 0 {
t.Errorf("another package should not match langflow, got %d findings", len(res.Findings))
}
})
t.Run("a langflow package without main_version is not flagged", func(t *testing.T) {
if res := runOrchestrationModule(t, langflow, 200, `{"package":"Langflow"}`); len(res.Findings) > 0 {
t.Errorf("a package-only body should not match langflow, got %d findings", len(res.Findings))
}
})
t.Run("a dify system-features is flagged and reports open registration", func(t *testing.T) {
res := runOrchestrationModule(t, dify, 200, difyFeatures)
if len(res.Findings) == 0 {
t.Fatal("expected a dify finding")
}
if v := orchestrationExtract(res, "dify_allow_register"); v != "true" {
t.Errorf("dify_allow_register=%q, want true", v)
}
})
t.Run("a body without sso_enforced_for_signin is not flagged as dify", func(t *testing.T) {
body := `{"enable_email_password_login":true,"is_allow_create_workspace":false,"is_allow_register":true}`
if res := runOrchestrationModule(t, dify, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without sso_enforced_for_signin should not match dify, got %d findings", len(res.Findings))
}
})
t.Run("a body without enable_email_password_login is not flagged as dify", func(t *testing.T) {
body := `{"sso_enforced_for_signin":false,"is_allow_create_workspace":false}`
if res := runOrchestrationModule(t, dify, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without enable_email_password_login should not match dify, got %d findings", len(res.Findings))
}
})
t.Run("a body without is_allow_create_workspace is not flagged as dify", func(t *testing.T) {
body := `{"sso_enforced_for_signin":false,"enable_email_password_login":true}`
if res := runOrchestrationModule(t, dify, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without is_allow_create_workspace should not match dify, got %d findings", len(res.Findings))
}
})
t.Run("a dify with registration disabled is not flagged", func(t *testing.T) {
body := `{"sso_enforced_for_signin":false,"enable_email_password_login":true,"is_allow_create_workspace":true,"is_allow_register":false}`
if res := runOrchestrationModule(t, dify, 200, body); len(res.Findings) > 0 {
t.Errorf("a closed-registration dify should not be flagged, got %d findings", len(res.Findings))
}
})
t.Run("a ray dashboard version is flagged", func(t *testing.T) {
res := runOrchestrationModule(t, ray, 200, rayVersion)
if len(res.Findings) == 0 {
t.Fatal("expected a ray finding")
}
if v := orchestrationExtract(res, "ray_version"); v != "2.9.3" {
t.Errorf("ray_version=%q, want 2.9.3", v)
}
})
t.Run("a generic version api is not flagged as ray", func(t *testing.T) {
body := `{"version":"4","api_version":"v1","build":"123"}`
if res := runOrchestrationModule(t, ray, 200, body); len(res.Findings) > 0 {
t.Errorf("a generic version api should not match ray, got %d findings", len(res.Findings))
}
})
t.Run("a ray_version without a ray_commit is not flagged", func(t *testing.T) {
body := `{"version":"4","ray_version":"2.9.3"}`
if res := runOrchestrationModule(t, ray, 200, body); len(res.Findings) > 0 {
t.Errorf("ray_version alone should not match ray, got %d findings", len(res.Findings))
}
})
t.Run("a skypilot health is flagged with its version and auth state", func(t *testing.T) {
res := runOrchestrationModule(t, skypilot, 200, skypilotHealth)
if len(res.Findings) == 0 {
t.Fatal("expected a skypilot finding")
}
if v := orchestrationExtract(res, "skypilot_version"); v != "0.9.3" {
t.Errorf("skypilot_version=%q, want 0.9.3", v)
}
if v := orchestrationExtract(res, "skypilot_basic_auth"); v != "false" {
t.Errorf("skypilot_basic_auth=%q, want false", v)
}
})
t.Run("a bare status health is not flagged as skypilot", func(t *testing.T) {
if res := runOrchestrationModule(t, skypilot, 200, `{"status":"healthy"}`); len(res.Findings) > 0 {
t.Errorf("an auth-gated bare health should not match skypilot, got %d findings", len(res.Findings))
}
})
t.Run("a body without version_on_disk is not flagged as skypilot", func(t *testing.T) {
body := `{"status":"healthy","api_version":"14","commit":"abc","basic_auth_enabled":false}`
if res := runOrchestrationModule(t, skypilot, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without version_on_disk should not match skypilot, got %d findings", len(res.Findings))
}
})
t.Run("a body without basic_auth_enabled is not flagged as skypilot", func(t *testing.T) {
body := `{"status":"healthy","version_on_disk":"0.9.3","commit":"abc"}`
if res := runOrchestrationModule(t, skypilot, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without basic_auth_enabled should not match skypilot, got %d findings", len(res.Findings))
}
})
t.Run("a body without commit is not flagged as skypilot", func(t *testing.T) {
body := `{"status":"healthy","version_on_disk":"0.9.3","basic_auth_enabled":false}`
if res := runOrchestrationModule(t, skypilot, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without commit should not match skypilot, got %d findings", len(res.Findings))
}
})
t.Run("a skypilot with basic auth enabled is not flagged", func(t *testing.T) {
body := `{"status":"healthy","version_on_disk":"0.9.3","commit":"abc","basic_auth_enabled":true}`
if res := runOrchestrationModule(t, skypilot, 200, body); len(res.Findings) > 0 {
t.Errorf("an auth-enabled skypilot should not be flagged, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{langflow, dify, ray, skypilot} {
if res := runOrchestrationModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{langflow, dify, ray, skypilot} {
if res := runOrchestrationModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
@@ -0,0 +1,87 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
func runAlertmanagerModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func alertmanagerExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestAlertmanagerExposureModule(t *testing.T) {
const am = "../../modules/recon/alertmanager-status-exposure.yaml"
t.Run("an alertmanager status is flagged with its version", func(t *testing.T) {
body := `{"cluster":{"name":"01HXYZ","status":"ready","peers":[{"name":"01HX","address":"10.0.0.7:9094"}]},` +
`"versionInfo":{"branch":"HEAD","buildDate":"20240228","buildUser":"root@host","goVersion":"go1.21.7",` +
`"revision":"0aa3c2a","version":"0.27.0"},"config":{"original":"global:\n smtp_smarthost: 'smtp:587'\n ` +
`smtp_auth_password: 'hunter2'\nreceivers:\n- name: team\n slack_configs:\n - api_url: 'https://hooks.slack.com/services/T/B/X'\n"},` +
`"uptime":"2024-06-01T10:00:00.000Z"}`
res := runAlertmanagerModule(t, am, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected an alertmanager finding")
}
if v := alertmanagerExtract(res, "alertmanager_version"); v != "0.27.0" {
t.Errorf("alertmanager_version=%q, want 0.27.0", v)
}
})
t.Run("a versionInfo+cluster body without config is not flagged", func(t *testing.T) {
body := `{"cluster":{"name":"01HXYZ","status":"ready"},"versionInfo":{"version":"0.27.0"},"uptime":"x"}`
if res := runAlertmanagerModule(t, am, 200, body); len(res.Findings) > 0 {
t.Errorf("a configless status should not match alertmanager, got %d findings", len(res.Findings))
}
})
t.Run("a config+versionInfo body without cluster is not flagged", func(t *testing.T) {
body := `{"versionInfo":{"version":"0.27.0"},"config":{"original":"global:\n"},"uptime":"x"}`
if res := runAlertmanagerModule(t, am, 200, body); len(res.Findings) > 0 {
t.Errorf("a clusterless status should not match alertmanager, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
if res := runAlertmanagerModule(t, am, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("a plain 200 body should not match, got %d findings", len(res.Findings))
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
if res := runAlertmanagerModule(t, am, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("a 404 should not match, got %d findings", len(res.Findings))
}
})
}
@@ -7,7 +7,7 @@ import (
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
"github.com/vmfunc/sif/internal/modules"
)
func runAnalyticsModule(t *testing.T, file string, status int, body string) *modules.Result {
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
"github.com/vmfunc/sif/internal/modules"
)
func runAppCfgModule(t *testing.T, file string, status int, body string) *modules.Result {
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
"github.com/vmfunc/sif/internal/modules"
)
func runArgocdModule(t *testing.T, status int, body string) *modules.Result {
+6 -2
View File
@@ -23,7 +23,7 @@ import (
"sync"
"testing"
"github.com/dropalldatabases/sif/internal/httpx"
"github.com/vmfunc/sif/internal/httpx"
)
func reqURLs(reqs []*httpRequest) []string {
@@ -60,7 +60,11 @@ func TestGenerateHTTPRequestsAttack(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &HTTPConfig{Paths: tt.paths, Payloads: tt.payloads, Attack: tt.attack}
got := reqURLs(generateHTTPRequests(target, cfg))
reqs, err := generateHTTPRequests(target, cfg)
if err != nil {
t.Fatalf("generateHTTPRequests: %v", err)
}
got := reqURLs(reqs)
want := append([]string(nil), tt.want...)
sort.Strings(want)
if !reflect.DeepEqual(got, want) {
@@ -0,0 +1,110 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
func runAutomationModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func automationExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestAutomationServerExposureModules(t *testing.T) {
const jenkins = "../../modules/recon/jenkins-api-exposure.yaml"
const nifi = "../../modules/recon/nifi-api-exposure.yaml"
t.Run("a jenkins controller api is flagged with the first job", func(t *testing.T) {
body := `{"_class":"hudson.model.Hudson","assignedLabels":[{}],"mode":"NORMAL","nodeDescription":` +
`"the master Jenkins node","nodeName":"","numExecutors":2,"jobs":[{"_class":` +
`"hudson.model.FreeStyleProject","name":"deploy-prod","url":"http://ci/job/deploy-prod/","color":"blue"}],` +
`"useSecurity":true,"views":[{"_class":"hudson.model.AllView","name":"all","url":"http://ci/"}]}`
res := runAutomationModule(t, jenkins, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a jenkins finding")
}
if v := automationExtract(res, "jenkins_job"); v != "deploy-prod" {
t.Errorf("jenkins_job=%q, want deploy-prod", v)
}
})
t.Run("a non-root jenkins object is not flagged as the controller", func(t *testing.T) {
body := `{"_class":"hudson.model.FreeStyleProject","name":"deploy-prod","jobs":[],"color":"blue"}`
if res := runAutomationModule(t, jenkins, 200, body); len(res.Findings) > 0 {
t.Errorf("a FreeStyleProject object should not match the controller, got %d findings", len(res.Findings))
}
})
t.Run("a hudson root without a jobs key is not flagged", func(t *testing.T) {
if res := runAutomationModule(t, jenkins, 200, `{"_class":"hudson.model.Hudson","mode":"NORMAL"}`); len(res.Findings) > 0 {
t.Errorf("a jobless controller blob should not match, got %d findings", len(res.Findings))
}
})
t.Run("a nifi about is flagged with its version", func(t *testing.T) {
body := `{"about":{"title":"NiFi","version":"1.28.1","uri":"https://nifi:8443/nifi-api/",` +
`"contentViewerUrl":"../nifi-content-viewer/","timezone":"UTC","buildTag":"nifi-1.28.1-RC1",` +
`"buildRevision":"abc123","buildBranch":"support/nifi-1.x","buildTimestamp":"06/01/2024 10:00:00 UTC"}}`
res := runAutomationModule(t, nifi, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a nifi finding")
}
if v := automationExtract(res, "nifi_version"); v != "1.28.1" {
t.Errorf("nifi_version=%q, want 1.28.1", v)
}
})
t.Run("an about block for another product is not flagged as nifi", func(t *testing.T) {
if res := runAutomationModule(t, nifi, 200, `{"about":{"title":"SomeApp","version":"2.0.0"}}`); len(res.Findings) > 0 {
t.Errorf("a non-nifi about block should not match, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{jenkins, nifi} {
if res := runAutomationModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 403 or 401 is not a leak", func(t *testing.T) {
if res := runAutomationModule(t, jenkins, 403, `{"_class":"hudson.model.Hudson","jobs":[]}`); len(res.Findings) > 0 {
t.Errorf("a 403 jenkins should not match, got %d findings", len(res.Findings))
}
if res := runAutomationModule(t, nifi, 401, `{"about":{"title":"NiFi","version":"1.28.1"}}`); len(res.Findings) > 0 {
t.Errorf("a 401 nifi should not match, got %d findings", len(res.Findings))
}
})
}
@@ -7,7 +7,7 @@ import (
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
"github.com/vmfunc/sif/internal/modules"
)
func runBigDataModule(t *testing.T, file string, status int, body string) *modules.Result {
@@ -0,0 +1,115 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
func runGridModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func gridExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
const seleniumStatusBody = `{"value":{"ready":true,"message":"Selenium Grid ready.","nodes":[` +
`{"id":"028ca108-bfc4-430e-806c-6477b6b8569e","uri":"http://10.0.0.5:5555","maxSessions":1,` +
`"osInfo":{"arch":"amd64","name":"Linux","version":"5.15.0"},"heartbeatPeriod":60000,` +
`"availability":"UP","version":"4.18.1 (revision b1d3319b48)","slots":[{"lastStarted":` +
`"2024-06-01T10:00:00Z","session":null,"id":{"hostId":"028ca108","id":"fdd41c10"},` +
`"stereotype":{"browserName":"chrome","platformName":"LINUX"}}]}]}}`
const selenoidStatusBody = `{"total":80,"used":10,"queued":0,"pending":1,"browsers":{"chrome":` +
`{"124.0":{"user1":{"count":1,"sessions":[{"id":"abc","container":"sel-abc"}]}}},"firefox":{"125.0":{}}}}`
func TestBrowserGridExposureModules(t *testing.T) {
const selenium = "../../modules/recon/selenium-grid-exposure.yaml"
const selenoid = "../../modules/recon/selenoid-exposure.yaml"
t.Run("a selenium grid status is flagged with a node version", func(t *testing.T) {
res := runGridModule(t, selenium, 200, seleniumStatusBody)
if len(res.Findings) == 0 {
t.Fatal("expected a selenium finding")
}
if v := gridExtract(res, "selenium_version"); v != "4.18.1 (revision b1d3319b48)" {
t.Errorf("selenium_version=%q, want the node build string", v)
}
})
t.Run("a value+nodes envelope without the selenium grid message is not flagged", func(t *testing.T) {
body := `{"value":{"ready":true,"nodes":[{"id":"n1","name":"router"}]}}`
if res := runGridModule(t, selenium, 200, body); len(res.Findings) > 0 {
t.Errorf("a generic value/nodes blob should not match selenium, got %d findings", len(res.Findings))
}
})
t.Run("a selenoid status is flagged with the first browser", func(t *testing.T) {
res := runGridModule(t, selenoid, 200, selenoidStatusBody)
if len(res.Findings) == 0 {
t.Fatal("expected a selenoid finding")
}
if v := gridExtract(res, "selenoid_browser"); v != "chrome" {
t.Errorf("selenoid_browser=%q, want chrome", v)
}
})
t.Run("a capacity blob without browsers is not flagged as selenoid", func(t *testing.T) {
if res := runGridModule(t, selenoid, 200, `{"total":80,"used":10,"queued":0,"pending":1}`); len(res.Findings) > 0 {
t.Errorf("a browserless capacity blob should not match selenoid, got %d findings", len(res.Findings))
}
})
t.Run("the two grid modules do not cross-match each other", func(t *testing.T) {
if res := runGridModule(t, selenoid, 200, seleniumStatusBody); len(res.Findings) > 0 {
t.Errorf("selenium status should not match selenoid, got %d findings", len(res.Findings))
}
if res := runGridModule(t, selenium, 200, selenoidStatusBody); len(res.Findings) > 0 {
t.Errorf("selenoid status should not match selenium, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{selenium, selenoid} {
if res := runGridModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{selenium, selenoid} {
if res := runGridModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
@@ -0,0 +1,198 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
func runBuildCredModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func buildCredExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestBuildToolCredentialExposureModules(t *testing.T) {
const maven = "../../modules/recon/maven-settings-exposure.yaml"
const gradle = "../../modules/recon/gradle-properties-exposure.yaml"
const nuget = "../../modules/recon/nuget-config-exposure.yaml"
mavenSettings := "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
"<settings xmlns=\"http://maven.apache.org/SETTINGS/1.0.0\">\n" +
" <servers>\n <server>\n <id>nexus-releases</id>\n" +
" <username>deploy</username>\n <password>S3cretDeployPass</password>\n" +
" </server>\n </servers>\n</settings>\n"
gradleProps := "org.gradle.jvmargs=-Xmx2g\nossrhUsername=deployer\n" +
"ossrhPassword=mySonatypeSecret\nsigning.password=mySigningSecret\n"
nugetConfig := "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<configuration>\n" +
" <packageSourceCredentials>\n <MyFeed>\n" +
" <add key=\"Username\" value=\"deploy\" />\n" +
" <add key=\"ClearTextPassword\" value=\"S3cretFeedPass\" />\n" +
" </MyFeed>\n </packageSourceCredentials>\n</configuration>\n"
t.Run("an exposed maven settings leaks the server username", func(t *testing.T) {
res := runBuildCredModule(t, maven, 200, mavenSettings)
if len(res.Findings) == 0 {
t.Fatal("expected a maven finding")
}
if v := buildCredExtract(res, "maven_username"); v != "deploy" {
t.Errorf("maven_username=%q, want deploy", v)
}
})
t.Run("an exposed gradle properties leaks the secret property", func(t *testing.T) {
res := runBuildCredModule(t, gradle, 200, gradleProps)
if len(res.Findings) == 0 {
t.Fatal("expected a gradle finding")
}
if v := buildCredExtract(res, "gradle_secret_property"); v != "ossrhPassword" {
t.Errorf("gradle_secret_property=%q, want ossrhPassword", v)
}
})
t.Run("an exposed nuget config leaks the feed username", func(t *testing.T) {
res := runBuildCredModule(t, nuget, 200, nugetConfig)
if len(res.Findings) == 0 {
t.Fatal("expected a nuget finding")
}
if v := buildCredExtract(res, "nuget_username"); v != "deploy" {
t.Errorf("nuget_username=%q, want deploy", v)
}
})
t.Run("a maven settings with mirrors but no password is not flagged", func(t *testing.T) {
body := "<settings>\n <mirrors>\n <mirror>\n <id>central</id>\n" +
" <url>https://repo.example.com/maven2</url>\n </mirror>\n </mirrors>\n</settings>\n"
if res := runBuildCredModule(t, maven, 200, body); len(res.Findings) > 0 {
t.Errorf("a settings without a password should not match, got %d findings", len(res.Findings))
}
})
t.Run("an html page demonstrating a maven settings is not a leak", func(t *testing.T) {
body := "<!DOCTYPE html><html><body><pre><settings><server><password>x</password></server></settings></pre></body></html>"
if res := runBuildCredModule(t, maven, 200, body); len(res.Findings) > 0 {
t.Errorf("an html maven tutorial should not match, got %d findings", len(res.Findings))
}
})
t.Run("a gradle properties with no credential property is not flagged", func(t *testing.T) {
body := "org.gradle.jvmargs=-Xmx2g\nversion=1.0.0\norg.gradle.daemon=true\n"
if res := runBuildCredModule(t, gradle, 200, body); len(res.Findings) > 0 {
t.Errorf("a non credential properties file should not match, got %d findings", len(res.Findings))
}
})
t.Run("a comment naming a password is not a credential property", func(t *testing.T) {
body := "# set your password=here before building\norg.gradle.daemon=true\n"
if res := runBuildCredModule(t, gradle, 200, body); len(res.Findings) > 0 {
t.Errorf("a comment line should not match, got %d findings", len(res.Findings))
}
})
t.Run("an empty password property is not flagged", func(t *testing.T) {
body := "signing.password=\nsigning.keyId=24875D73\n"
if res := runBuildCredModule(t, gradle, 200, body); len(res.Findings) > 0 {
t.Errorf("an empty value should not match, got %d findings", len(res.Findings))
}
})
t.Run("an html page demonstrating a gradle property is not a leak", func(t *testing.T) {
body := "<!DOCTYPE html>\n<html><body><pre>\nossrhPassword=mySonatypeSecret\n</pre></body></html>\n"
if res := runBuildCredModule(t, gradle, 200, body); len(res.Findings) > 0 {
t.Errorf("an html gradle tutorial should not match, got %d findings", len(res.Findings))
}
})
t.Run("a nuget config without a credentials section is not flagged", func(t *testing.T) {
body := "<configuration>\n <packageSources>\n" +
" <add key=\"nuget.org\" value=\"https://api.nuget.org/v3/index.json\" />\n" +
" </packageSources>\n</configuration>\n"
if res := runBuildCredModule(t, nuget, 200, body); len(res.Findings) > 0 {
t.Errorf("a config without credentials should not match, got %d findings", len(res.Findings))
}
})
t.Run("a nuget credentials section without a password is not flagged", func(t *testing.T) {
body := "<configuration>\n <packageSourceCredentials>\n <MyFeed>\n" +
" <add key=\"Username\" value=\"deploy\" />\n" +
" </MyFeed>\n </packageSourceCredentials>\n</configuration>\n"
if res := runBuildCredModule(t, nuget, 200, body); len(res.Findings) > 0 {
t.Errorf("a credentials section without a password should not match, got %d findings", len(res.Findings))
}
})
t.Run("an appsettings password is not a nuget feed credential", func(t *testing.T) {
body := "<configuration>\n <appSettings>\n" +
" <add key=\"Password\" value=\"appsecret\" />\n" +
" </appSettings>\n</configuration>\n"
if res := runBuildCredModule(t, nuget, 200, body); len(res.Findings) > 0 {
t.Errorf("an appsettings password should not match, got %d findings", len(res.Findings))
}
})
t.Run("an html page demonstrating a nuget config is not a leak", func(t *testing.T) {
body := "<!DOCTYPE html><html><body><pre><packageSourceCredentials><add key=\"ClearTextPassword\" value=\"x\" /></packageSourceCredentials></pre></body></html>"
if res := runBuildCredModule(t, nuget, 200, body); len(res.Findings) > 0 {
t.Errorf("an html nuget tutorial should not match, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{maven, gradle, nuget} {
if res := runBuildCredModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{maven, gradle, nuget} {
if res := runBuildCredModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
@@ -0,0 +1,120 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
func runCICDModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func cicdExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestCICDServerExposureModules(t *testing.T) {
const concourse = "../../modules/recon/concourse-info-exposure.yaml"
const woodpecker = "../../modules/recon/woodpecker-version-exposure.yaml"
const gocd = "../../modules/recon/gocd-version-exposure.yaml"
t.Run("a concourse info is flagged with its version", func(t *testing.T) {
body := `{"version":"7.11.2","worker_version":"2.4","external_url":"https://ci.example.com","cluster_name":"prod"}`
res := runCICDModule(t, concourse, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a concourse finding")
}
if v := cicdExtract(res, "concourse_version"); v != "7.11.2" {
t.Errorf("concourse_version=%q, want 7.11.2", v)
}
})
t.Run("an info without worker_version is not flagged as concourse", func(t *testing.T) {
body := `{"version":"1.0","external_url":"https://x"}`
if res := runCICDModule(t, concourse, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without worker_version should not match concourse, got %d findings", len(res.Findings))
}
})
t.Run("a woodpecker version is flagged with its version", func(t *testing.T) {
body := `{"source":"https://github.com/woodpecker-ci/woodpecker","version":"2.1.0"}`
res := runCICDModule(t, woodpecker, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a woodpecker finding")
}
if v := cicdExtract(res, "woodpecker_version"); v != "2.1.0" {
t.Errorf("woodpecker_version=%q, want 2.1.0", v)
}
})
t.Run("a drone version with the same shape is not flagged as woodpecker", func(t *testing.T) {
body := `{"source":"https://github.com/harness/drone","version":"2.20.0"}`
if res := runCICDModule(t, woodpecker, 200, body); len(res.Findings) > 0 {
t.Errorf("a drone source should not match woodpecker, got %d findings", len(res.Findings))
}
})
t.Run("a gocd version is flagged with its version", func(t *testing.T) {
body := `{"_links":{"self":{"href":"https://ci/go/api/version"}},"version":"21.4.0",` +
`"build_number":"13183","git_sha":"abc123","full_version":"21.4.0 (13183-abc123)",` +
`"commit_url":"https://github.com/gocd/gocd/commit/abc123"}`
res := runCICDModule(t, gocd, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a gocd finding")
}
if v := cicdExtract(res, "gocd_version"); v != "21.4.0" {
t.Errorf("gocd_version=%q, want 21.4.0", v)
}
})
t.Run("a version with a non-gocd commit url is not flagged as gocd", func(t *testing.T) {
body := `{"version":"1.0","full_version":"1.0 (1-x)","commit_url":"https://github.com/other/repo/commit/x"}`
if res := runCICDModule(t, gocd, 200, body); len(res.Findings) > 0 {
t.Errorf("a non-gocd commit url should not match gocd, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{concourse, woodpecker, gocd} {
if res := runCICDModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{concourse, woodpecker, gocd} {
if res := runCICDModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
@@ -0,0 +1,205 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
func runCMSCfgModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func cmsCfgExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestCMSConfigExposureModules(t *testing.T) {
const joomla = "../../modules/recon/joomla-config-exposure.yaml"
const drupal = "../../modules/recon/drupal-config-exposure.yaml"
const magento = "../../modules/recon/magento-config-exposure.yaml"
joomlaConfig := "<?php\nclass JConfig {\n\tpublic $offline = '0';\n" +
"\tpublic $host = 'localhost';\n\tpublic $user = 'joomla_user';\n" +
"\tpublic $password = 'S3cretJoomlaPass';\n\tpublic $db = 'joomla_db';\n" +
"\tpublic $dbprefix = 'jos_';\n\tpublic $secret = 'AbCdEfGhIjKlMnOp';\n}\n"
drupalConfig := "<?php\n$databases['default']['default'] = array (\n" +
" 'database' => 'drupal_db',\n 'username' => 'drupal_user',\n" +
" 'password' => 'S3cretDrupalPass',\n 'host' => 'localhost',\n" +
" 'driver' => 'mysql',\n);\n$settings['hash_salt'] = 'longrandomhashsalt';\n"
magentoConfig := "<?php\nreturn [\n 'backend' => ['frontName' => 'admin_x7y'],\n" +
" 'crypt' => ['key' => 'a1b2c3d4e5f6g7h8'],\n 'db' => [\n" +
" 'connection' => ['default' => [\n 'host' => 'localhost',\n" +
" 'dbname' => 'magento',\n 'username' => 'magento_user',\n" +
" 'password' => 'S3cretMagentoPass',\n ]],\n ],\n 'MAGE_MODE' => 'production',\n];\n"
t.Run("an exposed joomla configuration leaks the password", func(t *testing.T) {
res := runCMSCfgModule(t, joomla, 200, joomlaConfig)
if len(res.Findings) == 0 {
t.Fatal("expected a joomla finding")
}
if v := cmsCfgExtract(res, "joomla_password"); v != "S3cretJoomlaPass" {
t.Errorf("joomla_password=%q, want S3cretJoomlaPass", v)
}
})
t.Run("an exposed drupal settings leaks the password", func(t *testing.T) {
res := runCMSCfgModule(t, drupal, 200, drupalConfig)
if len(res.Findings) == 0 {
t.Fatal("expected a drupal finding")
}
if v := cmsCfgExtract(res, "drupal_password"); v != "S3cretDrupalPass" {
t.Errorf("drupal_password=%q, want S3cretDrupalPass", v)
}
})
t.Run("an exposed magento env leaks the crypt key", func(t *testing.T) {
res := runCMSCfgModule(t, magento, 200, magentoConfig)
if len(res.Findings) == 0 {
t.Fatal("expected a magento finding")
}
if v := cmsCfgExtract(res, "magento_crypt_key"); v != "a1b2c3d4e5f6g7h8" {
t.Errorf("magento_crypt_key=%q, want a1b2c3d4e5f6g7h8", v)
}
})
t.Run("a joomla config missing the password is not flagged", func(t *testing.T) {
body := "<?php\nclass JConfig {\n\tpublic $host = 'localhost';\n" +
"\tpublic $db = 'joomla_db';\n\tpublic $dbprefix = 'jos_';\n}\n"
if res := runCMSCfgModule(t, joomla, 200, body); len(res.Findings) > 0 {
t.Errorf("a config without a password should not match, got %d findings", len(res.Findings))
}
})
t.Run("a php class with a public password but no jconfig is not joomla", func(t *testing.T) {
body := "<?php\nclass MyAuth {\n\tpublic $password = 'changeme';\n" +
"\tpublic $username = 'admin';\n}\n"
if res := runCMSCfgModule(t, joomla, 200, body); len(res.Findings) > 0 {
t.Errorf("a generic class should not match joomla, got %d findings", len(res.Findings))
}
})
t.Run("a php array with a password but no databases is not drupal", func(t *testing.T) {
body := "<?php\n$config = array('password' => 'x', 'host' => 'y');\n"
if res := runCMSCfgModule(t, drupal, 200, body); len(res.Findings) > 0 {
t.Errorf("a generic array should not match drupal, got %d findings", len(res.Findings))
}
})
t.Run("a drupal databases array without a password is not flagged", func(t *testing.T) {
body := "<?php\n$databases['default']['default'] = array (\n" +
" 'database' => 'drupal_db',\n 'host' => 'localhost',\n 'driver' => 'mysql',\n);\n"
if res := runCMSCfgModule(t, drupal, 200, body); len(res.Findings) > 0 {
t.Errorf("a databases array without a password should not match, got %d findings", len(res.Findings))
}
})
t.Run("a php return array with a password but no magento markers is not flagged", func(t *testing.T) {
body := "<?php\nreturn ['db' => ['password' => 'secret', 'host' => 'localhost']];\n"
if res := runCMSCfgModule(t, magento, 200, body); len(res.Findings) > 0 {
t.Errorf("a generic return array should not match magento, got %d findings", len(res.Findings))
}
})
t.Run("a magento config without a credential is not flagged", func(t *testing.T) {
body := "<?php\nreturn ['MAGE_MODE' => 'production', 'db' => ['host' => 'localhost']];\n"
if res := runCMSCfgModule(t, magento, 200, body); len(res.Findings) > 0 {
t.Errorf("a magento config without a credential should not match, got %d findings", len(res.Findings))
}
})
t.Run("an html page demonstrating a joomla config is not a leak", func(t *testing.T) {
body := "<!DOCTYPE html><html><body><pre>class JConfig { public $password = 'x'; public $db = 'y'; }</pre></body></html>"
if res := runCMSCfgModule(t, joomla, 200, body); len(res.Findings) > 0 {
t.Errorf("an html joomla tutorial should not match, got %d findings", len(res.Findings))
}
})
t.Run("a drupal settings using env indirection is not a literal password leak", func(t *testing.T) {
body := "<?php\n$databases['default']['default'] = array (\n" +
" 'database' => 'drupal_db',\n 'username' => 'drupal_user',\n" +
" 'password' => getenv('DB_PASS'),\n 'host' => 'localhost',\n);\n"
if res := runCMSCfgModule(t, drupal, 200, body); len(res.Findings) > 0 {
t.Errorf("env indirection should not match, got %d findings", len(res.Findings))
}
})
t.Run("a magento env with a cloud placeholder key is not a literal leak", func(t *testing.T) {
body := "<?php\nreturn ['crypt' => ['key' => '#env.CRYPT_KEY#'], 'MAGE_MODE' => 'production'];\n"
if res := runCMSCfgModule(t, magento, 200, body); len(res.Findings) > 0 {
t.Errorf("a cloud placeholder should not match, got %d findings", len(res.Findings))
}
})
t.Run("a magento env with a placeholder key but a literal password is flagged not mis-extracted", func(t *testing.T) {
body := "<?php\nreturn ['crypt' => ['key' => '#env.CRYPT_KEY#'],\n" +
" 'db' => ['connection' => ['default' => ['password' => 'RealDbPass']]],\n" +
" 'MAGE_MODE' => 'production'];\n"
res := runCMSCfgModule(t, magento, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a magento finding on the literal password")
}
if v := cmsCfgExtract(res, "magento_crypt_key"); v == "#env.CRYPT_KEY#" {
t.Errorf("extractor surfaced the placeholder %q as the crypt key", v)
}
})
t.Run("an html page demonstrating a drupal config is not a leak", func(t *testing.T) {
body := "<!DOCTYPE html><html><body><pre>$databases['default']['default'] = array('password' => 'x');</pre></body></html>"
if res := runCMSCfgModule(t, drupal, 200, body); len(res.Findings) > 0 {
t.Errorf("an html drupal tutorial should not match, got %d findings", len(res.Findings))
}
})
t.Run("an html page demonstrating a magento config is not a leak", func(t *testing.T) {
body := "<!DOCTYPE html><html><body><pre>'crypt' => ['key' => 'x'], 'MAGE_MODE' => 'production'</pre></body></html>"
if res := runCMSCfgModule(t, magento, 200, body); len(res.Findings) > 0 {
t.Errorf("an html magento tutorial should not match, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{joomla, drupal, magento} {
if res := runCMSCfgModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{joomla, drupal, magento} {
if res := runCMSCfgModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
@@ -0,0 +1,113 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
func runCredModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func credExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestCredentialExposureModules(t *testing.T) {
const aws = "../../modules/recon/aws-credentials-exposure.yaml"
const npmrc = "../../modules/recon/npmrc-exposure.yaml"
const docker = "../../modules/recon/docker-config-exposure.yaml"
t.Run("aws credentials leak the access key id", func(t *testing.T) {
body := "[default]\naws_access_key_id = AKIAIOSFODNN7EXAMPLE\n" +
"aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\n"
res := runCredModule(t, aws, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected an aws credentials finding")
}
if v := credExtract(res, "aws_access_key_id"); v != "AKIAIOSFODNN7EXAMPLE" {
t.Errorf("aws_access_key_id=%q, want AKIAIOSFODNN7EXAMPLE", v)
}
})
t.Run("npmrc leaks the registry of an auth token", func(t *testing.T) {
body := "//registry.npmjs.org/:_authToken=npm_AbCdEf0123456789AbCdEf0123456789\n"
res := runCredModule(t, npmrc, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected an npmrc finding")
}
if v := credExtract(res, "npm_registry"); v != "registry.npmjs.org" {
t.Errorf("npm_registry=%q, want registry.npmjs.org", v)
}
})
t.Run("docker config leaks the registry host", func(t *testing.T) {
body := `{"auths":{"registry.example.com":{"auth":"dXNlcm5hbWU6c3VwZXJzZWNyZXRwYXNz"}}}`
res := runCredModule(t, docker, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a docker config finding")
}
if v := credExtract(res, "docker_registry"); v != "registry.example.com" {
t.Errorf("docker_registry=%q, want registry.example.com", v)
}
})
t.Run("html page mentioning the key name is not a leak", func(t *testing.T) {
body := `<html><head><title>Docs</title></head><body>` +
`set your aws_secret_access_key in ~/.aws/credentials</body></html>`
if res := runCredModule(t, aws, 200, body); len(res.Findings) > 0 {
t.Errorf("an html doc mentioning the key should not match, got %d findings", len(res.Findings))
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{aws, npmrc, docker} {
if res := runCredModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{aws, npmrc, docker} {
if res := runCredModule(t, file, 200, "nothing to see here"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a docker auth field holding a jwt is not a leak", func(t *testing.T) {
body := `{"token":"x","auth":"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"}`
if res := runCredModule(t, docker, 200, body); len(res.Findings) > 0 {
t.Errorf("a jwt in an auth field should not match, got %d findings", len(res.Findings))
}
})
}
@@ -0,0 +1,99 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
func runDataOrchModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func dataOrchExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestDataOrchestrationExposureModules(t *testing.T) {
const dagster = "../../modules/recon/dagster-webserver-exposure.yaml"
const mage = "../../modules/recon/mage-status-exposure.yaml"
t.Run("a dagster server_info is flagged with its version", func(t *testing.T) {
body := `{"dagster_webserver_version":"1.7.0","dagster_version":"1.7.0","dagster_graphql_version":"1.7.0"}`
res := runDataOrchModule(t, dagster, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a dagster finding")
}
if v := dataOrchExtract(res, "dagster_version"); v != "1.7.0" {
t.Errorf("dagster_version=%q, want 1.7.0", v)
}
})
t.Run("a bare core-version body is not flagged as dagster", func(t *testing.T) {
if res := runDataOrchModule(t, dagster, 200, `{"dagster_version":"1.7.0"}`); len(res.Findings) > 0 {
t.Errorf("a body without dagster_webserver_version should not match, got %d findings", len(res.Findings))
}
})
t.Run("a mage status is flagged with its repo path", func(t *testing.T) {
body := `{"statuses":[{"is_instance_manager":false,"repo_path":"/home/src/default_repo",` +
`"repo_path_relative":"default_repo","scheduler_status":"running","project_type":"standalone",` +
`"project_uuid":"abc-123"}]}`
res := runDataOrchModule(t, mage, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a mage finding")
}
if v := dataOrchExtract(res, "mage_repo_path"); v != "/home/src/default_repo" {
t.Errorf("mage_repo_path=%q, want /home/src/default_repo", v)
}
})
t.Run("a statuses collection without scheduler fields is not flagged as mage", func(t *testing.T) {
if res := runDataOrchModule(t, mage, 200, `{"statuses":[{"id":1,"name":"ok"}]}`); len(res.Findings) > 0 {
t.Errorf("a generic statuses array should not match mage, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{dagster, mage} {
if res := runDataOrchModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{dagster, mage} {
if res := runDataOrchModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
@@ -7,7 +7,7 @@ import (
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
"github.com/vmfunc/sif/internal/modules"
)
func runPipelineModule(t *testing.T, file string, status int, body string) *modules.Result {
@@ -8,7 +8,7 @@ import (
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
"github.com/vmfunc/sif/internal/modules"
)
func runDBFileModule(t *testing.T, file string, status int, body string) *modules.Result {
@@ -0,0 +1,111 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
func runDBHTTPModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func dbHTTPExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestDatabaseHTTPExposureModules(t *testing.T) {
const clickhouse = "../../modules/recon/clickhouse-http-exposure.yaml"
const dgraph = "../../modules/recon/dgraph-api-exposure.yaml"
t.Run("a clickhouse FORMAT JSON result is flagged with the version", func(t *testing.T) {
body := `{"meta":[{"name":"version()","type":"String"}],"data":[{"version()":"24.3.1.2672"}],` +
`"rows":1,"statistics":{"elapsed":0.000123,"rows_read":1,"bytes_read":1}}`
res := runDBHTTPModule(t, clickhouse, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a clickhouse finding")
}
if v := dbHTTPExtract(res, "clickhouse_version"); v != "24.3.1.2672" {
t.Errorf("clickhouse_version=%q, want 24.3.1.2672", v)
}
})
t.Run("a json result without the statistics envelope is not flagged as clickhouse", func(t *testing.T) {
body := `{"meta":[{"name":"x"}],"data":[{"x":1}],"rows":1}`
if res := runDBHTTPModule(t, clickhouse, 200, body); len(res.Findings) > 0 {
t.Errorf("a statless json result should not match clickhouse, got %d findings", len(res.Findings))
}
})
t.Run("a dgraph alpha health is flagged with its version", func(t *testing.T) {
body := `[{"instance":"alpha","address":"localhost:7080","status":"healthy","group":"0",` +
`"version":"v23.1.0","uptime":3600,"lastEcho":1700000000,"ongoing":["opRollup"],"max_assigned":30002}]`
res := runDBHTTPModule(t, dgraph, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a dgraph finding")
}
if v := dbHTTPExtract(res, "dgraph_version"); v != "v23.1.0" {
t.Errorf("dgraph_version=%q, want v23.1.0", v)
}
})
t.Run("a dgraph alpha health without max_assigned is not flagged", func(t *testing.T) {
body := `[{"instance":"alpha","status":"healthy","lastEcho":1700000000}]`
if res := runDBHTTPModule(t, dgraph, 200, body); len(res.Findings) > 0 {
t.Errorf("a partial alpha health should not match dgraph, got %d findings", len(res.Findings))
}
})
t.Run("a non-alpha instance health is not flagged as dgraph", func(t *testing.T) {
body := `[{"instance":"zero","max_assigned":30002,"lastEcho":1700000000}]`
if res := runDBHTTPModule(t, dgraph, 200, body); len(res.Findings) > 0 {
t.Errorf("a zero-node health should not match dgraph alpha, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{clickhouse, dgraph} {
if res := runDBHTTPModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 403 or 404 is not a leak", func(t *testing.T) {
if res := runDBHTTPModule(t, clickhouse, 403, "Authentication failed"); len(res.Findings) > 0 {
t.Errorf("a 403 clickhouse should not match, got %d findings", len(res.Findings))
}
for _, file := range []string{clickhouse, dgraph} {
if res := runDBHTTPModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
+88
View File
@@ -0,0 +1,88 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
func runDBModule(t *testing.T, file string, status int, headers map[string]string, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for k, v := range headers {
w.Header().Set(k, v)
}
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func dbExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestDBPanelModules(t *testing.T) {
const adminer = "../../modules/info/adminer-panel.yaml"
const phpmyadmin = "../../modules/info/phpmyadmin-panel.yaml"
adminerLogin := `<form action=""><input type="hidden" name="auth[driver]" value="server">` +
`<input name="auth[username]"></form>` +
`<p class="links"><a href="https://www.adminer.org/">Adminer</a> <span class="version">4.8.1</span></p>`
pmaLogin := `<link rel="stylesheet" href="themes/pmahomme/css/theme.css">` +
`<input type="text" name="pma_username"><script>var data = {"PMA_VERSION":"5.2.1"};</script>`
t.Run("adminer login", func(t *testing.T) {
res := runDBModule(t, adminer, 200, nil, adminerLogin)
if len(res.Findings) == 0 {
t.Fatal("expected an adminer finding")
}
if v := dbExtract(res, "adminer_version"); v != "4.8.1" {
t.Errorf("adminer_version=%q, want 4.8.1", v)
}
})
t.Run("adminer unrelated page", func(t *testing.T) {
if res := runDBModule(t, adminer, 200, nil, "<html><body>nothing</body></html>"); len(res.Findings) > 0 {
t.Errorf("unrelated page should not match, got %d findings", len(res.Findings))
}
})
t.Run("phpmyadmin login", func(t *testing.T) {
res := runDBModule(t, phpmyadmin, 200, map[string]string{"Set-Cookie": "phpMyAdmin=abc123; path=/"}, pmaLogin)
if len(res.Findings) == 0 {
t.Fatal("expected a phpmyadmin finding")
}
if v := dbExtract(res, "phpmyadmin_version"); v != "5.2.1" {
t.Errorf("phpmyadmin_version=%q, want 5.2.1", v)
}
})
t.Run("phpmyadmin unrelated page", func(t *testing.T) {
if res := runDBModule(t, phpmyadmin, 200, nil, "<html><body>nothing</body></html>"); len(res.Findings) > 0 {
t.Errorf("unrelated page should not match, got %d findings", len(res.Findings))
}
})
}
+121
View File
@@ -0,0 +1,121 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
func runDebugModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func debugExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestDebugExposureModules(t *testing.T) {
const ignition = "../../modules/recon/laravel-ignition-exposure.yaml"
const profiler = "../../modules/recon/symfony-profiler-exposure.yaml"
const heapdump = "../../modules/recon/spring-heapdump-exposure.yaml"
t.Run("ignition health check exposes command execution", func(t *testing.T) {
res := runDebugModule(t, ignition, 200, `{"can_execute_commands":true,"config":{}}`)
if len(res.Findings) == 0 {
t.Fatal("expected an ignition finding")
}
if v := debugExtract(res, "can_execute_commands"); v != "true" {
t.Errorf("can_execute_commands=%q, want true", v)
}
})
t.Run("ignition exposed with debug off still flags and extracts false", func(t *testing.T) {
res := runDebugModule(t, ignition, 200, `{"can_execute_commands":false}`)
if len(res.Findings) == 0 {
t.Fatal("expected an ignition finding even when command execution is off")
}
if v := debugExtract(res, "can_execute_commands"); v != "false" {
t.Errorf("can_execute_commands=%q, want false", v)
}
})
t.Run("symfony profiler exposes a request token", func(t *testing.T) {
body := `<html><head><title>Symfony Profiler</title></head><body>` +
`<a href="/_profiler/5f3a2b">GET /</a></body></html>`
res := runDebugModule(t, profiler, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a symfony profiler finding")
}
if v := debugExtract(res, "profiler_token"); v != "5f3a2b" {
t.Errorf("profiler_token=%q, want 5f3a2b", v)
}
})
t.Run("spring heap dump exposes the hprof magic", func(t *testing.T) {
body := "JAVA PROFILE 1.0.2\x00\x00\x00\x08heap bytes follow"
res := runDebugModule(t, heapdump, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a heap dump finding")
}
if v := debugExtract(res, "hprof_version"); v != "1.0.2" {
t.Errorf("hprof_version=%q, want 1.0.2", v)
}
})
t.Run("the hprof magic must be at the start not merely present", func(t *testing.T) {
body := "<html><body>docs about the JAVA PROFILE 1.0.2 hprof header</body></html>"
if res := runDebugModule(t, heapdump, 200, body); len(res.Findings) > 0 {
t.Errorf("the magic away from the start should not match, got %d findings", len(res.Findings))
}
})
t.Run("a page that only names ignition is not the endpoint", func(t *testing.T) {
body := `<html><body>we use ignition to render errors in development</body></html>`
if res := runDebugModule(t, ignition, 200, body); len(res.Findings) > 0 {
t.Errorf("a prose mention should not match, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{ignition, profiler, heapdump} {
if res := runDebugModule(t, file, 200, "<html><body>plain</body></html>"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{ignition, profiler, heapdump} {
if res := runDebugModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
@@ -7,7 +7,7 @@ import (
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
"github.com/vmfunc/sif/internal/modules"
)
func runDeployModule(t *testing.T, file string, status int, body string) *modules.Result {
@@ -7,7 +7,7 @@ import (
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
"github.com/vmfunc/sif/internal/modules"
)
func runDistDBModule(t *testing.T, file string, status int, body string) *modules.Result {
@@ -0,0 +1,164 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
func runDotfileModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func dotfileExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestDotfileCredentialExposureModules(t *testing.T) {
const netrc = "../../modules/recon/netrc-exposure.yaml"
const pgpass = "../../modules/recon/pgpass-exposure.yaml"
const mycnf = "../../modules/recon/mysql-client-config-exposure.yaml"
netrcBody := "machine api.example.com\n login deploy\n password s3cr3tP@ss\n" +
"machine ftp.example.com\n login anon\n password anon@site\n"
pgpassBody := "db.example.com:5432:appdb:appuser:Sup3rSecret\n*:*:*:replication:replpass\n"
mycnfBody := "[client]\nuser=root\npassword=R00tPass!\nhost=127.0.0.1\nport=3306\n"
t.Run("an exposed netrc leaks the machine host", func(t *testing.T) {
res := runDotfileModule(t, netrc, 200, netrcBody)
if len(res.Findings) == 0 {
t.Fatal("expected a netrc finding")
}
if v := dotfileExtract(res, "netrc_machine"); v != "api.example.com" {
t.Errorf("netrc_machine=%q, want api.example.com", v)
}
})
t.Run("an exposed pgpass leaks the host", func(t *testing.T) {
res := runDotfileModule(t, pgpass, 200, pgpassBody)
if len(res.Findings) == 0 {
t.Fatal("expected a pgpass finding")
}
if v := dotfileExtract(res, "pgpass_host"); v != "db.example.com" {
t.Errorf("pgpass_host=%q, want db.example.com", v)
}
})
t.Run("an exposed my.cnf leaks the client user", func(t *testing.T) {
res := runDotfileModule(t, mycnf, 200, mycnfBody)
if len(res.Findings) == 0 {
t.Fatal("expected a my.cnf finding")
}
if v := dotfileExtract(res, "mysql_user"); v != "root" {
t.Errorf("mysql_user=%q, want root", v)
}
})
t.Run("prose that names machine login and password out of order is not a netrc", func(t *testing.T) {
body := "this machine requires a login; store the password securely"
if res := runDotfileModule(t, netrc, 200, body); len(res.Findings) > 0 {
t.Errorf("out of order prose should not match, got %d findings", len(res.Findings))
}
})
t.Run("an html page demonstrating a netrc is not a leak", func(t *testing.T) {
body := "<!DOCTYPE html><html><body><pre>machine api.example.com login deploy password s3cret</pre></body></html>"
if res := runDotfileModule(t, netrc, 200, body); len(res.Findings) > 0 {
t.Errorf("an html netrc tutorial should not match, got %d findings", len(res.Findings))
}
})
t.Run("a yaml db config with colon keys is not a pgpass", func(t *testing.T) {
body := "database:\n host: db.example.com\n port: 5432\n user: appuser\n password: secret\n"
if res := runDotfileModule(t, pgpass, 200, body); len(res.Findings) > 0 {
t.Errorf("a yaml db config should not match, got %d findings", len(res.Findings))
}
})
t.Run("a pgpass shaped line with a non numeric port is not flagged", func(t *testing.T) {
body := "db.example.com:default:appdb:appuser:Sup3rSecret\n"
if res := runDotfileModule(t, pgpass, 200, body); len(res.Findings) > 0 {
t.Errorf("a non numeric port should not match, got %d findings", len(res.Findings))
}
})
t.Run("a multi line config with a number field does not match across lines", func(t *testing.T) {
body := "timeout:30:seconds configured\nsee http://docs.example.com:8080 for details\n"
if res := runDotfileModule(t, pgpass, 200, body); len(res.Findings) > 0 {
t.Errorf("fields must stay on one line, got %d findings", len(res.Findings))
}
})
t.Run("an html page demonstrating a pgpass is not a leak", func(t *testing.T) {
body := "<!DOCTYPE html>\n<html><body><pre>\ndb.example.com:5432:appdb:appuser:secret\n</pre></body></html>\n"
if res := runDotfileModule(t, pgpass, 200, body); len(res.Findings) > 0 {
t.Errorf("an html pgpass tutorial should not match, got %d findings", len(res.Findings))
}
})
t.Run("a my.cnf client section without a password is not flagged", func(t *testing.T) {
body := "[client]\nuser=root\nhost=localhost\nport=3306\n"
if res := runDotfileModule(t, mycnf, 200, body); len(res.Findings) > 0 {
t.Errorf("a section without a password should not match, got %d findings", len(res.Findings))
}
})
t.Run("a password line without a my.cnf section is not flagged", func(t *testing.T) {
body := "password=hunter2\nfoo=bar\n"
if res := runDotfileModule(t, mycnf, 200, body); len(res.Findings) > 0 {
t.Errorf("a password without a section should not match, got %d findings", len(res.Findings))
}
})
t.Run("an html page demonstrating a my.cnf is not a leak", func(t *testing.T) {
body := "<!DOCTYPE html><html><body><pre>[client]\npassword=secret</pre></body></html>"
if res := runDotfileModule(t, mycnf, 200, body); len(res.Findings) > 0 {
t.Errorf("an html my.cnf tutorial should not match, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{netrc, pgpass, mycnf} {
if res := runDotfileModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{netrc, pgpass, mycnf} {
if res := runDotfileModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
+84
View File
@@ -0,0 +1,84 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
// runEnvModule runs the env exposure module end to end against a server that
// returns the same status and body for every path it requests.
func runEnvModule(t *testing.T, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule("../../modules/recon/env-file-exposure.yaml")
if err != nil {
t.Fatalf("parse: %v", err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute: %v", err)
}
return res
}
func envLeakedKey(res *modules.Result) string {
for _, f := range res.Findings {
if v := f.Extracted["leaked_key"]; v != "" {
return v
}
}
return ""
}
func TestEnvFileExposureModule(t *testing.T) {
realEnv := "APP_NAME=Acme\nAPP_KEY=base64:Zm9vYmFy\nDB_PASSWORD=s3cr3t\nMAIL_PASSWORD=hunter2\n"
htmlMentionsSecret := "<!DOCTYPE html>\n<html><head><title>Docs</title></head><body>" +
"<code>APP_KEY=base64:...</code> put DB_PASSWORD= in your .env</body></html>"
t.Run("real env body leaks", func(t *testing.T) {
res := runEnvModule(t, 200, realEnv)
if len(res.Findings) == 0 {
t.Fatal("expected a finding for a real .env body")
}
if key := envLeakedKey(res); key != "APP_KEY" {
t.Errorf("leaked_key=%q, want APP_KEY", key)
}
})
t.Run("html page mentioning a key is not a leak", func(t *testing.T) {
if res := runEnvModule(t, 200, htmlMentionsSecret); len(res.Findings) > 0 {
t.Errorf("html page should not match, got %d findings", len(res.Findings))
}
})
t.Run("secrets behind a 404 are not a leak", func(t *testing.T) {
if res := runEnvModule(t, 404, realEnv); len(res.Findings) > 0 {
t.Errorf("404 should not match, got %d findings", len(res.Findings))
}
})
}
+115 -21
View File
@@ -13,15 +13,19 @@
package modules
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"regexp"
"strings"
"sync"
"time"
"github.com/tidwall/gjson"
)
// MaxBodySize limits response body to prevent memory exhaustion.
@@ -69,7 +73,10 @@ func ExecuteHTTPModule(ctx context.Context, target string, def *YAMLModule, opts
}
// Generate requests based on paths and payloads
requests := generateHTTPRequests(target, cfg)
requests, err := generateHTTPRequests(target, cfg)
if err != nil {
return nil, err
}
// Determine thread count
threads := cfg.Threads
@@ -123,9 +130,14 @@ func ExecuteHTTPModule(ctx context.Context, target string, def *YAMLModule, opts
}
// generateHTTPRequests creates all requests based on paths and payloads.
func generateHTTPRequests(target string, cfg *HTTPConfig) []*httpRequest {
func generateHTTPRequests(target string, cfg *HTTPConfig) ([]*httpRequest, error) {
var requests []*httpRequest
paths, err := resolvePaths(cfg)
if err != nil {
return nil, err
}
// Ensure target has no trailing slash
target = strings.TrimSuffix(target, "/")
@@ -136,7 +148,7 @@ func generateHTTPRequests(target string, cfg *HTTPConfig) []*httpRequest {
// If no payloads, just use paths directly
if len(cfg.Payloads) == 0 {
for _, path := range cfg.Paths {
for _, path := range paths {
url := substituteVariables(path, target, "")
requests = append(requests, &httpRequest{
Method: method,
@@ -146,29 +158,82 @@ func generateHTTPRequests(target string, cfg *HTTPConfig) []*httpRequest {
Original: path,
})
}
return requests
return requests, nil
}
// pitchfork pairs path[i] with payload[i] and stops at the shorter list;
// clusterbomb (default) crosses every path with every payload.
if strings.EqualFold(cfg.Attack, "pitchfork") {
n := len(cfg.Paths)
n := len(paths)
if len(cfg.Payloads) < n {
n = len(cfg.Payloads)
}
for i := 0; i < n; i++ {
requests = append(requests, newPayloadRequest(method, target, cfg.Paths[i], cfg.Payloads[i], cfg))
requests = append(requests, newPayloadRequest(method, target, paths[i], cfg.Payloads[i], cfg))
}
return requests
return requests, nil
}
for _, path := range cfg.Paths {
for _, path := range paths {
for _, payload := range cfg.Payloads {
requests = append(requests, newPayloadRequest(method, target, path, payload, cfg))
}
}
return requests
return requests, nil
}
// resolvePaths expands a wordlist over any {{word}} path templates so one
// "{{BaseURL}}/{{word}}" path fuzzes the whole list; paths without {{word}}
// pass through literally. no wordlist leaves cfg.Paths untouched.
func resolvePaths(cfg *HTTPConfig) ([]string, error) {
if cfg.Wordlist == "" {
return cfg.Paths, nil
}
words, err := loadWordlist(cfg.Wordlist)
if err != nil {
return nil, err
}
var paths []string
for _, path := range cfg.Paths {
if !strings.Contains(path, "{{word}}") && !strings.Contains(path, "{{Word}}") {
paths = append(paths, path)
continue
}
for _, word := range words {
expanded := strings.ReplaceAll(path, "{{word}}", word)
expanded = strings.ReplaceAll(expanded, "{{Word}}", word)
paths = append(paths, expanded)
}
}
return paths, nil
}
// loadWordlist reads non-empty lines from a local wordlist file, mirroring the
// dirlist scanner's scanLines so a converted module fuzzes the identical words.
func loadWordlist(path string) ([]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open wordlist %q: %w", path, err)
}
defer f.Close()
var words []string
scanner := bufio.NewScanner(f)
scanner.Split(bufio.ScanLines)
for scanner.Scan() {
if line := scanner.Text(); line != "" {
words = append(words, line)
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("read wordlist %q: %w", path, err)
}
return words, nil
}
// newPayloadRequest builds one request with the path and body templates
@@ -239,45 +304,63 @@ func executeHTTPRequest(ctx context.Context, client *http.Client, r *httpRequest
bodyStr := string(respBody)
// Check matchers
if !checkMatchers(cfg.Matchers, resp, bodyStr) {
if !checkMatchers(cfg.Matchers, cfg.MatchersCondition, resp, bodyStr) {
return Finding{}, false
}
// Extract data
extracted := runExtractors(cfg.Extractors, resp, bodyStr)
// favicon-only matches fire on binary icon bytes; report the hash, not the body.
evidence := truncateEvidence(bodyStr)
if fav, ok := faviconEvidence(cfg.Matchers, bodyStr); ok {
evidence = fav
}
return Finding{
URL: r.URL,
Severity: severity,
Evidence: truncateEvidence(bodyStr),
Evidence: evidence,
Extracted: extracted,
}, true
}
// checkMatchers evaluates all matchers against the response.
func checkMatchers(matchers []Matcher, resp *http.Response, body string) bool {
// checkMatchers combines matchers with condition "and" (default, all match) or "or" (any).
func checkMatchers(matchers []Matcher, condition string, resp *http.Response, body string) bool {
if len(matchers) == 0 {
return false
}
// Default to AND condition across matchers
or := strings.EqualFold(condition, "or")
for i := range matchers {
matched := checkMatcher(&matchers[i], resp, body)
if matchers[i].Negative {
matched = !matched
}
if !matched {
return false // AND logic
if or && matched {
return true
}
if !or && !matched {
return false
}
}
return true
// and: all matched; or: none matched.
return !or
}
// validateMatchersCondition rejects a matchers-condition that is not "", "and", or "or".
func validateMatchersCondition(condition string) error {
switch strings.ToLower(condition) {
case "", "and", "or":
return nil
default:
return fmt.Errorf("invalid matchers-condition %q (want \"and\" or \"or\")", condition)
}
}
// checkMatcher evaluates a single matcher.
func checkMatcher(m *Matcher, resp *http.Response, body string) bool {
part := getPart(m.Part, resp, body)
switch m.Type {
case "status":
for _, status := range m.Status {
@@ -288,10 +371,13 @@ func checkMatcher(m *Matcher, resp *http.Response, body string) bool {
return false
case "word":
return checkWords(part, m.Words, m.Condition)
return checkWords(getPart(m.Part, resp, body), m.Words, m.Condition)
case "regex":
return checkRegex(part, m.Regex, m.Condition)
return checkRegex(getPart(m.Part, resp, body), m.Regex, m.Condition)
case "favicon":
return checkFaviconHash(body, m.Hash)
case "size":
// size matches the response body length against any listed value.
@@ -416,6 +502,14 @@ func runExtractors(extractors []Extractor, resp *http.Response, body string) map
}
result[key] = strings.Join(v, ", ")
}
case "json":
part := getPart(e.Part, resp, body)
for _, path := range e.JSON {
if r := gjson.Get(part, path); r.Exists() {
result[e.Name] = r.String()
break
}
}
}
}
+45 -1
View File
@@ -17,10 +17,12 @@ import (
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/httpx"
"github.com/vmfunc/sif/internal/httpx"
)
const testTimeout = 5 * time.Second
@@ -322,6 +324,48 @@ func TestWrapperExecuteRoutesByType(t *testing.T) {
})
}
// TestExecuteHTTPModuleWordlist proves a {{word}} path templated against a local
// wordlist drives one real request per word, and only the path that exists fires.
func TestExecuteHTTPModuleWordlist(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/admin" {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
list := filepath.Join(t.TempDir(), "words.txt")
if err := os.WriteFile(list, []byte("login\nadmin\nbackup\n"), 0o600); err != nil {
t.Fatal(err)
}
def := &YAMLModule{
ID: "test-wordlist",
Type: TypeHTTP,
Info: YAMLModuleInfo{Severity: "low"},
HTTP: &HTTPConfig{
Method: "GET",
Wordlist: list,
Paths: []string{"{{BaseURL}}/{{word}}"},
Matchers: []Matcher{{Type: "status", Status: []int{200}}},
},
}
opts := Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)}
res, err := ExecuteHTTPModule(context.Background(), srv.URL, def, opts)
if err != nil {
t.Fatalf("execute: %v", err)
}
if len(res.Findings) != 1 {
t.Fatalf("got %d findings, want 1 (only /admin exists)", len(res.Findings))
}
if got := res.Findings[0].URL; got != srv.URL+"/admin" {
t.Errorf("finding url = %q, want %q", got, srv.URL+"/admin")
}
}
func TestTruncateEvidence(t *testing.T) {
short := "short evidence"
if got := truncateEvidence(short); got != short {
+82
View File
@@ -0,0 +1,82 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package modules
import (
"fmt"
"math"
"github.com/vmfunc/sif/internal/fingerprint"
)
// checkFaviconHash reports whether the body's shodan mmh3 hash matches any
// configured value. only the body (the icon) is hashed; part is ignored.
func checkFaviconHash(body string, want []int64) bool {
if len(want) == 0 {
return false
}
got := fingerprint.FaviconHash([]byte(body))
for _, w := range want {
if n, ok := normalizeFaviconHash(w); ok && n == got {
return true
}
}
return false
}
// normalizeFaviconHash folds a hash to the signed int32 shodan stores, accepting
// either 32-bit form so a signed or unsigned value pastes in as-is. out-of-range
// values are rejected so a stray number can't wrap into a false match.
func normalizeFaviconHash(v int64) (int32, bool) {
if v < math.MinInt32 || v > math.MaxUint32 {
return 0, false
}
return int32(uint32(v)), true //nolint:gosec // intentional 32-bit fold to shodan's signed form
}
// faviconEvidence gives the hash as evidence for a favicon-only finding, and
// nothing when a word/regex matcher is present so its body evidence stands.
func faviconEvidence(matchers []Matcher, body string) (string, bool) {
favicon := false
for i := range matchers {
switch matchers[i].Type {
case "word", "regex":
return "", false
case "favicon":
favicon = true
}
}
if !favicon {
return "", false
}
return fmt.Sprintf("favicon mmh3=%d", fingerprint.FaviconHash([]byte(body))), true
}
// validateMatchers fails favicon matchers that would silently never fire (no
// hash, or one out of 32-bit range) at load rather than at match time.
func validateMatchers(matchers []Matcher) error {
for i := range matchers {
if matchers[i].Type != "favicon" {
continue
}
if len(matchers[i].Hash) == 0 {
return fmt.Errorf("favicon matcher requires at least one hash")
}
for _, h := range matchers[i].Hash {
if _, ok := normalizeFaviconHash(h); !ok {
return fmt.Errorf("favicon hash %d out of range (use a signed int32 or unsigned uint32 value)", h)
}
}
}
return nil
}
+191
View File
@@ -0,0 +1,191 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package modules
import (
"context"
"fmt"
"math"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/vmfunc/sif/internal/fingerprint"
"github.com/vmfunc/sif/internal/httpx"
)
// faviconFixture hashes to a negative int32, so its signed and unsigned forms
// differ and the unsigned-match case below actually exercises the fold.
var faviconFixture = []byte(strings.Repeat("sif-favicon-golden-test-bytes-", 8))
func TestCheckMatcherFavicon(t *testing.T) {
body := string(faviconFixture)
signed := int64(fingerprint.FaviconHash(faviconFixture))
if signed >= 0 {
t.Fatalf("fixture must hash to a negative int32 for the unsigned case to be meaningful, got %d", signed)
}
unsigned := int64(uint32(fingerprint.FaviconHash(faviconFixture)))
tests := []struct {
name string
hashes []int64
expect bool
}{
{name: "signed match", hashes: []int64{signed}, expect: true},
{name: "unsigned match", hashes: []int64{unsigned}, expect: true},
{name: "one of many", hashes: []int64{1, 2, signed}, expect: true},
{name: "no match", hashes: []int64{1, 2, 3}, expect: false},
{name: "empty list", hashes: nil, expect: false},
{name: "out-of-range ignored", hashes: []int64{1 << 40}, expect: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := &Matcher{Type: "favicon", Hash: tt.hashes}
resp := fakeResponse(t, 200, nil)
if got := checkMatcher(m, resp, body); got != tt.expect {
t.Errorf("checkMatcher favicon = %v, want %v", got, tt.expect)
}
})
}
}
func TestNormalizeFaviconHash(t *testing.T) {
tests := []struct {
name string
in int64
want int32
wantOK bool
}{
{name: "signed passthrough", in: -235701012, want: -235701012, wantOK: true},
{name: "unsigned folds to signed", in: 4059266284, want: -235701012, wantOK: true},
{name: "positive in range", in: 116323821, want: 116323821, wantOK: true},
{name: "min int32", in: math.MinInt32, want: math.MinInt32, wantOK: true},
{name: "max uint32 folds to -1", in: math.MaxUint32, want: -1, wantOK: true},
{name: "above uint32 rejected", in: math.MaxUint32 + 1, wantOK: false},
{name: "below int32 rejected", in: math.MinInt32 - 1, wantOK: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := normalizeFaviconHash(tt.in)
if ok != tt.wantOK {
t.Fatalf("ok = %v, want %v", ok, tt.wantOK)
}
if ok && got != tt.want {
t.Errorf("normalizeFaviconHash(%d) = %d, want %d", tt.in, got, tt.want)
}
})
}
}
func TestFaviconEvidence(t *testing.T) {
body := string(faviconFixture)
hashLine := fmt.Sprintf("favicon mmh3=%d", fingerprint.FaviconHash(faviconFixture))
tests := []struct {
name string
matchers []Matcher
want string
wantOK bool
}{
{name: "favicon only", matchers: []Matcher{{Type: "favicon"}}, want: hashLine, wantOK: true},
{name: "favicon with status", matchers: []Matcher{{Type: "status"}, {Type: "favicon"}}, want: hashLine, wantOK: true},
{name: "favicon with word keeps body", matchers: []Matcher{{Type: "word"}, {Type: "favicon"}}, wantOK: false},
{name: "favicon with regex keeps body", matchers: []Matcher{{Type: "regex"}, {Type: "favicon"}}, wantOK: false},
{name: "no favicon matcher", matchers: []Matcher{{Type: "status"}}, wantOK: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := faviconEvidence(tt.matchers, body)
if ok != tt.wantOK {
t.Fatalf("ok = %v, want %v", ok, tt.wantOK)
}
if ok && got != tt.want {
t.Errorf("evidence = %q, want %q", got, tt.want)
}
})
}
}
func TestValidateMatchers(t *testing.T) {
tests := []struct {
name string
matchers []Matcher
wantErr bool
}{
{name: "valid signed", matchers: []Matcher{{Type: "favicon", Hash: []int64{-235701012}}}, wantErr: false},
{name: "valid unsigned", matchers: []Matcher{{Type: "favicon", Hash: []int64{4059266284}}}, wantErr: false},
{name: "favicon with no hash", matchers: []Matcher{{Type: "favicon"}}, wantErr: true},
{name: "out-of-range hash", matchers: []Matcher{{Type: "favicon", Hash: []int64{99999999999}}}, wantErr: true},
{name: "non-favicon ignored", matchers: []Matcher{{Type: "word", Words: []string{"x"}}}, wantErr: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := validateMatchers(tt.matchers); (err != nil) != tt.wantErr {
t.Errorf("validateMatchers err = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
// favicon composes with the negative flag like any other matcher.
func TestCheckMatcherFaviconNegative(t *testing.T) {
signed := int64(fingerprint.FaviconHash(faviconFixture))
matchers := []Matcher{{Type: "favicon", Hash: []int64{signed}, Negative: true}}
resp := fakeResponse(t, 200, nil)
if checkMatchers(matchers, "", resp, string(faviconFixture)) {
t.Error("negative favicon matcher should not match its own hash")
}
}
// drives the full executor: fetch favicon, match on its hash, report the hash.
func TestExecuteHTTPModuleFavicon(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/favicon.ico" {
w.Header().Set("Content-Type", "image/x-icon")
_, _ = w.Write(faviconFixture)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
// unsigned form must still match end to end
unsigned := int64(uint32(fingerprint.FaviconHash(faviconFixture)))
def := &YAMLModule{
ID: "favicon-fp",
Type: TypeHTTP,
Info: YAMLModuleInfo{Severity: "info"},
HTTP: &HTTPConfig{
Method: "GET",
Paths: []string{"{{BaseURL}}/favicon.ico"},
Matchers: []Matcher{
{Type: "status", Status: []int{200}},
{Type: "favicon", Hash: []int64{unsigned}},
},
},
}
opts := Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)}
result, err := ExecuteHTTPModule(context.Background(), srv.URL, def, opts)
if err != nil {
t.Fatalf("ExecuteHTTPModule: %v", err)
}
if len(result.Findings) != 1 {
t.Fatalf("got %d findings, want 1", len(result.Findings))
}
wantEvidence := fmt.Sprintf("favicon mmh3=%d", fingerprint.FaviconHash(faviconFixture))
if got := result.Findings[0].Evidence; got != wantEvidence {
t.Errorf("evidence = %q, want %q", got, wantEvidence)
}
}
@@ -0,0 +1,93 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
func runGrafanaModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func grafanaExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestGrafanaAnonymousExposureModule(t *testing.T) {
const grafana = "../../modules/recon/grafana-anonymous-exposure.yaml"
t.Run("an anonymous search result is flagged with a dashboard title", func(t *testing.T) {
body := `[{"id":1,"uid":"abc123","title":"Production Overview","uri":"db/production-overview",` +
`"url":"/d/abc123/production-overview","slug":"","type":"dash-db","tags":["prod"],` +
`"isStarred":false,"sortMeta":0}]`
res := runGrafanaModule(t, grafana, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a grafana finding")
}
if v := grafanaExtract(res, "grafana_dashboard"); v != "Production Overview" {
t.Errorf("grafana_dashboard=%q, want Production Overview", v)
}
})
t.Run("a folder-only result is not flagged", func(t *testing.T) {
body := `[{"id":1,"uid":"f","title":"General","uri":"db/general","url":"/dashboards/f/general",` +
`"type":"dash-folder","isStarred":false}]`
if res := runGrafanaModule(t, grafana, 200, body); len(res.Findings) > 0 {
t.Errorf("a folder-only search should not match, got %d findings", len(res.Findings))
}
})
t.Run("a dash-db result without isStarred is not flagged", func(t *testing.T) {
body := `[{"uid":"abc","title":"x","uri":"db/x","type":"dash-db"}]`
if res := runGrafanaModule(t, grafana, 200, body); len(res.Findings) > 0 {
t.Errorf("a partial search blob should not match, got %d findings", len(res.Findings))
}
})
t.Run("a login-required grafana is not flagged", func(t *testing.T) {
body := `{"message":"Unauthorized"}`
if res := runGrafanaModule(t, grafana, 401, body); len(res.Findings) > 0 {
t.Errorf("a 401 grafana should not match, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
if res := runGrafanaModule(t, grafana, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("a plain 200 body should not match, got %d findings", len(res.Findings))
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
if res := runGrafanaModule(t, grafana, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("a 404 should not match, got %d findings", len(res.Findings))
}
})
}
@@ -0,0 +1,128 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
const zkMonitorBody = `{"command":"monitor","error":null,"version":"3.9.2-a8d7f3e",` +
`"server_state":"leader","avg_latency":0,"max_latency":12,"min_latency":0,` +
`"packets_received":1532,"packets_sent":1531,"num_alive_connections":3,` +
`"outstanding_requests":0,"znode_count":127,"watch_count":4,"ephemerals_count":2,` +
`"approximate_data_size":45219,"open_file_descriptor_count":67,"max_file_descriptor_count":1048576}`
const nameNodeInfoBody = `{"beans":[{"name":"Hadoop:service=NameNode,name=NameNodeInfo",` +
`"modelerType":"org.apache.hadoop.hdfs.server.namenode.FSNamesystem",` +
`"SoftwareVersion":"3.3.6","Version":"3.3.6, rUNKNOWN","Total":2147483648,"Free":1073741824,` +
`"Safemode":"","ClusterId":"CID-abc123","BlockPoolId":"BP-998-10.0.0.1-170",` +
`"LiveNodes":"{\"dn1.hdfs.internal:9866\":{\"infoAddr\":\"10.0.0.2:9864\"}}",` +
`"DeadNodes":"{}","DecomNodes":"{}","TotalBlocks":1024,"TotalFiles":512}]}`
func runHadoopZKModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func hadoopZKExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestHadoopZooKeeperExposureModules(t *testing.T) {
const zk = "../../modules/recon/zookeeper-admin-exposure.yaml"
const namenode = "../../modules/recon/hadoop-namenode-exposure.yaml"
t.Run("an open zookeeper monitor is flagged with the version", func(t *testing.T) {
res := runHadoopZKModule(t, zk, 200, zkMonitorBody)
if len(res.Findings) == 0 {
t.Fatal("expected a zookeeper finding")
}
if v := hadoopZKExtract(res, "zookeeper_version"); v != "3.9.2-a8d7f3e" {
t.Errorf("zookeeper_version=%q, want 3.9.2-a8d7f3e", v)
}
})
t.Run("a different adminserver command is not flagged", func(t *testing.T) {
body := `{"command":"configuration","error":null,"version":"3.9.2","clientPort":2181}`
if res := runHadoopZKModule(t, zk, 200, body); len(res.Findings) > 0 {
t.Errorf("a non-monitor command should not match, got %d findings", len(res.Findings))
}
})
t.Run("a firewalled or absent adminserver is not flagged", func(t *testing.T) {
if res := runHadoopZKModule(t, zk, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("a 404 should not match zookeeper, got %d findings", len(res.Findings))
}
})
t.Run("a namenode jmx body does not match the zookeeper module", func(t *testing.T) {
if res := runHadoopZKModule(t, zk, 200, nameNodeInfoBody); len(res.Findings) > 0 {
t.Errorf("a namenode body should not match zookeeper, got %d findings", len(res.Findings))
}
})
t.Run("an open namenode jmx is flagged with the hdfs version", func(t *testing.T) {
res := runHadoopZKModule(t, namenode, 200, nameNodeInfoBody)
if len(res.Findings) == 0 {
t.Fatal("expected a namenode finding")
}
if v := hadoopZKExtract(res, "hdfs_version"); v != "3.3.6" {
t.Errorf("hdfs_version=%q, want 3.3.6", v)
}
})
t.Run("a different hadoop jmx bean is not flagged", func(t *testing.T) {
body := `{"beans":[{"name":"Hadoop:service=DataNode,name=DataNodeInfo",` +
`"SoftwareVersion":"3.3.6","LiveNodes":"","DeadNodes":""}]}`
if res := runHadoopZKModule(t, namenode, 200, body); len(res.Findings) > 0 {
t.Errorf("a non-NameNodeInfo bean should not match, got %d findings", len(res.Findings))
}
})
t.Run("a kerberos-secured namenode is not flagged", func(t *testing.T) {
if res := runHadoopZKModule(t, namenode, 401, "Authentication required"); len(res.Findings) > 0 {
t.Errorf("a 401 namenode should not match, got %d findings", len(res.Findings))
}
})
t.Run("a zookeeper monitor body does not match the namenode module", func(t *testing.T) {
if res := runHadoopZKModule(t, namenode, 200, zkMonitorBody); len(res.Findings) > 0 {
t.Errorf("a zookeeper body should not match namenode, got %d findings", len(res.Findings))
}
})
t.Run("plain 200 bodies are not leaks", func(t *testing.T) {
if res := runHadoopZKModule(t, zk, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("a plain 200 should not match zookeeper, got %d findings", len(res.Findings))
}
if res := runHadoopZKModule(t, namenode, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("a plain 200 should not match namenode, got %d findings", len(res.Findings))
}
})
}
@@ -7,7 +7,7 @@ import (
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
"github.com/vmfunc/sif/internal/modules"
)
func runHTTPDBModule(t *testing.T, file string, status int, body string) *modules.Result {
+195
View File
@@ -0,0 +1,195 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
func runImageGenModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func imageGenExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestImageGenExposureModules(t *testing.T) {
const comfyui = "../../modules/recon/comfyui-api-exposure.yaml"
const a1111 = "../../modules/recon/automatic1111-api-exposure.yaml"
const fooocus = "../../modules/recon/fooocus-api-exposure.yaml"
const iopaint = "../../modules/recon/iopaint-api-exposure.yaml"
comfyStats := `{"system":{"os":"posix","ram_total":67430219776,"ram_free":12345678,` +
`"comfyui_version":"0.3.40","python_version":"3.11.9","pytorch_version":"2.3.1",` +
`"embedded_python":false,"argv":["main.py"]},"devices":[{"name":"cuda:0 NVIDIA RTX 4090",` +
`"type":"cuda","index":0,"vram_total":25757220864,"vram_free":24000000000,` +
`"torch_vram_total":268435456,"torch_vram_free":260000000}]}`
a1111Models := `[{"title":"sd_xl_base_1.0.safetensors [31e35c80fc]","model_name":"sd_xl_base_1.0",` +
`"hash":"31e35c80fc","sha256":"31e35c80fc4829d14f90153f4c74cd59c90b779f6afe05a74cd6120b893f7e5b",` +
`"filename":"/home/sd/models/Stable-diffusion/sd_xl_base_1.0.safetensors","config":null}]`
fooocusModels := `{"model_filenames":["juggernautXL_v8.safetensors","sd_xl_base_1.0.safetensors"],` +
`"lora_filenames":["sdxl_lcm_lora.safetensors"]}`
iopaintConfig := `{"plugins":[{"name":"RemoveBG","support_gen_image":true,"support_gen_mask":false}],` +
`"modelInfos":[{"name":"lama","path":"lama","model_type":"inpaint"}],` +
`"removeBGModel":"briaai/RMBG-1.4","removeBGModels":["briaai/RMBG-1.4","u2net"],` +
`"realesrganModel":"realesr-general-x4v3","realesrganModels":["realesr-general-x4v3"],` +
`"interactiveSegModel":"sam2_1_tiny","interactiveSegModels":["vit_b","sam2_1_tiny"],` +
`"enableFileManager":true,"enableAutoSaving":false,"enableControlnet":false,` +
`"controlnetMethod":null,"disableModelSwitch":false,"isDesktop":false,` +
`"samplers":["DPM++ 2M","Euler","Euler a"]}`
t.Run("a comfyui system_stats is flagged with its version", func(t *testing.T) {
res := runImageGenModule(t, comfyui, 200, comfyStats)
if len(res.Findings) == 0 {
t.Fatal("expected a comfyui finding")
}
if v := imageGenExtract(res, "comfyui_version"); v != "0.3.40" {
t.Errorf("comfyui_version=%q, want 0.3.40", v)
}
})
t.Run("a system_stats without comfyui keys is not flagged", func(t *testing.T) {
body := `{"system":{"os":"linux","ram_total":123},"devices":[]}`
if res := runImageGenModule(t, comfyui, 200, body); len(res.Findings) > 0 {
t.Errorf("a generic system stats should not match comfyui, got %d findings", len(res.Findings))
}
})
t.Run("a comfyui version without device memory is not flagged", func(t *testing.T) {
body := `{"system":{"comfyui_version":"0.3.40"}}`
if res := runImageGenModule(t, comfyui, 200, body); len(res.Findings) > 0 {
t.Errorf("a version-only body should not match comfyui, got %d findings", len(res.Findings))
}
})
t.Run("an automatic1111 sd-models list is flagged with its checkpoint", func(t *testing.T) {
res := runImageGenModule(t, a1111, 200, a1111Models)
if len(res.Findings) == 0 {
t.Fatal("expected an automatic1111 finding")
}
if v := imageGenExtract(res, "sd_model_name"); v != "sd_xl_base_1.0" {
t.Errorf("sd_model_name=%q, want sd_xl_base_1.0", v)
}
})
t.Run("a list with a model_name but no filename is not flagged as a1111", func(t *testing.T) {
body := `[{"title":"some entry","model_name":"thing"}]`
if res := runImageGenModule(t, a1111, 200, body); len(res.Findings) > 0 {
t.Errorf("a partial entry should not match automatic1111, got %d findings", len(res.Findings))
}
})
t.Run("a generic titled list is not flagged as a1111", func(t *testing.T) {
body := `[{"title":"My Blog Post","filename":"post.md","author":"someone"}]`
if res := runImageGenModule(t, a1111, 200, body); len(res.Findings) > 0 {
t.Errorf("a generic list should not match automatic1111, got %d findings", len(res.Findings))
}
})
t.Run("a model_name and filename without a title is not flagged as a1111", func(t *testing.T) {
body := `[{"model_name":"thing","filename":"/tmp/thing.bin"}]`
if res := runImageGenModule(t, a1111, 200, body); len(res.Findings) > 0 {
t.Errorf("a titleless entry should not match automatic1111, got %d findings", len(res.Findings))
}
})
t.Run("a fooocus-api all-models is flagged with its checkpoint", func(t *testing.T) {
res := runImageGenModule(t, fooocus, 200, fooocusModels)
if len(res.Findings) == 0 {
t.Fatal("expected a fooocus finding")
}
if v := imageGenExtract(res, "fooocus_model"); v != "juggernautXL_v8.safetensors" {
t.Errorf("fooocus_model=%q, want juggernautXL_v8.safetensors", v)
}
})
t.Run("a body without model_filenames is not flagged as fooocus", func(t *testing.T) {
if res := runImageGenModule(t, fooocus, 200, `{"lora_filenames":["x.safetensors"]}`); len(res.Findings) > 0 {
t.Errorf("a body without model_filenames should not match fooocus, got %d findings", len(res.Findings))
}
})
t.Run("a body without lora_filenames is not flagged as fooocus", func(t *testing.T) {
if res := runImageGenModule(t, fooocus, 200, `{"model_filenames":["x.safetensors"]}`); len(res.Findings) > 0 {
t.Errorf("a body without lora_filenames should not match fooocus, got %d findings", len(res.Findings))
}
})
t.Run("an iopaint server-config is flagged with its file-manager state", func(t *testing.T) {
res := runImageGenModule(t, iopaint, 200, iopaintConfig)
if len(res.Findings) == 0 {
t.Fatal("expected an iopaint finding")
}
if v := imageGenExtract(res, "iopaint_file_manager"); v != "true" {
t.Errorf("iopaint_file_manager=%q, want true", v)
}
})
t.Run("a config without interactiveSegModel is not flagged as iopaint", func(t *testing.T) {
body := `{"modelInfos":[],"enableFileManager":true,"disableModelSwitch":false,"samplers":[]}`
if res := runImageGenModule(t, iopaint, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without interactiveSegModel should not match iopaint, got %d findings", len(res.Findings))
}
})
t.Run("a config without enableFileManager is not flagged as iopaint", func(t *testing.T) {
body := `{"interactiveSegModel":"vit_b","disableModelSwitch":false,"samplers":[]}`
if res := runImageGenModule(t, iopaint, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without enableFileManager should not match iopaint, got %d findings", len(res.Findings))
}
})
t.Run("a config without disableModelSwitch is not flagged as iopaint", func(t *testing.T) {
body := `{"interactiveSegModel":"vit_b","enableFileManager":true,"samplers":[]}`
if res := runImageGenModule(t, iopaint, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without disableModelSwitch should not match iopaint, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{comfyui, a1111, fooocus, iopaint} {
if res := runImageGenModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{comfyui, a1111, fooocus, iopaint} {
if res := runImageGenModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
@@ -0,0 +1,151 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
func runInfraModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func infraExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestInfraConfigExposureModules(t *testing.T) {
const terraform = "../../modules/recon/terraform-state-exposure.yaml"
const kubeconfig = "../../modules/recon/kubeconfig-exposure.yaml"
const compose = "../../modules/recon/docker-compose-exposure.yaml"
t.Run("terraform state leaks the terraform version", func(t *testing.T) {
body := `{"version":4,"terraform_version":"1.5.7","serial":12,"lineage":"a1b2",` +
`"outputs":{},"resources":[{"type":"aws_db_instance","name":"main"}]}`
res := runInfraModule(t, terraform, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a terraform state finding")
}
if v := infraExtract(res, "terraform_version"); v != "1.5.7" {
t.Errorf("terraform_version=%q, want 1.5.7", v)
}
})
t.Run("terraform state with a pre-release version still extracts the number", func(t *testing.T) {
body := `{"version":4,"terraform_version":"0.12.0-beta1","serial":1,"lineage":"x","resources":[]}`
res := runInfraModule(t, terraform, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a terraform state finding")
}
if v := infraExtract(res, "terraform_version"); v != "0.12.0" {
t.Errorf("terraform_version=%q, want 0.12.0", v)
}
})
t.Run("kubeconfig leaks the cluster server", func(t *testing.T) {
body := "apiVersion: v1\nkind: Config\nclusters:\n- cluster:\n" +
" server: https://10.0.0.1:6443\n name: prod\ncurrent-context: prod\n"
res := runInfraModule(t, kubeconfig, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a kubeconfig finding")
}
if v := infraExtract(res, "cluster_server"); v != "https://10.0.0.1:6443" {
t.Errorf("cluster_server=%q, want https://10.0.0.1:6443", v)
}
})
t.Run("docker compose leaks the image version", func(t *testing.T) {
body := "version: \"3.8\"\nservices:\n web:\n image: nginx:1.25\n ports:\n" +
" - \"80:80\"\n db:\n image: postgres:15\n"
res := runInfraModule(t, compose, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a docker compose finding")
}
if v := infraExtract(res, "compose_image"); v != "nginx:1.25" {
t.Errorf("compose_image=%q, want nginx:1.25", v)
}
})
t.Run("a terraform_version mention without the state structure is not a leak", func(t *testing.T) {
body := `{"terraform_version":"1.5.7"}`
if res := runInfraModule(t, terraform, 200, body); len(res.Findings) > 0 {
t.Errorf("a bare version mention should not match, got %d findings", len(res.Findings))
}
})
t.Run("a kind Config mention without the kubeconfig structure is not a leak", func(t *testing.T) {
body := "kind: Config\ndescription: an unrelated document\n"
if res := runInfraModule(t, kubeconfig, 200, body); len(res.Findings) > 0 {
t.Errorf("a bare kind mention should not match, got %d findings", len(res.Findings))
}
})
t.Run("a services key without a service definition is not a leak", func(t *testing.T) {
body := "services: enabled\nnote: not a compose file\n"
if res := runInfraModule(t, compose, 200, body); len(res.Findings) > 0 {
t.Errorf("a bare services key should not match, got %d findings", len(res.Findings))
}
})
t.Run("an html page carrying the markers is not a leak", func(t *testing.T) {
body := `<html><head><title>x</title></head><body>"terraform_version":"1.5.7" "lineage":"a1b2"</body></html>`
if res := runInfraModule(t, terraform, 200, body); len(res.Findings) > 0 {
t.Errorf("an html page should not match, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{terraform, kubeconfig, compose} {
if res := runInfraModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{terraform, kubeconfig, compose} {
if res := runInfraModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
@@ -0,0 +1,129 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
func runInfraControlplaneModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func controlplaneExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestInfraControlplaneExposureModules(t *testing.T) {
const traefik = "../../modules/recon/traefik-api-exposure.yaml"
const nomad = "../../modules/recon/nomad-agent-exposure.yaml"
const portainer = "../../modules/recon/portainer-status-exposure.yaml"
t.Run("a traefik overview is flagged with its first provider", func(t *testing.T) {
body := `{"http":{"routers":{"total":12,"warnings":0,"errors":1},"services":{"total":8,"warnings":0,` +
`"errors":0},"middlewares":{"total":5,"warnings":0,"errors":0}},"tcp":{"routers":{"total":0},` +
`"services":{"total":0}},"udp":{"routers":{"total":0},"services":{"total":0}},` +
`"features":{"tracing":"Noop","metrics":"Prometheus","accessLog":true},"providers":["Docker","File"]}`
res := runInfraControlplaneModule(t, traefik, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a traefik finding")
}
if v := controlplaneExtract(res, "traefik_provider"); v != "Docker" {
t.Errorf("traefik_provider=%q, want Docker", v)
}
})
t.Run("a routing summary without features is not flagged as traefik", func(t *testing.T) {
body := `{"http":{"routers":{"total":1}},"providers":["Docker"]}`
if res := runInfraControlplaneModule(t, traefik, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without features should not match traefik, got %d findings", len(res.Findings))
}
})
t.Run("an open nomad agent self is flagged with its version", func(t *testing.T) {
body := `{"config":{"Region":"global","Datacenter":"dc1","BindAddr":"0.0.0.0"},` +
`"member":{"Name":"node1.global","Addr":"10.0.0.5","Port":4648,` +
`"Tags":{"role":"nomad","region":"global","dc":"dc1","build":"1.7.2","vsn":"1"},"Status":"alive"},` +
`"stats":{"nomad":{"server":"true"},"runtime":{"version":"go1.21"}}}`
res := runInfraControlplaneModule(t, nomad, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a nomad finding")
}
if v := controlplaneExtract(res, "nomad_version"); v != "1.7.2" {
t.Errorf("nomad_version=%q, want 1.7.2", v)
}
})
t.Run("an acl-enabled nomad returns 403 and is not flagged", func(t *testing.T) {
if res := runInfraControlplaneModule(t, nomad, 403, `{"errors":["Permission denied"]}`); len(res.Findings) > 0 {
t.Errorf("a 403 from an acl-enabled nomad should not match, got %d findings", len(res.Findings))
}
})
t.Run("a config+stats body without member is not flagged as nomad", func(t *testing.T) {
body := `{"config":{"a":1},"stats":{"b":2}}`
if res := runInfraControlplaneModule(t, nomad, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without member should not match nomad, got %d findings", len(res.Findings))
}
})
t.Run("a portainer status is flagged with its instance id", func(t *testing.T) {
body := `{"Version":"2.19.4","InstanceID":"299ab403-70a8-4c05-92f7-bf7a994d50df"}`
res := runInfraControlplaneModule(t, portainer, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a portainer finding")
}
if v := controlplaneExtract(res, "portainer_instance_id"); v != "299ab403-70a8-4c05-92f7-bf7a994d50df" {
t.Errorf("portainer_instance_id=%q, want the uuid", v)
}
})
t.Run("a bare version body is not flagged as portainer", func(t *testing.T) {
if res := runInfraControlplaneModule(t, portainer, 200, `{"Version":"2.19.4"}`); len(res.Findings) > 0 {
t.Errorf("a bare version should not match portainer, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{traefik, nomad, portainer} {
if res := runInfraControlplaneModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{traefik, nomad, portainer} {
if res := runInfraControlplaneModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
@@ -0,0 +1,126 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
func runJobDashModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func jobDashExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestJobDashboardExposureModules(t *testing.T) {
const sidekiq = "../../modules/recon/sidekiq-web-exposure.yaml"
const flower = "../../modules/recon/celery-flower-exposure.yaml"
const rq = "../../modules/recon/rq-dashboard-exposure.yaml"
t.Run("a sidekiq stats dump is flagged with its redis version", func(t *testing.T) {
body := `{"sidekiq":{"processed":12345,"failed":67,"busy":3,"processes":2,"enqueued":10,` +
`"scheduled":5,"retries":1,"dead":0,"default_latency":0},"redis":{"redis_version":"7.2.4",` +
`"uptime_in_days":"12","connected_clients":"8","used_memory_human":"2.50M",` +
`"used_memory_peak_human":"3.10M"},"server_utc_time":"18:00:00 UTC"}`
res := runJobDashModule(t, sidekiq, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a sidekiq finding")
}
if v := jobDashExtract(res, "redis_version"); v != "7.2.4" {
t.Errorf("redis_version=%q, want 7.2.4", v)
}
})
t.Run("a bare redis-info body without default_latency is not flagged as sidekiq", func(t *testing.T) {
if res := runJobDashModule(t, sidekiq, 200, `{"redis_version":"7.2.4","server_utc_time":"x"}`); len(res.Findings) > 0 {
t.Errorf("a redis info blob should not match sidekiq, got %d findings", len(res.Findings))
}
})
t.Run("a flower workers api is flagged with the celery version", func(t *testing.T) {
body := `{"celery@worker1":{"active_queues":[{"name":"celery","exchange":{"name":"celery",` +
`"type":"direct"},"routing_key":"celery"}],"conf":{"broker_url":"redis://localhost:6379/0",` +
`"result_backend":"redis://localhost:6379/0"},"registered":["tasks.add","tasks.send_email"],` +
`"stats":{"sw_ident":"py-celery","sw_ver":"5.3.6","sw_sys":"Linux","pool":{"max-concurrency":4},` +
`"broker":{"hostname":"localhost","transport":"redis"}},"timestamp":1719345600.0}}`
res := runJobDashModule(t, flower, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a flower finding")
}
if v := jobDashExtract(res, "celery_version"); v != "5.3.6" {
t.Errorf("celery_version=%q, want 5.3.6", v)
}
})
t.Run("a worker blob without conf is not flagged as flower", func(t *testing.T) {
if res := runJobDashModule(t, flower, 200, `{"celery@w":{"active_queues":[],"registered":["tasks.add"]}}`); len(res.Findings) > 0 {
t.Errorf("a confless worker blob should not match flower, got %d findings", len(res.Findings))
}
})
t.Run("an rq queues dump is flagged with the first queue name", func(t *testing.T) {
body := `{"queues":[{"name":"default","count":42,"queued_url":"/0/view/jobs/default/queued/...",` +
`"failed_job_registry_count":3,"failed_url":"...","started_job_registry_count":1,"started_url":"...",` +
`"deferred_job_registry_count":0,"deferred_url":"...","finished_job_registry_count":100,` +
`"finished_url":"...","canceled_job_registry_count":0,"canceled_url":"...",` +
`"scheduled_job_registry_count":5,"scheduled_url":"..."}]}`
res := runJobDashModule(t, rq, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected an rq finding")
}
if v := jobDashExtract(res, "rq_queue_name"); v != "default" {
t.Errorf("rq_queue_name=%q, want default", v)
}
})
t.Run("a queues blob without the registry counts is not flagged as rq", func(t *testing.T) {
if res := runJobDashModule(t, rq, 200, `{"queues":[{"name":"q","failed_job_registry_count":0}]}`); len(res.Findings) > 0 {
t.Errorf("a partial queues blob should not match rq, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{sidekiq, flower, rq} {
if res := runJobDashModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{sidekiq, flower, rq} {
if res := runJobDashModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
+86
View File
@@ -0,0 +1,86 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package modules
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/vmfunc/sif/internal/httpx"
)
func TestRunExtractorsJSON(t *testing.T) {
const body = `{"version":"1.2.3","app":{"name":"sif"},"items":[{"id":7}]}`
resp := fakeResponse(t, 200, nil)
tests := []struct {
name string
paths []string
want string // "" means the extractor should set nothing
}{
{"top level", []string{"version"}, "1.2.3"},
{"nested", []string{"app.name"}, "sif"},
{"array index", []string{"items.0.id"}, "7"},
{"first existing wins", []string{"missing", "version"}, "1.2.3"},
{"no match", []string{"nope"}, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ex := []Extractor{{Type: "json", Name: "v", Part: "body", JSON: tt.paths}}
got := runExtractors(ex, resp, body)
if tt.want == "" {
if v, ok := got["v"]; ok {
t.Errorf("expected no extraction, got %q", v)
}
return
}
if got["v"] != tt.want {
t.Errorf("got %q, want %q", got["v"], tt.want)
}
})
}
}
func TestExecuteHTTPModuleJSONExtractor(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"9.9.9"}`))
}))
defer srv.Close()
def := &YAMLModule{
ID: "j",
Type: TypeHTTP,
Info: YAMLModuleInfo{Severity: "info"},
HTTP: &HTTPConfig{
Method: "GET",
Paths: []string{"{{BaseURL}}/"},
Matchers: []Matcher{{Type: "status", Status: []int{200}}},
Extractors: []Extractor{{Type: "json", Name: "version", Part: "body", JSON: []string{"version"}}},
},
}
opts := Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)}
res, err := ExecuteHTTPModule(context.Background(), srv.URL, def, opts)
if err != nil {
t.Fatal(err)
}
if len(res.Findings) != 1 {
t.Fatalf("got %d findings, want 1", len(res.Findings))
}
if got := res.Findings[0].Extracted["version"]; got != "9.9.9" {
t.Errorf("extracted version = %q, want 9.9.9", got)
}
}
+130
View File
@@ -0,0 +1,130 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
const kafkaUIClustersBody = `[{"name":"production","defaultCluster":true,"status":"online",` +
`"brokerCount":3,"onlinePartitionCount":128,"topicCount":42,"bytesInPerSec":10485.5,` +
`"bytesOutPerSec":20971.0,"readOnly":false,"version":"3.7.0",` +
`"features":["TOPIC_DELETION","KAFKA_CONNECT","KSQL_DB"]}]`
const kafdropOverviewBody = `{"summary":{"topicCount":42,"partitionCount":128,` +
`"underReplicatedCount":0,"preferredReplicaPercent":1.0,"brokerLeaderPartitionCount":{"1":43},` +
`"brokerPreferredLeaderPartitionCount":{"1":43},"expectedBrokerIds":[1,2,3]},` +
`"brokers":[{"id":1,"host":"kafka-0.svc.internal","port":9092,"controller":true,"rack":"r1"}],` +
`"topics":[{"name":"orders","partitions":[]}]}`
func runKafkaModule(t *testing.T, file string, status int, body string) (*modules.Result, http.Header) {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
var gotHeaders http.Header
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotHeaders = r.Header.Clone()
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res, gotHeaders
}
func kafkaExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestKafkaManagementUIExposureModules(t *testing.T) {
const kafkaUI = "../../modules/recon/kafka-ui-exposure.yaml"
const kafdrop = "../../modules/recon/kafdrop-exposure.yaml"
t.Run("an open kafka-ui /api/clusters is flagged with the version", func(t *testing.T) {
res, _ := runKafkaModule(t, kafkaUI, 200, kafkaUIClustersBody)
if len(res.Findings) == 0 {
t.Fatal("expected a kafka-ui finding")
}
if v := kafkaExtract(res, "kafka_version"); v != "3.7.0" {
t.Errorf("kafka_version=%q, want 3.7.0", v)
}
})
t.Run("a kafka-ui cluster with a non-enum status is not flagged", func(t *testing.T) {
body := `[{"name":"x","defaultCluster":true,"status":"degraded","brokerCount":1}]`
if res, _ := runKafkaModule(t, kafkaUI, 200, body); len(res.Findings) > 0 {
t.Errorf("a non-enum status should not match, got %d findings", len(res.Findings))
}
})
t.Run("a login-protected kafka-ui is not flagged", func(t *testing.T) {
if res, _ := runKafkaModule(t, kafkaUI, 401, `{"message":"Unauthorized"}`); len(res.Findings) > 0 {
t.Errorf("a 401 kafka-ui should not match, got %d findings", len(res.Findings))
}
})
t.Run("a kafdrop overview does not match the kafka-ui module", func(t *testing.T) {
if res, _ := runKafkaModule(t, kafkaUI, 200, kafdropOverviewBody); len(res.Findings) > 0 {
t.Errorf("a kafdrop body should not match kafka-ui, got %d findings", len(res.Findings))
}
})
t.Run("an open kafdrop overview is flagged with a broker host", func(t *testing.T) {
res, hdr := runKafkaModule(t, kafdrop, 200, kafdropOverviewBody)
if len(res.Findings) == 0 {
t.Fatal("expected a kafdrop finding")
}
if v := kafkaExtract(res, "kafka_broker"); v != "kafka-0.svc.internal" {
t.Errorf("kafka_broker=%q, want kafka-0.svc.internal", v)
}
if got := hdr.Get("Accept"); got != "application/json" {
t.Errorf("Accept header=%q, want application/json", got)
}
})
t.Run("a cluster overview without preferredReplicaPercent is not flagged", func(t *testing.T) {
body := `{"summary":{"topicCount":1},"brokers":[{"id":1,"host":"h"}],"topics":[]}`
if res, _ := runKafkaModule(t, kafdrop, 200, body); len(res.Findings) > 0 {
t.Errorf("a preferredReplicaPercent-less overview should not match, got %d findings", len(res.Findings))
}
})
t.Run("a basic-auth-protected kafdrop is not flagged", func(t *testing.T) {
if res, _ := runKafkaModule(t, kafdrop, 401, "Unauthorized"); len(res.Findings) > 0 {
t.Errorf("a 401 kafdrop should not match, got %d findings", len(res.Findings))
}
})
t.Run("a kafka-ui cluster list does not match the kafdrop module", func(t *testing.T) {
if res, _ := runKafkaModule(t, kafdrop, 200, kafkaUIClustersBody); len(res.Findings) > 0 {
t.Errorf("a kafka-ui body should not match kafdrop, got %d findings", len(res.Findings))
}
})
t.Run("plain 200 bodies are not leaks", func(t *testing.T) {
if res, _ := runKafkaModule(t, kafkaUI, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("a plain 200 should not match kafka-ui, got %d findings", len(res.Findings))
}
if res, _ := runKafkaModule(t, kafdrop, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("a 404 should not match kafdrop, got %d findings", len(res.Findings))
}
})
}
@@ -0,0 +1,85 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
func runGatewayModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func gatewayExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestLLMGatewayExposureModules(t *testing.T) {
const oneapi = "../../modules/recon/oneapi-status-exposure.yaml"
oneapiStatus := `{"success":true,"message":"","data":{"version":"v0.8.4","start_time":1719100000,` +
`"system_name":"New API","quota_per_unit":500000,"github_oauth":false}}`
t.Run("a oneapi status is flagged with its version", func(t *testing.T) {
res := runGatewayModule(t, oneapi, 200, oneapiStatus)
if len(res.Findings) == 0 {
t.Fatal("expected a oneapi finding")
}
if v := gatewayExtract(res, "oneapi_version"); v != "v0.8.4" {
t.Errorf("oneapi_version=%q, want v0.8.4", v)
}
})
t.Run("a body without system_name is not flagged as oneapi", func(t *testing.T) {
body := `{"data":{"start_time":1,"quota_per_unit":500000}}`
if res := runGatewayModule(t, oneapi, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without system_name should not match oneapi, got %d findings", len(res.Findings))
}
})
t.Run("a body without quota_per_unit is not flagged as oneapi", func(t *testing.T) {
body := `{"data":{"system_name":"X","start_time":1}}`
if res := runGatewayModule(t, oneapi, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without quota_per_unit should not match oneapi, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
if res := runGatewayModule(t, oneapi, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("a plain 200 body should not match oneapi, got %d findings", len(res.Findings))
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
if res := runGatewayModule(t, oneapi, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("a 404 should not match oneapi, got %d findings", len(res.Findings))
}
})
}
@@ -0,0 +1,161 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
func runGPUServingModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func gpuServingExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestLLMGPUServingExposureModules(t *testing.T) {
const triton = "../../modules/recon/triton-api-exposure.yaml"
const sglang = "../../modules/recon/sglang-api-exposure.yaml"
const torchserve = "../../modules/recon/torchserve-api-exposure.yaml"
tritonMeta := `{"name":"triton","version":"2.45.0","extensions":["classification","sequence",` +
`"model_repository","schedule_policy","model_configuration","statistics","trace","logging"]}`
sglangInfo := `{"model_path":"meta-llama/Llama-3-8B","tokenizer_path":"meta-llama/Llama-3-8B",` +
`"is_generation":true,"preferred_sampling_params":null,"weight_version":"default",` +
`"has_image_understanding":false,"has_audio_understanding":false,"model_type":"llama",` +
`"architectures":["LlamaForCausalLM"]}`
torchserveModels := `{"nextPageToken":"4","models":[{"modelName":"resnet-18","modelUrl":"resnet-18.mar"},` +
`{"modelName":"noop","modelUrl":"noop-v1.0"}]}`
t.Run("a triton metadata api is flagged with its version", func(t *testing.T) {
res := runGPUServingModule(t, triton, 200, tritonMeta)
if len(res.Findings) == 0 {
t.Fatal("expected a triton finding")
}
if v := gpuServingExtract(res, "triton_version"); v != "2.45.0" {
t.Errorf("triton_version=%q, want 2.45.0", v)
}
})
t.Run("a sglang model_info is flagged with its model path", func(t *testing.T) {
res := runGPUServingModule(t, sglang, 200, sglangInfo)
if len(res.Findings) == 0 {
t.Fatal("expected a sglang finding")
}
if v := gpuServingExtract(res, "sglang_model"); v != "meta-llama/Llama-3-8B" {
t.Errorf("sglang_model=%q, want meta-llama/Llama-3-8B", v)
}
})
t.Run("a torchserve models api is flagged with its model name", func(t *testing.T) {
res := runGPUServingModule(t, torchserve, 200, torchserveModels)
if len(res.Findings) == 0 {
t.Fatal("expected a torchserve finding")
}
if v := gpuServingExtract(res, "torchserve_model"); v != "resnet-18" {
t.Errorf("torchserve_model=%q, want resnet-18", v)
}
})
t.Run("a hugging face config with model_type is not flagged as sglang", func(t *testing.T) {
body := `{"model_type":"llama","architectures":["LlamaForCausalLM"],"hidden_size":4096}`
if res := runGPUServingModule(t, sglang, 200, body); len(res.Findings) > 0 {
t.Errorf("a model config should not match sglang, got %d findings", len(res.Findings))
}
})
t.Run("a generation flag alone is not flagged as sglang", func(t *testing.T) {
body := `{"is_generation":true,"model":"x"}`
if res := runGPUServingModule(t, sglang, 200, body); len(res.Findings) > 0 {
t.Errorf("is_generation alone should not match sglang, got %d findings", len(res.Findings))
}
})
t.Run("an image understanding flag alone is not flagged as sglang", func(t *testing.T) {
body := `{"has_image_understanding":false,"model_path":"x"}`
if res := runGPUServingModule(t, sglang, 200, body); len(res.Findings) > 0 {
t.Errorf("has_image_understanding alone should not match sglang, got %d findings", len(res.Findings))
}
})
t.Run("a model url without a page token is not flagged as torchserve", func(t *testing.T) {
body := `{"models":[{"modelName":"x","modelUrl":"x.mar"}]}`
if res := runGPUServingModule(t, torchserve, 200, body); len(res.Findings) > 0 {
t.Errorf("modelUrl without nextPageToken should not match torchserve, got %d findings", len(res.Findings))
}
})
t.Run("an ollama style models list is not flagged as torchserve", func(t *testing.T) {
body := `{"models":[{"name":"llama3:latest","model":"llama3:latest"}]}`
if res := runGPUServingModule(t, torchserve, 200, body); len(res.Findings) > 0 {
t.Errorf("an ollama model list should not match torchserve, got %d findings", len(res.Findings))
}
})
t.Run("a paginated list without a model url is not flagged as torchserve", func(t *testing.T) {
body := `{"nextPageToken":"4","items":[{"id":"a"}]}`
if res := runGPUServingModule(t, torchserve, 200, body); len(res.Findings) > 0 {
t.Errorf("a generic paginated list should not match torchserve, got %d findings", len(res.Findings))
}
})
t.Run("a kserve server that is not triton is not flagged", func(t *testing.T) {
body := `{"name":"my-model-server","version":"1.0","extensions":["model_repository"]}`
if res := runGPUServingModule(t, triton, 200, body); len(res.Findings) > 0 {
t.Errorf("a non-triton kserve server should not match, got %d findings", len(res.Findings))
}
})
t.Run("a triton name without extensions is not flagged", func(t *testing.T) {
if res := runGPUServingModule(t, triton, 200, `{"name":"triton"}`); len(res.Findings) > 0 {
t.Errorf("a name-only body should not match triton, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{triton, sglang, torchserve} {
if res := runGPUServingModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{triton, sglang, torchserve} {
if res := runGPUServingModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
@@ -0,0 +1,125 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
func runInferenceExposureModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func inferenceExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestLLMInferenceExposureModules(t *testing.T) {
const tgi = "../../modules/recon/tgi-api-exposure.yaml"
const tei = "../../modules/recon/tei-api-exposure.yaml"
tgiInfo := `{"model_id":"meta-llama/Llama-2-7b-chat-hf","model_sha":"abc","model_pipeline_tag":"text-generation",` +
`"max_concurrent_requests":128,"max_best_of":2,"max_input_tokens":4095,"max_total_tokens":4096,` +
`"max_batch_total_tokens":16384,"router":"text-generation-router","version":"2.0.4","sha":"deadbeef"}`
teiInfo := `{"model_id":"BAAI/bge-large-en-v1.5","model_sha":"abc","model_dtype":"float16",` +
`"model_type":{"embedding":{"pooling":"cls"}},"max_concurrent_requests":512,"max_input_length":512,` +
`"max_batch_tokens":16384,"max_client_batch_size":32,"tokenization_workers":8,"version":"1.5.0"}`
t.Run("a tgi info is flagged with its model id", func(t *testing.T) {
res := runInferenceExposureModule(t, tgi, 200, tgiInfo)
if len(res.Findings) == 0 {
t.Fatal("expected a tgi finding")
}
if v := inferenceExtract(res, "tgi_model"); v != "meta-llama/Llama-2-7b-chat-hf" {
t.Errorf("tgi_model=%q, want meta-llama/Llama-2-7b-chat-hf", v)
}
})
t.Run("a tei info is flagged with its model id", func(t *testing.T) {
res := runInferenceExposureModule(t, tei, 200, teiInfo)
if len(res.Findings) == 0 {
t.Fatal("expected a tei finding")
}
if v := inferenceExtract(res, "tei_model"); v != "BAAI/bge-large-en-v1.5" {
t.Errorf("tei_model=%q, want BAAI/bge-large-en-v1.5", v)
}
})
t.Run("a tgi info is not flagged as tei", func(t *testing.T) {
if res := runInferenceExposureModule(t, tei, 200, tgiInfo); len(res.Findings) > 0 {
t.Errorf("a tgi info should not match the tei module, got %d findings", len(res.Findings))
}
})
t.Run("a tei info is not flagged as tgi", func(t *testing.T) {
if res := runInferenceExposureModule(t, tgi, 200, teiInfo); len(res.Findings) > 0 {
t.Errorf("a tei info should not match the tgi module, got %d findings", len(res.Findings))
}
})
t.Run("a hugging face model config is not flagged as tei", func(t *testing.T) {
body := `{"model_type":"bert","hidden_size":768,"num_attention_heads":12,"vocab_size":30522}`
if res := runInferenceExposureModule(t, tei, 200, body); len(res.Findings) > 0 {
t.Errorf("a model config.json should not match tei, got %d findings", len(res.Findings))
}
})
t.Run("a batch-tokens body without model_type is not flagged as tei", func(t *testing.T) {
body := `{"max_batch_tokens":16384,"max_concurrent_requests":512}`
if res := runInferenceExposureModule(t, tei, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without model_type should not match tei, got %d findings", len(res.Findings))
}
})
t.Run("a different router is not flagged as tgi", func(t *testing.T) {
body := `{"router":"some-other-router","max_concurrent_requests":10}`
if res := runInferenceExposureModule(t, tgi, 200, body); len(res.Findings) > 0 {
t.Errorf("a non-tgi router should not match, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{tgi, tei} {
if res := runInferenceExposureModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{tgi, tei} {
if res := runInferenceExposureModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
@@ -0,0 +1,212 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
func runLocalRunnerModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func localRunnerExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestLLMLocalRunnerExposureModules(t *testing.T) {
const ollama = "../../modules/recon/ollama-api-exposure.yaml"
const koboldcpp = "../../modules/recon/koboldcpp-api-exposure.yaml"
const tabby = "../../modules/recon/tabby-api-exposure.yaml"
const oobabooga = "../../modules/recon/oobabooga-api-exposure.yaml"
ollamaTags := `{"models":[{"name":"deepseek-r1:latest","model":"deepseek-r1:latest",` +
`"modified_at":"2025-05-10T08:06:48.639712648-07:00","size":4683075271,` +
`"digest":"0a8c266910232fd3291e71e5ba1e058cc5af9d411192cf88b6d30e92b6e73163",` +
`"details":{"parent_model":"","format":"gguf","family":"qwen2","families":["qwen2"],` +
`"parameter_size":"7.6B","quantization_level":"Q4_K_M"}}]}`
koboldVersion := `{"result":"KoboldCpp","version":"1.71.1","protected":false,"llm":true,` +
`"txt2img":true,"vision":false,"audio":false,"transcribe":false,"multiplayer":false,` +
`"websearch":false,"tts":false,"embeddings":false,"music":false,"savedata":false,` +
`"admin":0,"router":false,"guidance":false,"jinja":true,"mcp":false}`
tabbyHealth := `{"model":"TabbyML/StarCoder-1B","chat_model":"Qwen2.5-Coder-7B-Instruct",` +
`"device":"cuda","cuda_devices":["NVIDIA GeForce RTX 4090"],"models":{"completion":{"vram":1234}},` +
`"arch":"x86_64","cpu_info":"AMD Ryzen 9 5950X","cpu_count":32,` +
`"version":{"build_date":"2024-06-01","build_timestamp":"2024-06-01T00:00:00Z",` +
`"git_sha":"deadbeef","git_describe":"v0.13.0"},"webserver":true}`
oobaModelInfo := `{"model_name":"TheBloke_Llama-2-13B-chat-GPTQ","lora_names":["alpaca-lora"],` +
`"loader":"ExLlamav2_HF"}`
t.Run("an ollama tags api is flagged with its model name", func(t *testing.T) {
res := runLocalRunnerModule(t, ollama, 200, ollamaTags)
if len(res.Findings) == 0 {
t.Fatal("expected an ollama finding")
}
if v := localRunnerExtract(res, "ollama_model"); v != "deepseek-r1:latest" {
t.Errorf("ollama_model=%q, want deepseek-r1:latest", v)
}
})
t.Run("an ollama tags list without model details is not flagged", func(t *testing.T) {
body := `{"models":[{"name":"llama3:latest","digest":"abc"}]}`
if res := runLocalRunnerModule(t, ollama, 200, body); len(res.Findings) > 0 {
t.Errorf("a bare model list should not match ollama, got %d findings", len(res.Findings))
}
})
t.Run("an ollama version response is not flagged", func(t *testing.T) {
if res := runLocalRunnerModule(t, ollama, 200, `{"version":"0.5.1"}`); len(res.Findings) > 0 {
t.Errorf("a version response should not match ollama, got %d findings", len(res.Findings))
}
})
t.Run("a koboldcpp version probe is flagged with its version", func(t *testing.T) {
res := runLocalRunnerModule(t, koboldcpp, 200, koboldVersion)
if len(res.Findings) == 0 {
t.Fatal("expected a koboldcpp finding")
}
if v := localRunnerExtract(res, "koboldcpp_version"); v != "1.71.1" {
t.Errorf("koboldcpp_version=%q, want 1.71.1", v)
}
if v := localRunnerExtract(res, "koboldcpp_protected"); v != "false" {
t.Errorf("koboldcpp_protected=%q, want false", v)
}
})
t.Run("a koboldcpp result without txt2img is not flagged", func(t *testing.T) {
body := `{"result":"KoboldCpp","protected":false}`
if res := runLocalRunnerModule(t, koboldcpp, 200, body); len(res.Findings) > 0 {
t.Errorf("a probe without txt2img should not match koboldcpp, got %d findings", len(res.Findings))
}
})
t.Run("a koboldcpp result without protected is not flagged", func(t *testing.T) {
body := `{"result":"KoboldCpp","txt2img":true}`
if res := runLocalRunnerModule(t, koboldcpp, 200, body); len(res.Findings) > 0 {
t.Errorf("a probe without protected should not match koboldcpp, got %d findings", len(res.Findings))
}
})
t.Run("another server reporting capabilities is not flagged as koboldcpp", func(t *testing.T) {
body := `{"result":"SomeOtherServer","protected":false,"txt2img":true}`
if res := runLocalRunnerModule(t, koboldcpp, 200, body); len(res.Findings) > 0 {
t.Errorf("a non-kobold result should not match koboldcpp, got %d findings", len(res.Findings))
}
})
t.Run("a tabby health is flagged with its model", func(t *testing.T) {
res := runLocalRunnerModule(t, tabby, 200, tabbyHealth)
if len(res.Findings) == 0 {
t.Fatal("expected a tabby finding")
}
if v := localRunnerExtract(res, "tabby_model"); v != "TabbyML/StarCoder-1B" {
t.Errorf("tabby_model=%q, want TabbyML/StarCoder-1B", v)
}
})
t.Run("a tabby health without git_describe is not flagged", func(t *testing.T) {
body := `{"cpu_info":"AMD Ryzen","cuda_devices":["RTX 4090"],"device":"cuda"}`
if res := runLocalRunnerModule(t, tabby, 200, body); len(res.Findings) > 0 {
t.Errorf("a health body without git_describe should not match tabby, got %d findings", len(res.Findings))
}
})
t.Run("a tabby health without cuda_devices is not flagged", func(t *testing.T) {
body := `{"cpu_info":"AMD Ryzen","version":{"git_describe":"v0.13.0"}}`
if res := runLocalRunnerModule(t, tabby, 200, body); len(res.Findings) > 0 {
t.Errorf("a health body without cuda_devices should not match tabby, got %d findings", len(res.Findings))
}
})
t.Run("a build info without cpu_info is not flagged as tabby", func(t *testing.T) {
body := `{"cuda_devices":["RTX 4090"],"version":{"git_describe":"v0.13.0"}}`
if res := runLocalRunnerModule(t, tabby, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without cpu_info should not match tabby, got %d findings", len(res.Findings))
}
})
t.Run("an oobabooga model info is flagged with its model name", func(t *testing.T) {
res := runLocalRunnerModule(t, oobabooga, 200, oobaModelInfo)
if len(res.Findings) == 0 {
t.Fatal("expected an oobabooga finding")
}
if v := localRunnerExtract(res, "oobabooga_model"); v != "TheBloke_Llama-2-13B-chat-GPTQ" {
t.Errorf("oobabooga_model=%q, want TheBloke_Llama-2-13B-chat-GPTQ", v)
}
})
t.Run("a body without lora_names is not flagged as oobabooga", func(t *testing.T) {
body := `{"model_name":"some-model","loader":"Transformers"}`
if res := runLocalRunnerModule(t, oobabooga, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without lora_names should not match oobabooga, got %d findings", len(res.Findings))
}
})
t.Run("a body without model_name is not flagged as oobabooga", func(t *testing.T) {
body := `{"lora_names":["x"],"loader":"Transformers"}`
if res := runLocalRunnerModule(t, oobabooga, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without model_name should not match oobabooga, got %d findings", len(res.Findings))
}
})
t.Run("a model_names plural list is not flagged as oobabooga", func(t *testing.T) {
body := `{"model_names":["TheBloke_Llama-2-13B-chat-GPTQ","mistral-7b-instruct"]}`
if res := runLocalRunnerModule(t, oobabooga, 200, body); len(res.Findings) > 0 {
t.Errorf("a model_names plural list should not match oobabooga, got %d findings", len(res.Findings))
}
})
t.Run("an idle oobabooga with no model loaded is still flagged", func(t *testing.T) {
body := `{"model_name":"None","lora_names":[],"loader":"Transformers"}`
if res := runLocalRunnerModule(t, oobabooga, 200, body); len(res.Findings) == 0 {
t.Error("expected an idle oobabooga (model_name None) to still be flagged")
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{ollama, koboldcpp, oobabooga, tabby} {
if res := runLocalRunnerModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{ollama, koboldcpp, oobabooga, tabby} {
if res := runLocalRunnerModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
@@ -0,0 +1,225 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
func runOpenAICompatModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func openAICompatExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestLLMOpenAICompatExposureModules(t *testing.T) {
const vllm = "../../modules/recon/vllm-api-exposure.yaml"
const llamacpp = "../../modules/recon/llamacpp-api-exposure.yaml"
const infinity = "../../modules/recon/infinity-embedding-api-exposure.yaml"
const lmstudio = "../../modules/recon/lmstudio-api-exposure.yaml"
vllmModels := `{"object":"list","data":[{"id":"meta-llama/Llama-3.1-8B-Instruct","object":"model",` +
`"created":1718900000,"owned_by":"vllm","root":"meta-llama/Llama-3.1-8B-Instruct",` +
`"parent":null,"max_model_len":131072}]}`
llamacppModels := `{"object":"list","data":[{"id":"/models/llama-2-7b.Q4_K_M.gguf","object":"model",` +
`"created":1718900000,"owned_by":"llamacpp"}]}`
infinityModels := `{"data":[{"id":"BAAI/bge-small-en-v1.5","stats":{"queue_fraction":0.0,` +
`"queue_absolute":0,"results_pending":0,"results_queue":0},"object":"model","owned_by":"infinity",` +
`"created":1718900000,"backend":"torch","capabilities":["embed"]}],"object":"list"}`
lmstudioModels := `{"object":"list","data":[{"id":"qwen2-vl-7b-instruct","object":"model",` +
`"type":"vlm","publisher":"mlx-community","arch":"qwen2_vl","compatibility_type":"mlx",` +
`"quantization":"4bit","state":"not-loaded","max_context_length":32768},` +
`{"id":"text-embedding-nomic-embed-text-v1.5","object":"model","type":"embeddings",` +
`"publisher":"nomic-ai","arch":"nomic-bert","compatibility_type":"gguf","quantization":"Q4_0",` +
`"state":"not-loaded","max_context_length":2048}]}`
t.Run("a vllm models api is flagged with its model id", func(t *testing.T) {
res := runOpenAICompatModule(t, vllm, 200, vllmModels)
if len(res.Findings) == 0 {
t.Fatal("expected a vllm finding")
}
if v := openAICompatExtract(res, "vllm_model"); v != "meta-llama/Llama-3.1-8B-Instruct" {
t.Errorf("vllm_model=%q, want meta-llama/Llama-3.1-8B-Instruct", v)
}
})
t.Run("a vllm-prefixed but distinct owned_by is not flagged as vllm", func(t *testing.T) {
body := `{"object":"list","data":[{"id":"meta-llama/Llama-3.1-8B-Instruct","object":"model",` +
`"created":1718900000,"owned_by":"vllm-frontend-rs","root":"meta-llama/Llama-3.1-8B-Instruct"}]}`
if res := runOpenAICompatModule(t, vllm, 200, body); len(res.Findings) > 0 {
t.Errorf("owned_by vllm-frontend-rs should not match the anchored vllm regex, got %d findings", len(res.Findings))
}
})
t.Run("a llamacpp models api is flagged with its model id", func(t *testing.T) {
res := runOpenAICompatModule(t, llamacpp, 200, llamacppModels)
if len(res.Findings) == 0 {
t.Fatal("expected a llamacpp finding")
}
if v := openAICompatExtract(res, "llamacpp_model"); v != "/models/llama-2-7b.Q4_K_M.gguf" {
t.Errorf("llamacpp_model=%q, want /models/llama-2-7b.Q4_K_M.gguf", v)
}
})
t.Run("a llamacpp models api is not flagged as vllm", func(t *testing.T) {
if res := runOpenAICompatModule(t, vllm, 200, llamacppModels); len(res.Findings) > 0 {
t.Errorf("a llamacpp list should not match the vllm module, got %d findings", len(res.Findings))
}
})
t.Run("a vllm models api is not flagged as llamacpp", func(t *testing.T) {
if res := runOpenAICompatModule(t, llamacpp, 200, vllmModels); len(res.Findings) > 0 {
t.Errorf("a vllm list should not match the llamacpp module, got %d findings", len(res.Findings))
}
})
t.Run("an openai compatible list owned by openai is not flagged as vllm", func(t *testing.T) {
body := `{"object":"list","data":[{"id":"gpt-4o","object":"model","owned_by":"openai"}]}`
if res := runOpenAICompatModule(t, vllm, 200, body); len(res.Findings) > 0 {
t.Errorf("an openai-owned list should not match vllm, got %d findings", len(res.Findings))
}
})
t.Run("an infinity models list is flagged with its model id", func(t *testing.T) {
res := runOpenAICompatModule(t, infinity, 200, infinityModels)
if len(res.Findings) == 0 {
t.Fatal("expected an infinity finding")
}
if v := openAICompatExtract(res, "infinity_model"); v != "BAAI/bge-small-en-v1.5" {
t.Errorf("infinity_model=%q, want BAAI/bge-small-en-v1.5", v)
}
})
t.Run("an infinity list without owned_by is not flagged", func(t *testing.T) {
body := `{"data":[{"id":"x","backend":"torch","capabilities":["embed"]}],"object":"list"}`
if res := runOpenAICompatModule(t, infinity, 200, body); len(res.Findings) > 0 {
t.Errorf("a list without owned_by should not match infinity, got %d findings", len(res.Findings))
}
})
t.Run("an infinity list without a backend is not flagged", func(t *testing.T) {
body := `{"data":[{"id":"x","owned_by":"infinity","capabilities":["embed"]}],"object":"list"}`
if res := runOpenAICompatModule(t, infinity, 200, body); len(res.Findings) > 0 {
t.Errorf("a list without backend should not match infinity, got %d findings", len(res.Findings))
}
})
t.Run("an infinity list without capabilities is not flagged", func(t *testing.T) {
body := `{"data":[{"id":"x","owned_by":"infinity","backend":"torch"}],"object":"list"}`
if res := runOpenAICompatModule(t, infinity, 200, body); len(res.Findings) > 0 {
t.Errorf("a list without capabilities should not match infinity, got %d findings", len(res.Findings))
}
})
t.Run("a non-infinity server with backend and capabilities is not flagged", func(t *testing.T) {
body := `{"object":"list","data":[{"id":"m","object":"model","owned_by":"acme-org",` +
`"backend":"vllm","capabilities":["chat"]}]}`
if res := runOpenAICompatModule(t, infinity, 200, body); len(res.Findings) > 0 {
t.Errorf("backend+capabilities with a non-infinity owned_by should not match, got %d findings", len(res.Findings))
}
})
t.Run("a vllm models list is not flagged as infinity", func(t *testing.T) {
if res := runOpenAICompatModule(t, infinity, 200, vllmModels); len(res.Findings) > 0 {
t.Errorf("a vllm list should not match infinity, got %d findings", len(res.Findings))
}
})
t.Run("an infinity list is not flagged as vllm", func(t *testing.T) {
if res := runOpenAICompatModule(t, vllm, 200, infinityModels); len(res.Findings) > 0 {
t.Errorf("an infinity list should not match the vllm module, got %d findings", len(res.Findings))
}
})
t.Run("an lmstudio models api is flagged with its model id", func(t *testing.T) {
res := runOpenAICompatModule(t, lmstudio, 200, lmstudioModels)
if len(res.Findings) == 0 {
t.Fatal("expected an lmstudio finding")
}
if v := openAICompatExtract(res, "lmstudio_model"); v != "qwen2-vl-7b-instruct" {
t.Errorf("lmstudio_model=%q, want qwen2-vl-7b-instruct", v)
}
})
t.Run("a body without compatibility_type is not flagged as lmstudio", func(t *testing.T) {
body := `{"object":"list","data":[{"id":"x","quantization":"4bit","max_context_length":4096}]}`
if res := runOpenAICompatModule(t, lmstudio, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without compatibility_type should not match lmstudio, got %d findings", len(res.Findings))
}
})
t.Run("a body without quantization is not flagged as lmstudio", func(t *testing.T) {
body := `{"object":"list","data":[{"id":"x","compatibility_type":"gguf","max_context_length":4096}]}`
if res := runOpenAICompatModule(t, lmstudio, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without quantization should not match lmstudio, got %d findings", len(res.Findings))
}
})
t.Run("a body without max_context_length is not flagged as lmstudio", func(t *testing.T) {
body := `{"object":"list","data":[{"id":"x","compatibility_type":"gguf","quantization":"4bit"}]}`
if res := runOpenAICompatModule(t, lmstudio, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without max_context_length should not match lmstudio, got %d findings", len(res.Findings))
}
})
t.Run("an openai compatible v1 models list is not flagged as lmstudio", func(t *testing.T) {
if res := runOpenAICompatModule(t, lmstudio, 200, vllmModels); len(res.Findings) > 0 {
t.Errorf("a plain v1 models list should not match lmstudio, got %d findings", len(res.Findings))
}
})
t.Run("an lmstudio models list is not flagged as vllm", func(t *testing.T) {
if res := runOpenAICompatModule(t, vllm, 200, lmstudioModels); len(res.Findings) > 0 {
t.Errorf("an lmstudio list should not match the vllm module, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{vllm, llamacpp, lmstudio, infinity} {
if res := runOpenAICompatModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{vllm, llamacpp, lmstudio, infinity} {
if res := runOpenAICompatModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
@@ -0,0 +1,201 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
func runRAGUIModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func ragUIExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestLLMRAGUIExposureModules(t *testing.T) {
const fastgpt = "../../modules/recon/fastgpt-init-exposure.yaml"
const perplexica = "../../modules/recon/perplexica-config-exposure.yaml"
const onyx = "../../modules/recon/onyx-auth-exposure.yaml"
const verba = "../../modules/recon/verba-health-exposure.yaml"
fastgptInit := `{"code":200,"data":{"bufferId":"unAuth_x","feConfigs":{"systemTitle":"FastGPT",` +
`"docUrl":"https://doc.fastgpt.io","show_git":true},"modelProviders":[{"provider":"openai"}],` +
`"aiproxyChannels":[]}}`
perplexicaConfig := `{"values":{"general":{"theme":"dark","measureUnit":"Metric","autoMediaSearch":true,` +
`"showWeatherWidget":true},"modelProviders":[{"id":"openai","name":"OpenAI",` +
`"config":{"apiKey":"sk-secret","baseURL":""},"chatModels":[],"embeddingModels":[]}],` +
`"search":{"searxngURL":"http://searxng:8080"}},"fields":[{"key":"measureUnit"},{"key":"autoMediaSearch"},` +
`{"key":"searxngURL"}]}`
onyxAuth := `{"auth_type":"basic","requires_verification":false,"anonymous_user_enabled":false,` +
`"password_min_length":8,"has_users":true,"oauth_enabled":false}`
verbaHealth := `{"message":"Alive!","production":"Local","gtag":"",` +
`"deployments":{"WEAVIATE_URL_VERBA":"https://my-cluster.weaviate.network",` +
`"WEAVIATE_API_KEY_VERBA":"sk-weaviate-AbC123secret"},"default_deployment":"Weaviate"}`
t.Run("a fastgpt init is flagged with its system title", func(t *testing.T) {
res := runRAGUIModule(t, fastgpt, 200, fastgptInit)
if len(res.Findings) == 0 {
t.Fatal("expected a fastgpt finding")
}
if v := ragUIExtract(res, "fastgpt_title"); v != "FastGPT" {
t.Errorf("fastgpt_title=%q, want FastGPT", v)
}
})
t.Run("a body without feConfigs is not flagged as fastgpt", func(t *testing.T) {
body := `{"data":{"modelProviders":[],"aiproxyChannels":[]}}`
if res := runRAGUIModule(t, fastgpt, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without feConfigs should not match fastgpt, got %d findings", len(res.Findings))
}
})
t.Run("a body without aiproxyChannels is not flagged as fastgpt", func(t *testing.T) {
body := `{"data":{"feConfigs":{},"modelProviders":[]}}`
if res := runRAGUIModule(t, fastgpt, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without aiproxyChannels should not match fastgpt, got %d findings", len(res.Findings))
}
})
t.Run("a perplexica config is flagged with its searxng url", func(t *testing.T) {
res := runRAGUIModule(t, perplexica, 200, perplexicaConfig)
if len(res.Findings) == 0 {
t.Fatal("expected a perplexica finding")
}
if v := ragUIExtract(res, "perplexica_searxng"); v != "http://searxng:8080" {
t.Errorf("perplexica_searxng=%q, want http://searxng:8080", v)
}
})
t.Run("a body without searxngURL is not flagged as perplexica", func(t *testing.T) {
body := `{"values":{"general":{"measureUnit":"Metric","autoMediaSearch":true}}}`
if res := runRAGUIModule(t, perplexica, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without searxngURL should not match perplexica, got %d findings", len(res.Findings))
}
})
t.Run("a body without autoMediaSearch is not flagged as perplexica", func(t *testing.T) {
body := `{"values":{"general":{"measureUnit":"Metric"},"search":{"searxngURL":"http://x"}}}`
if res := runRAGUIModule(t, perplexica, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without autoMediaSearch should not match perplexica, got %d findings", len(res.Findings))
}
})
t.Run("an onyx auth type is flagged with its auth scheme", func(t *testing.T) {
res := runRAGUIModule(t, onyx, 200, onyxAuth)
if len(res.Findings) == 0 {
t.Fatal("expected an onyx finding")
}
if v := ragUIExtract(res, "onyx_auth_type"); v != "basic" {
t.Errorf("onyx_auth_type=%q, want basic", v)
}
})
t.Run("a body without anonymous_user_enabled is not flagged as onyx", func(t *testing.T) {
body := `{"auth_type":"basic","requires_verification":false,"password_min_length":8}`
if res := runRAGUIModule(t, onyx, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without anonymous_user_enabled should not match onyx, got %d findings", len(res.Findings))
}
})
t.Run("a body without password_min_length is not flagged as onyx", func(t *testing.T) {
body := `{"auth_type":"basic","requires_verification":false,"anonymous_user_enabled":false}`
if res := runRAGUIModule(t, onyx, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without password_min_length should not match onyx, got %d findings", len(res.Findings))
}
})
t.Run("a verba health is flagged and leaks the weaviate url", func(t *testing.T) {
res := runRAGUIModule(t, verba, 200, verbaHealth)
if len(res.Findings) == 0 {
t.Fatal("expected a verba finding")
}
if v := ragUIExtract(res, "verba_weaviate_url"); v != "https://my-cluster.weaviate.network" {
t.Errorf("verba_weaviate_url=%q, want https://my-cluster.weaviate.network", v)
}
})
t.Run("a body without WEAVIATE_API_KEY_VERBA is not flagged as verba", func(t *testing.T) {
body := `{"message":"Alive!","deployments":{"WEAVIATE_URL_VERBA":"http://x"},"default_deployment":""}`
if res := runRAGUIModule(t, verba, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without WEAVIATE_API_KEY_VERBA should not match verba, got %d findings", len(res.Findings))
}
})
t.Run("a body without WEAVIATE_URL_VERBA is not flagged as verba", func(t *testing.T) {
body := `{"message":"Alive!","deployments":{"WEAVIATE_API_KEY_VERBA":"k"},"default_deployment":""}`
if res := runRAGUIModule(t, verba, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without WEAVIATE_URL_VERBA should not match verba, got %d findings", len(res.Findings))
}
})
t.Run("a body without default_deployment is not flagged as verba", func(t *testing.T) {
body := `{"message":"Alive!","deployments":{"WEAVIATE_URL_VERBA":"http://x","WEAVIATE_API_KEY_VERBA":"k"}}`
if res := runRAGUIModule(t, verba, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without default_deployment should not match verba, got %d findings", len(res.Findings))
}
})
t.Run("a verba health with an empty weaviate url is not flagged", func(t *testing.T) {
body := `{"message":"Alive!","production":"Local","deployments":{"WEAVIATE_URL_VERBA":"",` +
`"WEAVIATE_API_KEY_VERBA":""},"default_deployment":""}`
if res := runRAGUIModule(t, verba, 200, body); len(res.Findings) > 0 {
t.Errorf("an embedded verba that leaks no backend url should not match, got %d findings", len(res.Findings))
}
})
t.Run("a generic alive health is not flagged as verba", func(t *testing.T) {
body := `{"message":"Alive!","status":"ok","uptime":1234}`
if res := runRAGUIModule(t, verba, 200, body); len(res.Findings) > 0 {
t.Errorf("a generic health should not match verba, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{perplexica, verba, onyx, fastgpt} {
if res := runRAGUIModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{perplexica, verba, onyx, fastgpt} {
if res := runRAGUIModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
+204
View File
@@ -0,0 +1,204 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
func runUIExposureModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func uiExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestLLMUIExposureModules(t *testing.T) {
const openWebUI = "../../modules/recon/open-webui-exposure.yaml"
const librechat = "../../modules/recon/librechat-exposure.yaml"
const nextchat = "../../modules/recon/nextchat-config-exposure.yaml"
const anythingllm = "../../modules/recon/anythingllm-exposure.yaml"
openWebUIConfig := `{"status":true,"name":"Open WebUI","version":"0.6.15","default_locale":"",` +
`"oauth":{"providers":{}},"features":{"auth":false,"auth_trusted_header":false,` +
`"enable_ldap":false,"enable_signup":true,"enable_login_form":true,"enable_websocket":true}}`
librechatConfig := `{"appTitle":"LibreChat","serverDomain":"https://chat.example.com",` +
`"emailLoginEnabled":true,"registrationEnabled":true,"socialLogins":["google","github"]}`
nextchatConfig := `{"needCode":false,"hideUserApiKey":false,"disableGPT4":false,` +
`"hideBalanceQuery":true,"disableFastLink":false,"customModels":"","defaultModel":"gpt-4o-mini",` +
`"visionModels":""}`
anythingllmSetup := `{"results":{"RequiresAuth":false,"MultiUserMode":false,"EmbeddingEngine":"native",` +
`"VectorDB":"lancedb","LLMProvider":"openai","LLMModel":"gpt-4o","WhisperProvider":"local"}}`
t.Run("an open webui with auth disabled is flagged with its version", func(t *testing.T) {
res := runUIExposureModule(t, openWebUI, 200, openWebUIConfig)
if len(res.Findings) == 0 {
t.Fatal("expected an open webui finding")
}
if v := uiExtract(res, "open_webui_version"); v != "0.6.15" {
t.Errorf("open_webui_version=%q, want 0.6.15", v)
}
})
t.Run("a rebranded open webui with auth disabled is still flagged", func(t *testing.T) {
body := `{"status":true,"name":"Acme AI Portal","version":"0.6.15",` +
`"features":{"auth":false,"auth_trusted_header":false,"enable_signup":true}}`
res := runUIExposureModule(t, openWebUI, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a finding for a rebranded open webui")
}
if v := uiExtract(res, "open_webui_version"); v != "0.6.15" {
t.Errorf("open_webui_version=%q, want 0.6.15", v)
}
})
t.Run("a librechat with open registration is flagged with its title", func(t *testing.T) {
res := runUIExposureModule(t, librechat, 200, librechatConfig)
if len(res.Findings) == 0 {
t.Fatal("expected a librechat finding")
}
if v := uiExtract(res, "librechat_title"); v != "LibreChat" {
t.Errorf("librechat_title=%q, want LibreChat", v)
}
})
t.Run("an open webui with auth enabled is not flagged", func(t *testing.T) {
body := `{"status":true,"name":"Open WebUI","version":"0.6.15",` +
`"features":{"auth":true,"auth_trusted_header":false,"enable_signup":true}}`
if res := runUIExposureModule(t, openWebUI, 200, body); len(res.Findings) > 0 {
t.Errorf("an auth-enabled open webui should not match, got %d findings", len(res.Findings))
}
})
t.Run("an open webui auth_trusted_header false does not satisfy the auth regex", func(t *testing.T) {
body := `{"status":true,"name":"Open WebUI","version":"0.6.15",` +
`"features":{"auth":true,"auth_trusted_header":false}}`
if res := runUIExposureModule(t, openWebUI, 200, body); len(res.Findings) > 0 {
t.Errorf("auth_trusted_header false should not match the auth regex, got %d findings", len(res.Findings))
}
})
t.Run("a librechat with registration disabled is not flagged", func(t *testing.T) {
body := `{"appTitle":"LibreChat","emailLoginEnabled":true,"registrationEnabled":false,"socialLogins":[]}`
if res := runUIExposureModule(t, librechat, 200, body); len(res.Findings) > 0 {
t.Errorf("a closed-registration librechat should not match, got %d findings", len(res.Findings))
}
})
t.Run("an unrelated app with open registration is not flagged as librechat", func(t *testing.T) {
body := `{"name":"otherapp","registrationEnabled":true}`
if res := runUIExposureModule(t, librechat, 200, body); len(res.Findings) > 0 {
t.Errorf("an unrelated app should not match librechat, got %d findings", len(res.Findings))
}
})
t.Run("a nextchat config is flagged and reports its access-code gate", func(t *testing.T) {
res := runUIExposureModule(t, nextchat, 200, nextchatConfig)
if len(res.Findings) == 0 {
t.Fatal("expected a nextchat finding")
}
if v := uiExtract(res, "nextchat_needcode"); v != "false" {
t.Errorf("nextchat_needcode=%q, want false", v)
}
})
t.Run("a body without needCode is not flagged as nextchat", func(t *testing.T) {
body := `{"hideUserApiKey":false,"hideBalanceQuery":true}`
if res := runUIExposureModule(t, nextchat, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without needCode should not match nextchat, got %d findings", len(res.Findings))
}
})
t.Run("a body without hideUserApiKey is not flagged as nextchat", func(t *testing.T) {
body := `{"needCode":false,"hideBalanceQuery":true}`
if res := runUIExposureModule(t, nextchat, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without hideUserApiKey should not match nextchat, got %d findings", len(res.Findings))
}
})
t.Run("a body without hideBalanceQuery is not flagged as nextchat", func(t *testing.T) {
body := `{"needCode":false,"hideUserApiKey":false}`
if res := runUIExposureModule(t, nextchat, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without hideBalanceQuery should not match nextchat, got %d findings", len(res.Findings))
}
})
t.Run("a code-gated nextchat is not flagged", func(t *testing.T) {
body := `{"needCode":true,"hideUserApiKey":false,"hideBalanceQuery":true,"disableGPT4":false}`
if res := runUIExposureModule(t, nextchat, 200, body); len(res.Findings) > 0 {
t.Errorf("a needCode:true nextchat is access-code gated and should not match, got %d findings", len(res.Findings))
}
})
t.Run("an anythingllm setup is flagged with its model", func(t *testing.T) {
res := runUIExposureModule(t, anythingllm, 200, anythingllmSetup)
if len(res.Findings) == 0 {
t.Fatal("expected an anythingllm finding")
}
if v := uiExtract(res, "anythingllm_model"); v != "gpt-4o" {
t.Errorf("anythingllm_model=%q, want gpt-4o", v)
}
})
t.Run("a body without LLMProvider is not flagged as anythingllm", func(t *testing.T) {
body := `{"results":{"VectorDB":"lancedb","EmbeddingEngine":"native"}}`
if res := runUIExposureModule(t, anythingllm, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without LLMProvider should not match anythingllm, got %d findings", len(res.Findings))
}
})
t.Run("a body without a VectorDB is not flagged as anythingllm", func(t *testing.T) {
body := `{"results":{"LLMProvider":"openai","EmbeddingEngine":"native"}}`
if res := runUIExposureModule(t, anythingllm, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without VectorDB should not match anythingllm, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{openWebUI, librechat, anythingllm, nextchat} {
if res := runUIExposureModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{openWebUI, librechat, anythingllm, nextchat} {
if res := runUIExposureModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
+1 -1
View File
@@ -19,7 +19,7 @@ import (
"runtime"
"github.com/charmbracelet/log"
"github.com/dropalldatabases/sif/internal/output"
"github.com/vmfunc/sif/internal/output"
)
// Loader handles module discovery and loading.
+106
View File
@@ -0,0 +1,106 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
func runLoginModule(t *testing.T, file string, status int, headers map[string]string, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for k, v := range headers {
w.Header().Set(k, v)
}
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func loginExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestLoginPanelModules(t *testing.T) {
const grafana = "../../modules/info/grafana-panel.yaml"
const kibana = "../../modules/info/kibana-panel.yaml"
const jenkins = "../../modules/info/jenkins-panel.yaml"
grafanaBody := `<body class="app-grafana"><grafana-app></grafana-app>` +
`<script>window.grafanaBootData = {"settings":{"buildInfo":{"version":"10.4.2","commit":"abc"}}};</script></body>`
kibanaBody := `<div data-test-subj="kibanaChrome"><kbn-injected-metadata data="x"></kbn-injected-metadata></div>`
t.Run("grafana login", func(t *testing.T) {
res := runLoginModule(t, grafana, 200, nil, grafanaBody)
if len(res.Findings) == 0 {
t.Fatal("expected a grafana finding")
}
if v := loginExtract(res, "grafana_version"); v != "10.4.2" {
t.Errorf("grafana_version=%q, want 10.4.2", v)
}
})
t.Run("kibana via response headers", func(t *testing.T) {
res := runLoginModule(t, kibana, 200, map[string]string{"kbn-version": "8.13.0", "kbn-name": "node-1"}, kibanaBody)
if len(res.Findings) == 0 {
t.Fatal("expected a kibana finding")
}
if v := loginExtract(res, "kibana_version"); v != "8.13.0" {
t.Errorf("kibana_version=%q, want 8.13.0", v)
}
})
t.Run("jenkins via X-Jenkins header on a 403", func(t *testing.T) {
res := runLoginModule(t, jenkins, 403, map[string]string{"X-Jenkins": "2.426.1"},
`<html><head><title>Authentication required</title></head></html>`)
if len(res.Findings) == 0 {
t.Fatal("expected a jenkins finding")
}
if v := loginExtract(res, "jenkins_version"); v != "2.426.1" {
t.Errorf("jenkins_version=%q, want 2.426.1", v)
}
})
t.Run("unrelated page is not a panel", func(t *testing.T) {
for _, file := range []string{grafana, kibana, jenkins} {
if res := runLoginModule(t, file, 200, nil, "<html><body>plain</body></html>"); len(res.Findings) > 0 {
t.Errorf("%s: unrelated page should not match, got %d findings", file, len(res.Findings))
}
}
})
}
@@ -7,7 +7,7 @@ import (
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
"github.com/vmfunc/sif/internal/modules"
)
func runMgmtModule(t *testing.T, file string, status int, body string) *modules.Result {
+132
View File
@@ -0,0 +1,132 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package modules
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/vmfunc/sif/internal/httpx"
)
func TestCheckMatchersCondition(t *testing.T) {
const body = "hello world"
resp := fakeResponse(t, 200, nil)
status200 := Matcher{Type: "status", Status: []int{200}}
status500 := Matcher{Type: "status", Status: []int{500}}
wordHit := Matcher{Type: "word", Part: "body", Words: []string{"hello"}}
wordMiss := Matcher{Type: "word", Part: "body", Words: []string{"absent"}}
tests := []struct {
name string
condition string
matchers []Matcher
expect bool
}{
{"and both match", "and", []Matcher{status200, wordHit}, true},
{"and one fails", "and", []Matcher{status200, wordMiss}, false},
{"empty defaults to and", "", []Matcher{status200, wordMiss}, false},
{"or one matches", "or", []Matcher{status500, wordHit}, true},
{"or none match", "or", []Matcher{status500, wordMiss}, false},
{"or all match", "or", []Matcher{status200, wordHit}, true},
{"or is case-insensitive", "OR", []Matcher{status500, wordHit}, true},
{"and is case-insensitive", "AND", []Matcher{status200, wordMiss}, false},
{"or with negative pass", "or", []Matcher{{Type: "word", Part: "body", Words: []string{"absent"}, Negative: true}}, true},
{"or all fail with negative", "or", []Matcher{{Type: "word", Part: "body", Words: []string{"hello"}, Negative: true}, wordMiss}, false},
{"empty matcher list", "or", nil, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := checkMatchers(tt.matchers, tt.condition, resp, body); got != tt.expect {
t.Errorf("checkMatchers(%q) = %v, want %v", tt.condition, got, tt.expect)
}
})
}
}
func TestValidateMatchersCondition(t *testing.T) {
for _, ok := range []string{"", "and", "or", "AND", "Or"} {
if err := validateMatchersCondition(ok); err != nil {
t.Errorf("%q should be valid: %v", ok, err)
}
}
for _, bad := range []string{"xor", "nand", "any", "&&"} {
if err := validateMatchersCondition(bad); err == nil {
t.Errorf("%q should be rejected", bad)
}
}
}
func TestParseMatchersConditionValidation(t *testing.T) {
write := func(cond string) string {
p := filepath.Join(t.TempDir(), "m.yaml")
body := fmt.Sprintf("id: mc\ntype: http\nhttp:\n method: GET\n paths: [\"{{BaseURL}}\"]\n matchers-condition: %s\n matchers:\n - type: status\n status: [200]\n", cond)
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
return p
}
if _, err := ParseYAMLModule(write("or")); err != nil {
t.Errorf("matchers-condition: or should parse: %v", err)
}
if _, err := ParseYAMLModule(write("xor")); err == nil {
t.Error("matchers-condition: xor should be rejected at load")
}
}
// or fires on the word match alone; and does not (status:500 fails).
func TestExecuteHTTPModuleMatchersConditionOr(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("hello"))
}))
defer srv.Close()
def := &YAMLModule{
ID: "mc",
Type: TypeHTTP,
Info: YAMLModuleInfo{Severity: "info"},
HTTP: &HTTPConfig{
Method: "GET",
Paths: []string{"{{BaseURL}}/"},
Matchers: []Matcher{
{Type: "status", Status: []int{500}},
{Type: "word", Part: "body", Words: []string{"hello"}},
},
},
}
opts := Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)}
def.HTTP.MatchersCondition = "or"
res, err := ExecuteHTTPModule(context.Background(), srv.URL, def, opts)
if err != nil {
t.Fatalf("or: %v", err)
}
if len(res.Findings) != 1 {
t.Fatalf("or: got %d findings, want 1", len(res.Findings))
}
def.HTTP.MatchersCondition = ""
res, err = ExecuteHTTPModule(context.Background(), srv.URL, def, opts)
if err != nil {
t.Fatalf("and: %v", err)
}
if len(res.Findings) != 0 {
t.Fatalf("and: got %d findings, want 0 (status:500 fails)", len(res.Findings))
}
}
+70 -4
View File
@@ -14,6 +14,8 @@ package modules
import (
"net/http"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -184,7 +186,7 @@ func TestCheckMatchers(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := checkMatchers(tt.matchers, resp, body); got != tt.expect {
if got := checkMatchers(tt.matchers, "", resp, body); got != tt.expect {
t.Errorf("checkMatchers = %v, want %v", got, tt.expect)
}
})
@@ -413,7 +415,10 @@ func TestGenerateHTTPRequests(t *testing.T) {
Paths: []string{"{{BaseURL}}/a", "{{BaseURL}}/b"},
}
// trailing slash on the target must be trimmed before substitution.
got := generateHTTPRequests("http://h/", cfg)
got, err := generateHTTPRequests("http://h/", cfg)
if err != nil {
t.Fatalf("generate: %v", err)
}
if len(got) != 2 {
t.Fatalf("got %d requests, want 2", len(got))
}
@@ -432,7 +437,10 @@ func TestGenerateHTTPRequests(t *testing.T) {
Payloads: []string{"1", "2", "3"},
Body: "data={{payload}}",
}
got := generateHTTPRequests("http://h", cfg)
got, err := generateHTTPRequests("http://h", cfg)
if err != nil {
t.Fatalf("generate: %v", err)
}
if len(got) != 3 {
t.Fatalf("got %d requests, want 3", len(got))
}
@@ -457,9 +465,67 @@ func TestGenerateHTTPRequests(t *testing.T) {
Paths: []string{"{{BaseURL}}/a", "{{BaseURL}}/b"},
Payloads: []string{"x", "y"},
}
got := generateHTTPRequests("http://h", cfg)
got, err := generateHTTPRequests("http://h", cfg)
if err != nil {
t.Fatalf("generate: %v", err)
}
if len(got) != 4 {
t.Fatalf("got %d requests, want 4 (2 paths x 2 payloads)", len(got))
}
})
t.Run("wordlist expands {{word}} paths", func(t *testing.T) {
list := filepath.Join(t.TempDir(), "words.txt")
if err := os.WriteFile(list, []byte("admin\n\nconfig\nbackup\n"), 0o600); err != nil {
t.Fatal(err)
}
cfg := &HTTPConfig{
Paths: []string{"{{BaseURL}}/{{word}}", "{{BaseURL}}/.git/HEAD"},
Wordlist: list,
}
got, err := generateHTTPRequests("http://h", cfg)
if err != nil {
t.Fatalf("generate: %v", err)
}
// 3 words (the blank line is skipped) fuzz the templated path, then the
// literal path passes through untouched.
want := []string{"http://h/admin", "http://h/config", "http://h/backup", "http://h/.git/HEAD"}
if len(got) != len(want) {
t.Fatalf("got %d requests, want %d", len(got), len(want))
}
for i, w := range want {
if got[i].URL != w {
t.Errorf("req %d url = %q, want %q", i, got[i].URL, w)
}
}
})
t.Run("wordlist crosses with payloads", func(t *testing.T) {
list := filepath.Join(t.TempDir(), "words.txt")
if err := os.WriteFile(list, []byte("a\nb\n"), 0o600); err != nil {
t.Fatal(err)
}
cfg := &HTTPConfig{
Paths: []string{"{{BaseURL}}/{{word}}?q={{payload}}"},
Wordlist: list,
Payloads: []string{"1", "2", "3"},
}
got, err := generateHTTPRequests("http://h", cfg)
if err != nil {
t.Fatalf("generate: %v", err)
}
if len(got) != 6 {
t.Fatalf("got %d requests, want 6 (2 words x 3 payloads)", len(got))
}
})
t.Run("missing wordlist errors", func(t *testing.T) {
cfg := &HTTPConfig{
Paths: []string{"{{BaseURL}}/{{word}}"},
Wordlist: filepath.Join(t.TempDir(), "nope.txt"),
}
if _, err := generateHTTPRequests("http://h", cfg); err == nil {
t.Fatal("want error for missing wordlist, got nil")
}
})
}
+138
View File
@@ -0,0 +1,138 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
func runMetricsModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func metricsExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestMetricsExposureModules(t *testing.T) {
const netdata = "../../modules/recon/netdata-api-exposure.yaml"
const cadvisor = "../../modules/recon/cadvisor-api-exposure.yaml"
netdataInfo := `{"version":"v1.44.0","uid":"6c5c8a3f","mirrored_hosts":["localhost"],` +
`"mirrored_hosts_status":[{"guid":"6c5c8a3f","reachable":true}],"os_name":"Debian GNU/Linux",` +
`"cores_total":"8","total_disk_space":"512000000000"}`
cadvisorMachine := `{"num_cores":8,"num_physical_cores":4,"num_sockets":1,"cpu_frequency_khz":2904000,` +
`"memory_capacity":16777216000,"machine_id":"a1b2c3d4e5f60718293a4b5c6d7e8f90",` +
`"system_uuid":"4C4C4544-0042-3110-8044-B7C04F564432","boot_id":"f0e1d2c3"}`
t.Run("an exposed netdata info endpoint is flagged and versioned", func(t *testing.T) {
res := runMetricsModule(t, netdata, 200, netdataInfo)
if len(res.Findings) == 0 {
t.Fatal("expected a netdata finding")
}
if v := metricsExtract(res, "netdata_version"); v != "v1.44.0" {
t.Errorf("netdata_version=%q, want v1.44.0", v)
}
})
t.Run("an exposed cadvisor machine endpoint is flagged with the machine id", func(t *testing.T) {
res := runMetricsModule(t, cadvisor, 200, cadvisorMachine)
if len(res.Findings) == 0 {
t.Fatal("expected a cadvisor finding")
}
if v := metricsExtract(res, "cadvisor_machine_id"); v != "a1b2c3d4e5f60718293a4b5c6d7e8f90" {
t.Errorf("cadvisor_machine_id=%q, want the machine id", v)
}
})
t.Run("netdata mirrored hosts without cores total is not flagged", func(t *testing.T) {
body := `{"version":"v1.44.0","mirrored_hosts":["localhost"]}`
if res := runMetricsModule(t, netdata, 200, body); len(res.Findings) > 0 {
t.Errorf("mirrored hosts alone should not match netdata, got %d findings", len(res.Findings))
}
})
t.Run("netdata cores total without mirrored hosts is not flagged", func(t *testing.T) {
body := `{"version":"v1.44.0","cores_total":"8"}`
if res := runMetricsModule(t, netdata, 200, body); len(res.Findings) > 0 {
t.Errorf("cores total alone should not match netdata, got %d findings", len(res.Findings))
}
})
t.Run("cadvisor machine id without a cpu frequency is not flagged", func(t *testing.T) {
body := `{"machine_id":"a1b2c3d4e5f60718293a4b5c6d7e8f90","num_cores":8}`
if res := runMetricsModule(t, cadvisor, 200, body); len(res.Findings) > 0 {
t.Errorf("a machine id alone should not match cadvisor, got %d findings", len(res.Findings))
}
})
t.Run("cadvisor cpu frequency without a machine id is not flagged", func(t *testing.T) {
body := `{"cpu_frequency_khz":2904000,"num_cores":8}`
if res := runMetricsModule(t, cadvisor, 200, body); len(res.Findings) > 0 {
t.Errorf("a cpu frequency alone should not match cadvisor, got %d findings", len(res.Findings))
}
})
t.Run("a generic metrics json is not netdata", func(t *testing.T) {
body := `{"status":"ok","data":{"result":[]}}`
if res := runMetricsModule(t, netdata, 200, body); len(res.Findings) > 0 {
t.Errorf("a generic json should not match netdata, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{netdata, cadvisor} {
if res := runMetricsModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{netdata, cadvisor} {
if res := runMetricsModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
@@ -0,0 +1,133 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
func runMLPlatformModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func mlPlatformExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestMLPlatformExposureModules(t *testing.T) {
const labelStudio = "../../modules/recon/label-studio-exposure.yaml"
const cvat = "../../modules/recon/cvat-server-exposure.yaml"
labelStudioVersion := `{"release":"1.13.1","label-studio-os-package":{"version":"1.13.1",` +
`"short_version":"1.13","description":"Label Studio"},"label-studio-os-backend":{"message":"release",` +
`"commit":"abc1234","date":"2024-06-01"}}`
cvatAbout := `{"name":"Computer Vision Annotation Tool","description":"CVAT is a re-designed annotation tool",` +
`"version":"2.20.0","logo_url":"http://host/static/logo.png","subtitle":""}`
t.Run("a label studio version api is flagged with its release", func(t *testing.T) {
res := runMLPlatformModule(t, labelStudio, 200, labelStudioVersion)
if len(res.Findings) == 0 {
t.Fatal("expected a label studio finding")
}
if v := mlPlatformExtract(res, "label_studio_release"); v != "1.13.1" {
t.Errorf("label_studio_release=%q, want 1.13.1", v)
}
})
t.Run("a generic release version is not flagged as label studio", func(t *testing.T) {
body := `{"release":"1.0","name":"some-app"}`
if res := runMLPlatformModule(t, labelStudio, 200, body); len(res.Findings) > 0 {
t.Errorf("a generic release should not match label studio, got %d findings", len(res.Findings))
}
})
t.Run("a label studio package without the backend key is not flagged", func(t *testing.T) {
body := `{"release":"1.13.1","label-studio-os-package":{"version":"1.13.1"}}`
if res := runMLPlatformModule(t, labelStudio, 200, body); len(res.Findings) > 0 {
t.Errorf("a package-only body should not match label studio, got %d findings", len(res.Findings))
}
})
t.Run("a label studio backend without the package key is not flagged", func(t *testing.T) {
body := `{"release":"1.13.1","label-studio-os-backend":{"commit":"abc"}}`
if res := runMLPlatformModule(t, labelStudio, 200, body); len(res.Findings) > 0 {
t.Errorf("a backend-only body should not match label studio, got %d findings", len(res.Findings))
}
})
t.Run("a cvat about is flagged with its version", func(t *testing.T) {
res := runMLPlatformModule(t, cvat, 200, cvatAbout)
if len(res.Findings) == 0 {
t.Fatal("expected a cvat finding")
}
if v := mlPlatformExtract(res, "cvat_version"); v != "2.20.0" {
t.Errorf("cvat_version=%q, want 2.20.0", v)
}
})
t.Run("another annotation tool is not flagged as cvat", func(t *testing.T) {
body := `{"name":"Some Other Tool","version":"1.0","logo_url":"http://x/l.png"}`
if res := runMLPlatformModule(t, cvat, 200, body); len(res.Findings) > 0 {
t.Errorf("a non-cvat name should not match cvat, got %d findings", len(res.Findings))
}
})
t.Run("an html page mentioning cvat is not flagged", func(t *testing.T) {
body := `<html><body><h1>Computer Vision Annotation Tool</h1> version 2.20.0 logo_url</body></html>`
if res := runMLPlatformModule(t, cvat, 200, body); len(res.Findings) > 0 {
t.Errorf("prose mentioning cvat should not match the structured response, got %d findings", len(res.Findings))
}
})
t.Run("a cvat about without a logo_url is not flagged", func(t *testing.T) {
body := `{"name":"Computer Vision Annotation Tool","version":"2.20.0"}`
if res := runMLPlatformModule(t, cvat, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without logo_url should not match cvat, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{cvat, labelStudio} {
if res := runMLPlatformModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{cvat, labelStudio} {
if res := runMLPlatformModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
@@ -0,0 +1,161 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
func runMLPlatformServerModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func mlPlatformServerExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestMLPlatformServerExposureModules(t *testing.T) {
const h2o = "../../modules/recon/h2o-cluster-exposure.yaml"
const mindsdb = "../../modules/recon/mindsdb-api-exposure.yaml"
const zenml = "../../modules/recon/zenml-server-exposure.yaml"
h2oCloud := `{"__meta":{"schema_name":"CloudV3"},"version":"3.46.0.6","branch_name":"rel-x",` +
`"cloud_name":"H2O_started_from_python","cloud_size":1,"cloud_uptime_millis":123456,` +
`"cloud_healthy":true,"build_too_old":false}`
mindsdbStatus := `{"environment":"local","mindsdb_version":"25.13.1",` +
`"auth":{"confirmed":false,"required":false,"provider":"disabled"}}`
zenmlInfo := `{"id":"abc","version":"0.70.0","deployment_type":"docker","database_type":"sqlite",` +
`"secrets_store_type":"sql","auth_scheme":"OAUTH2_PASSWORD_BEARER","analytics_enabled":true}`
t.Run("an h2o cloud is flagged with its version", func(t *testing.T) {
res := runMLPlatformServerModule(t, h2o, 200, h2oCloud)
if len(res.Findings) == 0 {
t.Fatal("expected an h2o finding")
}
if v := mlPlatformServerExtract(res, "h2o_version"); v != "3.46.0.6" {
t.Errorf("h2o_version=%q, want 3.46.0.6", v)
}
})
t.Run("a body without cloud_name is not flagged as h2o", func(t *testing.T) {
body := `{"cloud_uptime_millis":1,"build_too_old":false,"version":"3.46"}`
if res := runMLPlatformServerModule(t, h2o, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without cloud_name should not match h2o, got %d findings", len(res.Findings))
}
})
t.Run("a body without build_too_old is not flagged as h2o", func(t *testing.T) {
body := `{"cloud_name":"x","cloud_uptime_millis":1}`
if res := runMLPlatformServerModule(t, h2o, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without build_too_old should not match h2o, got %d findings", len(res.Findings))
}
})
t.Run("a generic cloud status is not flagged as h2o", func(t *testing.T) {
body := `{"cloud_name":"x","build_too_old":false}`
if res := runMLPlatformServerModule(t, h2o, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without cloud_uptime_millis should not match h2o, got %d findings", len(res.Findings))
}
})
t.Run("a mindsdb status is flagged with its version", func(t *testing.T) {
res := runMLPlatformServerModule(t, mindsdb, 200, mindsdbStatus)
if len(res.Findings) == 0 {
t.Fatal("expected a mindsdb finding")
}
if v := mlPlatformServerExtract(res, "mindsdb_version"); v != "25.13.1" {
t.Errorf("mindsdb_version=%q, want 25.13.1", v)
}
})
t.Run("a body without mindsdb_version is not flagged", func(t *testing.T) {
body := `{"environment":"local","auth":{"provider":"disabled"}}`
if res := runMLPlatformServerModule(t, mindsdb, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without mindsdb_version should not match mindsdb, got %d findings", len(res.Findings))
}
})
t.Run("a body without an auth provider is not flagged as mindsdb", func(t *testing.T) {
body := `{"environment":"local","mindsdb_version":"25.13.1","auth":{"required":false}}`
if res := runMLPlatformServerModule(t, mindsdb, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without provider should not match mindsdb, got %d findings", len(res.Findings))
}
})
t.Run("a zenml info is flagged with its version", func(t *testing.T) {
res := runMLPlatformServerModule(t, zenml, 200, zenmlInfo)
if len(res.Findings) == 0 {
t.Fatal("expected a zenml finding")
}
if v := mlPlatformServerExtract(res, "zenml_version"); v != "0.70.0" {
t.Errorf("zenml_version=%q, want 0.70.0", v)
}
})
t.Run("a body without deployment_type is not flagged as zenml", func(t *testing.T) {
body := `{"secrets_store_type":"sql","auth_scheme":"OAUTH2","version":"0.70.0"}`
if res := runMLPlatformServerModule(t, zenml, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without deployment_type should not match zenml, got %d findings", len(res.Findings))
}
})
t.Run("a body without secrets_store_type is not flagged as zenml", func(t *testing.T) {
body := `{"deployment_type":"docker","auth_scheme":"OAUTH2"}`
if res := runMLPlatformServerModule(t, zenml, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without secrets_store_type should not match zenml, got %d findings", len(res.Findings))
}
})
t.Run("a body without auth_scheme is not flagged as zenml", func(t *testing.T) {
body := `{"deployment_type":"docker","secrets_store_type":"sql"}`
if res := runMLPlatformServerModule(t, zenml, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without auth_scheme should not match zenml, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{h2o, mindsdb, zenml} {
if res := runMLPlatformServerModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{h2o, mindsdb, zenml} {
if res := runMLPlatformServerModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
@@ -0,0 +1,223 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
func runTrackingModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func trackingExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestMLTrackingExposureModules(t *testing.T) {
const mlflow = "../../modules/recon/mlflow-api-exposure.yaml"
const tensorboard = "../../modules/recon/tensorboard-exposure.yaml"
const aim = "../../modules/recon/aim-exposure.yaml"
const determined = "../../modules/recon/determined-master-exposure.yaml"
mlflowExperiment := `{"experiment":{"experiment_id":"0","name":"Default",` +
`"artifact_location":"file:///mlflow/mlruns/0","lifecycle_stage":"active",` +
`"creation_time":1700000000000,"last_update_time":1700000000000,"tags":[]}}`
tensorboardEnv := `{"data_location":"/home/ml/runs/exp-2024","window_title":"",` +
`"experiment_name":"","experiment_description":"","creation_time":0,"version":"2.16.2"}`
aimProject := `{"name":"my-aim-repo","path":"/home/ml/.aim","description":"",` +
`"telemetry_enabled":0,"warn_index":false,"warn_runs":false}`
determinedMaster := `{"version":"0.27.1","master_id":"6f1f2a9c","cluster_id":"a1b2c3d4-e5f6-7890",` +
`"cluster_name":"prod-cluster","telemetry_enabled":true,"rbac_enabled":false,` +
`"strict_job_queue_control":false,"has_custom_logo":false,"branding":"determined"}`
t.Run("an mlflow experiment is flagged with its artifact store", func(t *testing.T) {
res := runTrackingModule(t, mlflow, 200, mlflowExperiment)
if len(res.Findings) == 0 {
t.Fatal("expected an mlflow finding")
}
if v := trackingExtract(res, "mlflow_artifact_location"); v != "file:///mlflow/mlruns/0" {
t.Errorf("mlflow_artifact_location=%q, want file:///mlflow/mlruns/0", v)
}
})
t.Run("an experiment_id without lifecycle_stage is not flagged as mlflow", func(t *testing.T) {
body := `{"experiment_id":"5","artifact_location":"s3://bucket/x"}`
if res := runTrackingModule(t, mlflow, 200, body); len(res.Findings) > 0 {
t.Errorf("a partial body should not match mlflow, got %d findings", len(res.Findings))
}
})
t.Run("an experiment without an artifact_location is not flagged as mlflow", func(t *testing.T) {
body := `{"experiment":{"experiment_id":"0","name":"Default","lifecycle_stage":"active"}}`
if res := runTrackingModule(t, mlflow, 200, body); len(res.Findings) > 0 {
t.Errorf("an artifactless experiment should not match mlflow, got %d findings", len(res.Findings))
}
})
t.Run("an experiment without an experiment_id is not flagged as mlflow", func(t *testing.T) {
body := `{"experiment":{"name":"Default","artifact_location":"file:///x","lifecycle_stage":"active"}}`
if res := runTrackingModule(t, mlflow, 200, body); len(res.Findings) > 0 {
t.Errorf("an idless experiment should not match mlflow, got %d findings", len(res.Findings))
}
})
t.Run("a generic lifecycle body is not flagged as mlflow", func(t *testing.T) {
body := `{"lifecycle_stage":"production","name":"some-service"}`
if res := runTrackingModule(t, mlflow, 200, body); len(res.Findings) > 0 {
t.Errorf("a generic lifecycle body should not match mlflow, got %d findings", len(res.Findings))
}
})
t.Run("a model checkpoint list is not flagged as mlflow", func(t *testing.T) {
body := `[{"title":"x","model_name":"y","filename":"z"}]`
if res := runTrackingModule(t, mlflow, 200, body); len(res.Findings) > 0 {
t.Errorf("a model list should not match mlflow, got %d findings", len(res.Findings))
}
})
t.Run("a tensorboard environment is flagged with its version and run path", func(t *testing.T) {
res := runTrackingModule(t, tensorboard, 200, tensorboardEnv)
if len(res.Findings) == 0 {
t.Fatal("expected a tensorboard finding")
}
if v := trackingExtract(res, "tensorboard_version"); v != "2.16.2" {
t.Errorf("tensorboard_version=%q, want 2.16.2", v)
}
if v := trackingExtract(res, "tensorboard_data_location"); v != "/home/ml/runs/exp-2024" {
t.Errorf("tensorboard_data_location=%q, want /home/ml/runs/exp-2024", v)
}
})
t.Run("a body without data_location is not flagged as tensorboard", func(t *testing.T) {
body := `{"window_title":"","version":"2.16.2"}`
if res := runTrackingModule(t, tensorboard, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without data_location should not match tensorboard, got %d findings", len(res.Findings))
}
})
t.Run("a body without window_title is not flagged as tensorboard", func(t *testing.T) {
body := `{"data_location":"/runs","version":"2.16.2"}`
if res := runTrackingModule(t, tensorboard, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without window_title should not match tensorboard, got %d findings", len(res.Findings))
}
})
t.Run("a body without a version is not flagged as tensorboard", func(t *testing.T) {
body := `{"data_location":"/runs","window_title":"my board"}`
if res := runTrackingModule(t, tensorboard, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without version should not match tensorboard, got %d findings", len(res.Findings))
}
})
t.Run("an aim project is flagged with its repo path", func(t *testing.T) {
res := runTrackingModule(t, aim, 200, aimProject)
if len(res.Findings) == 0 {
t.Fatal("expected an aim finding")
}
if v := trackingExtract(res, "aim_project_path"); v != "/home/ml/.aim" {
t.Errorf("aim_project_path=%q, want /home/ml/.aim", v)
}
})
t.Run("a body without telemetry_enabled is not flagged as aim", func(t *testing.T) {
body := `{"name":"x","path":"/y","warn_index":false,"warn_runs":false}`
if res := runTrackingModule(t, aim, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without telemetry_enabled should not match aim, got %d findings", len(res.Findings))
}
})
t.Run("a body without warn_index is not flagged as aim", func(t *testing.T) {
body := `{"telemetry_enabled":0,"warn_runs":false,"name":"x"}`
if res := runTrackingModule(t, aim, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without warn_index should not match aim, got %d findings", len(res.Findings))
}
})
t.Run("a body without warn_runs is not flagged as aim", func(t *testing.T) {
body := `{"telemetry_enabled":0,"warn_index":false,"name":"x"}`
if res := runTrackingModule(t, aim, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without warn_runs should not match aim, got %d findings", len(res.Findings))
}
})
t.Run("a determined master is flagged with its version and cluster id", func(t *testing.T) {
res := runTrackingModule(t, determined, 200, determinedMaster)
if len(res.Findings) == 0 {
t.Fatal("expected a determined finding")
}
if v := trackingExtract(res, "determined_version"); v != "0.27.1" {
t.Errorf("determined_version=%q, want 0.27.1", v)
}
if v := trackingExtract(res, "determined_cluster_id"); v != "a1b2c3d4-e5f6-7890" {
t.Errorf("determined_cluster_id=%q, want a1b2c3d4-e5f6-7890", v)
}
})
t.Run("a cluster info without a master_id is not flagged as determined", func(t *testing.T) {
body := `{"cluster_id":"x","cluster_name":"y","version":"1.0"}`
if res := runTrackingModule(t, determined, 200, body); len(res.Findings) > 0 {
t.Errorf("a masterless cluster info should not match determined, got %d findings", len(res.Findings))
}
})
t.Run("a master without a cluster_id is not flagged as determined", func(t *testing.T) {
body := `{"master_id":"x","cluster_name":"y","version":"1.0"}`
if res := runTrackingModule(t, determined, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without cluster_id should not match determined, got %d findings", len(res.Findings))
}
})
t.Run("a master without a cluster_name is not flagged as determined", func(t *testing.T) {
body := `{"master_id":"x","cluster_id":"y","version":"1.0"}`
if res := runTrackingModule(t, determined, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without cluster_name should not match determined, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{mlflow, tensorboard, aim, determined} {
if res := runTrackingModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{mlflow, tensorboard, aim, determined} {
if res := runTrackingModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
+4 -2
View File
@@ -86,13 +86,14 @@ type Finding struct {
// Matcher defines matching logic for module responses.
// Matchers are used to determine if a response indicates a vulnerability.
type Matcher struct {
Type string `yaml:"type"` // regex, status, word, size
Type string `yaml:"type"` // regex, status, word, favicon
Part string `yaml:"part"` // body, header, all
Regex []string `yaml:"regex,omitempty"`
Words []string `yaml:"words,omitempty"`
Status []int `yaml:"status,omitempty"`
Size []int `yaml:"size,omitempty"`
Condition string `yaml:"condition"` // and, or
Hash []int64 `yaml:"hash,omitempty"` // favicon: shodan mmh3 hashes (signed or unsigned)
Condition string `yaml:"condition"` // and, or
Negative bool `yaml:"negative"`
}
@@ -103,5 +104,6 @@ type Extractor struct {
Name string `yaml:"name"`
Part string `yaml:"part"`
Regex []string `yaml:"regex,omitempty"`
JSON []string `yaml:"json,omitempty"`
Group int `yaml:"group"`
}
@@ -0,0 +1,84 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
func runN8nModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func n8nExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestN8nSettingsExposureModule(t *testing.T) {
const n8n = "../../modules/recon/n8n-settings-exposure.yaml"
t.Run("an n8n settings response is flagged with the version", func(t *testing.T) {
body := `{"data":{"endpointWebhook":"webhook","endpointWebhookTest":"webhook-test",` +
`"urlBaseWebhook":"https://n8n.example.com/","urlBaseEditor":"https://n8n.example.com/",` +
`"versionCli":"1.45.1","releaseChannel":"stable","instanceId":"abc123def","n8nMetadata":{},` +
`"userManagement":{"showSetupOnFirstLoad":false,"smtpSetup":true,"authenticationMethod":"email"}}}`
res := runN8nModule(t, n8n, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected an n8n finding")
}
if v := n8nExtract(res, "n8n_version"); v != "1.45.1" {
t.Errorf("n8n_version=%q, want 1.45.1", v)
}
})
t.Run("a settings blob without instanceId is not flagged", func(t *testing.T) {
if res := runN8nModule(t, n8n, 200, `{"data":{"endpointWebhook":"webhook","versionCli":"1.45.1"}}`); len(res.Findings) > 0 {
t.Errorf("an instanceless settings blob should not match, got %d findings", len(res.Findings))
}
})
t.Run("a settings blob without endpointWebhook is not flagged", func(t *testing.T) {
if res := runN8nModule(t, n8n, 200, `{"data":{"versionCli":"1.45.1","instanceId":"abc"}}`); len(res.Findings) > 0 {
t.Errorf("a webhookless settings blob should not match, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
if res := runN8nModule(t, n8n, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("a plain 200 body should not match, got %d findings", len(res.Findings))
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
if res := runN8nModule(t, n8n, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("a 404 should not match, got %d findings", len(res.Findings))
}
})
}
@@ -0,0 +1,89 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
func runNodeRedModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func noderedExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestNodeREDFlowExposureModule(t *testing.T) {
const nodered = "../../modules/recon/nodered-flow-exposure.yaml"
t.Run("an open node-red flows export is flagged with its tab label", func(t *testing.T) {
body := `[{"id":"396c2376.c693dc","type":"tab","label":"Sheet 1"},` +
`{"id":"a1","type":"inject","z":"396c2376.c693dc","wires":[["b2"]]},` +
`{"id":"b2","type":"function","z":"396c2376.c693dc","func":"return msg;","wires":[[]]}]`
res := runNodeRedModule(t, nodered, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a node-red finding")
}
if v := noderedExtract(res, "nodered_flow_label"); v != "Sheet 1" {
t.Errorf("nodered_flow_label=%q, want Sheet 1", v)
}
})
t.Run("an adminAuth node-red returns 401 and is not flagged", func(t *testing.T) {
if res := runNodeRedModule(t, nodered, 401, `{"message":"Unauthorized"}`); len(res.Findings) > 0 {
t.Errorf("a 401 from a secured node-red should not match, got %d findings", len(res.Findings))
}
})
t.Run("a tabs-only flow without wires is not flagged", func(t *testing.T) {
if res := runNodeRedModule(t, nodered, 200, `[{"id":"x","type":"tab","label":"Home"}]`); len(res.Findings) > 0 {
t.Errorf("a tabs-only flow should not match, got %d findings", len(res.Findings))
}
})
t.Run("a wired graph without a tab is not flagged as node-red", func(t *testing.T) {
if res := runNodeRedModule(t, nodered, 200, `[{"id":"x","type":"section","wires":[["y"]]}]`); len(res.Findings) > 0 {
t.Errorf("a wired graph without a tab should not match, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
if res := runNodeRedModule(t, nodered, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("a plain 200 body should not match, got %d findings", len(res.Findings))
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
if res := runNodeRedModule(t, nodered, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("a 404 should not match, got %d findings", len(res.Findings))
}
})
}
@@ -0,0 +1,135 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
func runObservabilityModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func observExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestObservabilityExposureModules(t *testing.T) {
const loki = "../../modules/recon/loki-api-exposure.yaml"
const jaeger = "../../modules/recon/jaeger-query-exposure.yaml"
const zipkin = "../../modules/recon/zipkin-exposure.yaml"
t.Run("an open loki labels response is flagged with a label", func(t *testing.T) {
body := `{"status":"success","data":["app","filename","job","namespace","pod"]}`
res := runObservabilityModule(t, loki, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a loki finding")
}
if v := observExtract(res, "loki_label"); v != "app" {
t.Errorf("loki_label=%q, want app", v)
}
})
t.Run("an open loki with no ingested labels is still flagged", func(t *testing.T) {
if res := runObservabilityModule(t, loki, 200, `{"status":"success","data":[]}`); len(res.Findings) == 0 {
t.Error("expected a loki finding for an empty-but-open instance")
}
})
t.Run("a multi-tenant loki returns 401 and is not flagged", func(t *testing.T) {
if res := runObservabilityModule(t, loki, 401, `no org id\n`); len(res.Findings) > 0 {
t.Errorf("a 401 from a secured loki should not match, got %d findings", len(res.Findings))
}
})
t.Run("a non-loki success envelope is not flagged as loki", func(t *testing.T) {
if res := runObservabilityModule(t, loki, 200, `{"ok":true,"items":[]}`); len(res.Findings) > 0 {
t.Errorf("a body without the loki shape should not match, got %d findings", len(res.Findings))
}
})
t.Run("a jaeger service list is flagged with a service name", func(t *testing.T) {
body := `{"data":["customer","driver","frontend","route"],"total":0,"limit":0,"offset":0,"errors":null}`
res := runObservabilityModule(t, jaeger, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a jaeger finding")
}
if v := observExtract(res, "jaeger_service"); v != "customer" {
t.Errorf("jaeger_service=%q, want customer", v)
}
})
t.Run("a generic pagination envelope without errors is not flagged as jaeger", func(t *testing.T) {
body := `{"data":["a","b"],"total":2,"limit":10,"offset":0}`
if res := runObservabilityModule(t, jaeger, 200, body); len(res.Findings) > 0 {
t.Errorf("an envelope without errors should not match jaeger, got %d findings", len(res.Findings))
}
})
t.Run("a bare data array is not flagged as jaeger", func(t *testing.T) {
if res := runObservabilityModule(t, jaeger, 200, `{"data":["x"]}`); len(res.Findings) > 0 {
t.Errorf("a bare data array should not match jaeger, got %d findings", len(res.Findings))
}
})
t.Run("a zipkin config is flagged with its environment", func(t *testing.T) {
body := `{"environment":"prod","queryLimit":10,"defaultLookback":900000,"searchEnabled":true,` +
`"dependency":{"enabled":true,"lowErrorRate":0.5,"highErrorRate":0.75}}`
res := runObservabilityModule(t, zipkin, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a zipkin finding")
}
if v := observExtract(res, "zipkin_environment"); v != "prod" {
t.Errorf("zipkin_environment=%q, want prod", v)
}
})
t.Run("a config with searchEnabled alone is not flagged as zipkin", func(t *testing.T) {
if res := runObservabilityModule(t, zipkin, 200, `{"searchEnabled":true,"foo":1}`); len(res.Findings) > 0 {
t.Errorf("a partial config should not match zipkin, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{loki, jaeger, zipkin} {
if res := runObservabilityModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{loki, jaeger, zipkin} {
if res := runObservabilityModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
+112
View File
@@ -0,0 +1,112 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
// runOpsModule runs a shipped module end to end against a server that returns
// the same status and body for every path it requests.
func runOpsModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func opsExtracted(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v, ok := f.Extracted[key]; ok {
return v
}
}
return ""
}
func TestOpsPanelModules(t *testing.T) {
cases := []struct {
name string
file string
status int
body string
wantFinding bool
versionKey string
versionVal string
}{
{
name: "portainer status api", file: "../../modules/info/portainer-panel.yaml", status: 200,
body: `{"Edition":"CE","Version":"2.19.4","InstanceID":"a1b2c3"}`,
wantFinding: true, versionKey: "portainer_version", versionVal: "2.19.4",
},
{
name: "portainer version-only json is not a match", file: "../../modules/info/portainer-panel.yaml", status: 200,
body: `{"Version":"1.0.0"}`, wantFinding: false,
},
{
name: "portainer real body behind a 404 is not a match", file: "../../modules/info/portainer-panel.yaml", status: 404,
body: `{"Edition":"CE","Version":"2.19.4","InstanceID":"a1b2c3"}`, wantFinding: false,
},
{
name: "traefik version api", file: "../../modules/info/traefik-panel.yaml", status: 200,
body: `{"Version":"2.10.4","Codename":"saintnectaire","startDate":"2024-01-01T00:00:00Z"}`,
wantFinding: true, versionKey: "traefik_version", versionVal: "2.10.4",
},
{
name: "traefik without codename is not a match", file: "../../modules/info/traefik-panel.yaml", status: 200,
body: `{"Version":"2.10.4"}`, wantFinding: false,
},
{
name: "keycloak realm endpoint", file: "../../modules/info/keycloak-panel.yaml", status: 200,
body: `{"realm":"master","public_key":"MIIBIjAN","token-service":"https://h/realms/master/protocol/openid-connect","account-service":"https://h/realms/master/account"}`,
wantFinding: true, versionKey: "keycloak_realm", versionVal: "master",
},
{
name: "keycloak partial realm json is not a match", file: "../../modules/info/keycloak-panel.yaml", status: 200,
body: `{"realm":"master","public_key":"MIIBIjAN"}`, wantFinding: false,
},
{
name: "rabbitmq management ui", file: "../../modules/info/rabbitmq-panel.yaml", status: 200,
body: `<!DOCTYPE html><html><head><title>RabbitMQ Management</title></head><body><img src="img/rabbitmqlogo.svg"></body></html>`,
wantFinding: true,
},
{
name: "rabbitmq unrelated page is not a match", file: "../../modules/info/rabbitmq-panel.yaml", status: 200,
body: `<html><body>nothing to see</body></html>`, wantFinding: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
res := runOpsModule(t, tc.file, tc.status, tc.body)
got := len(res.Findings) > 0
if got != tc.wantFinding {
t.Fatalf("findings=%d, want match=%v", len(res.Findings), tc.wantFinding)
}
if tc.versionKey != "" {
if v := opsExtracted(res, tc.versionKey); v != tc.versionVal {
t.Errorf("extracted[%q]=%q, want %q", tc.versionKey, v, tc.versionVal)
}
}
})
}
}
@@ -7,7 +7,7 @@ import (
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
"github.com/vmfunc/sif/internal/modules"
)
func runOrchModule(t *testing.T, file string, status int, body string) *modules.Result {
@@ -7,7 +7,7 @@ import (
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
"github.com/vmfunc/sif/internal/modules"
)
func runRailsModule(t *testing.T, file string, status int, body string) *modules.Result {
+118
View File
@@ -0,0 +1,118 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
func runRegistryModule(t *testing.T, file string, status int, headers map[string]string, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for k, v := range headers {
w.Header().Set(k, v)
}
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func registryExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestRegistryExposureModules(t *testing.T) {
const dockerRegistry = "../../modules/recon/docker-registry-api-exposure.yaml"
const harbor = "../../modules/recon/harbor-api-exposure.yaml"
registryHeader := map[string]string{"Docker-Distribution-Api-Version": "registry/2.0"}
harborInfo := `{"auth_mode":"db_auth","registry_url":"harbor.example.com",` +
`"external_url":"https://harbor.example.com","harbor_version":"v2.9.1-1f4a3c9d",` +
`"self_registration":true,"has_ca_root":false,"read_only":false}`
t.Run("an anonymous docker registry is flagged with its api version", func(t *testing.T) {
res := runRegistryModule(t, dockerRegistry, 200, registryHeader, "{}")
if len(res.Findings) == 0 {
t.Fatal("expected a docker registry finding")
}
if v := registryExtract(res, "docker_registry_api_version"); v != "registry/2.0" {
t.Errorf("docker_registry_api_version=%q, want registry/2.0", v)
}
})
t.Run("a plain 200 without the registry header is not flagged", func(t *testing.T) {
if res := runRegistryModule(t, dockerRegistry, 200, nil, "{}"); len(res.Findings) > 0 {
t.Errorf("a 200 without the api-version header should not match, got %d findings", len(res.Findings))
}
})
t.Run("a registry that requires auth is not flagged", func(t *testing.T) {
if res := runRegistryModule(t, dockerRegistry, 401, registryHeader, ""); len(res.Findings) > 0 {
t.Errorf("a 401 registry should not match, got %d findings", len(res.Findings))
}
})
t.Run("an exposed harbor systeminfo is flagged and versioned", func(t *testing.T) {
res := runRegistryModule(t, harbor, 200, nil, harborInfo)
if len(res.Findings) == 0 {
t.Fatal("expected a harbor finding")
}
if v := registryExtract(res, "harbor_version"); v != "v2.9.1-1f4a3c9d" {
t.Errorf("harbor_version=%q, want v2.9.1-1f4a3c9d", v)
}
})
t.Run("a harbor version without an auth mode is not flagged", func(t *testing.T) {
body := `{"harbor_version":"v2.9.1","registry_url":"harbor.example.com"}`
if res := runRegistryModule(t, harbor, 200, nil, body); len(res.Findings) > 0 {
t.Errorf("a harbor version alone should not match, got %d findings", len(res.Findings))
}
})
t.Run("an auth mode without a harbor version is not flagged", func(t *testing.T) {
body := `{"auth_mode":"db_auth","self_registration":true}`
if res := runRegistryModule(t, harbor, 200, nil, body); len(res.Findings) > 0 {
t.Errorf("an auth mode alone should not match, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{dockerRegistry, harbor} {
if res := runRegistryModule(t, file, 200, nil, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{dockerRegistry, harbor} {
if res := runRegistryModule(t, file, 404, nil, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
@@ -0,0 +1,175 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
func runRuntimeModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func runtimeExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestRuntimeAPIExposureModules(t *testing.T) {
const docker = "../../modules/recon/docker-api-exposure.yaml"
const k8s = "../../modules/recon/kubernetes-api-exposure.yaml"
const kubelet = "../../modules/recon/kubelet-api-exposure.yaml"
dockerVersion := `{"Platform":{"Name":"Docker Engine - Community"},"Components":[` +
`{"Name":"Engine","Version":"24.0.7","Details":{"ApiVersion":"1.43"}},` +
`{"Name":"containerd","Version":"1.6.24"},{"Name":"runc","Version":"1.1.9"}],` +
`"Version":"24.0.7","ApiVersion":"1.43","MinAPIVersion":"1.12","GitCommit":"311b9ff",` +
`"GoVersion":"go1.20.10","Os":"linux","Arch":"amd64"}`
k8sVersion := `{"major":"1","minor":"28","gitVersion":"v1.28.2","gitCommit":"abc123",` +
`"gitTreeState":"clean","buildDate":"2023-09-13T09:35:49Z","goVersion":"go1.20.8",` +
`"compiler":"gc","platform":"linux/amd64"}`
kubeletPods := `{"kind":"PodList","apiVersion":"v1","metadata":{},"items":[{"metadata":` +
`{"name":"etcd-master","namespace":"kube-system"},"spec":{"containers":[{"name":"etcd"}]}}]}`
t.Run("an exposed docker api is flagged and versioned", func(t *testing.T) {
res := runRuntimeModule(t, docker, 200, dockerVersion)
if len(res.Findings) == 0 {
t.Fatal("expected a docker finding")
}
if v := runtimeExtract(res, "docker_version"); v != "24.0.7" {
t.Errorf("docker_version=%q, want 24.0.7", v)
}
})
t.Run("an exposed kubernetes api is flagged and versioned", func(t *testing.T) {
res := runRuntimeModule(t, k8s, 200, k8sVersion)
if len(res.Findings) == 0 {
t.Fatal("expected a kubernetes finding")
}
if v := runtimeExtract(res, "k8s_version"); v != "v1.28.2" {
t.Errorf("k8s_version=%q, want v1.28.2", v)
}
})
t.Run("an exposed kubelet leaks a pod namespace", func(t *testing.T) {
res := runRuntimeModule(t, kubelet, 200, kubeletPods)
if len(res.Findings) == 0 {
t.Fatal("expected a kubelet finding")
}
if v := runtimeExtract(res, "kubelet_namespace"); v != "kube-system" {
t.Errorf("kubelet_namespace=%q, want kube-system", v)
}
})
t.Run("a generic version json without the docker fields is not docker", func(t *testing.T) {
body := `{"version":"1.0.0","name":"myapp"}`
if res := runRuntimeModule(t, docker, 200, body); len(res.Findings) > 0 {
t.Errorf("a generic version should not match docker, got %d findings", len(res.Findings))
}
})
t.Run("an apiversion without a min api version is not docker", func(t *testing.T) {
body := `{"ApiVersion":"2.0","name":"otherservice"}`
if res := runRuntimeModule(t, docker, 200, body); len(res.Findings) > 0 {
t.Errorf("apiversion alone should not match docker, got %d findings", len(res.Findings))
}
})
t.Run("a min api version without an api version is not docker", func(t *testing.T) {
body := `{"MinAPIVersion":"1.12","Os":"linux"}`
if res := runRuntimeModule(t, docker, 200, body); len(res.Findings) > 0 {
t.Errorf("min api version alone should not match docker, got %d findings", len(res.Findings))
}
})
t.Run("a generic version json without the git fields is not kubernetes", func(t *testing.T) {
body := `{"version":"1.2.3","build":"xyz"}`
if res := runRuntimeModule(t, k8s, 200, body); len(res.Findings) > 0 {
t.Errorf("a generic version should not match kubernetes, got %d findings", len(res.Findings))
}
})
t.Run("a gitversion without a git tree state is not kubernetes", func(t *testing.T) {
body := `{"gitVersion":"v1.0.0","app":"custom"}`
if res := runRuntimeModule(t, k8s, 200, body); len(res.Findings) > 0 {
t.Errorf("gitversion alone should not match kubernetes, got %d findings", len(res.Findings))
}
})
t.Run("a build date without a gitversion is not kubernetes", func(t *testing.T) {
body := `{"buildDate":"2023-01-01T00:00:00Z","app":"custom"}`
if res := runRuntimeModule(t, k8s, 200, body); len(res.Findings) > 0 {
t.Errorf("build date alone should not match kubernetes, got %d findings", len(res.Findings))
}
})
t.Run("a service list is not a kubelet pod list", func(t *testing.T) {
body := `{"kind":"ServiceList","apiVersion":"v1","items":[]}`
if res := runRuntimeModule(t, kubelet, 200, body); len(res.Findings) > 0 {
t.Errorf("a service list should not match kubelet, got %d findings", len(res.Findings))
}
})
t.Run("a pod list without an api version is not flagged", func(t *testing.T) {
body := `{"kind":"PodList","items":[]}`
if res := runRuntimeModule(t, kubelet, 200, body); len(res.Findings) > 0 {
t.Errorf("a pod list without apiversion should not match, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{docker, k8s, kubelet} {
if res := runRuntimeModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{docker, k8s, kubelet} {
if res := runRuntimeModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
@@ -7,7 +7,7 @@ import (
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
"github.com/vmfunc/sif/internal/modules"
)
func runSecretModule(t *testing.T, file string, status int, body string) *modules.Result {
@@ -0,0 +1,101 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
func runServerAdminModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func serverAdminExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestServerAdminExposureModules(t *testing.T) {
const caddy = "../../modules/recon/caddy-admin-exposure.yaml"
const envoy = "../../modules/recon/envoy-admin-exposure.yaml"
t.Run("a caddy config dump is flagged with a handler", func(t *testing.T) {
body := `{"apps":{"http":{"servers":{"srv0":{"listen":[":443"],"routes":[{"match":[{"host":` +
`["example.com"]}],"handle":[{"handler":"reverse_proxy","upstreams":[{"dial":"localhost:8080"}]}]}]}}},` +
`"tls":{"automation":{"policies":[{"issuers":[{"module":"acme"}]}]}}},"admin":{"listen":"0.0.0.0:2019"}}`
res := runServerAdminModule(t, caddy, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a caddy finding")
}
if v := serverAdminExtract(res, "caddy_handler"); v != "reverse_proxy" {
t.Errorf("caddy_handler=%q, want reverse_proxy", v)
}
})
t.Run("an apps block without servers or handler is not flagged as caddy", func(t *testing.T) {
if res := runServerAdminModule(t, caddy, 200, `{"apps":{"tls":{"automation":{}}}}`); len(res.Findings) > 0 {
t.Errorf("an apps-only tls block should not match caddy, got %d findings", len(res.Findings))
}
})
t.Run("an envoy server_info is flagged with its version", func(t *testing.T) {
body := `{"version":"1.28.0/abcd/Clean/RELEASE/BoringSSL","state":"LIVE","uptime_current_epoch":"3600s",` +
`"uptime_all_epochs":"3600s","hot_restart_version":"11.104","command_line_options":{"base_id":"0",` +
`"concurrency":4,"config_path":"/etc/envoy/envoy.yaml"},"node":{"id":"node-1","cluster":"prod"}}`
res := runServerAdminModule(t, envoy, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected an envoy finding")
}
if v := serverAdminExtract(res, "envoy_version"); v != "1.28.0/abcd/Clean/RELEASE/BoringSSL" {
t.Errorf("envoy_version=%q, want the build string", v)
}
})
t.Run("a bare version+state body is not flagged as envoy", func(t *testing.T) {
if res := runServerAdminModule(t, envoy, 200, `{"version":"1.0","state":"LIVE"}`); len(res.Findings) > 0 {
t.Errorf("a bare version+state should not match envoy, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{caddy, envoy} {
if res := runServerAdminModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{caddy, envoy} {
if res := runServerAdminModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
@@ -0,0 +1,150 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
const eurekaAppsXML = `<applications><versions__delta>1</versions__delta>` +
`<apps__hashcode>UP_5_</apps__hashcode><application><name>PAYMENT-SERVICE</name>` +
`<instance><instanceId>10.0.0.5:payment-service:8443</instanceId>` +
`<hostName>payment-1.svc.internal</hostName><app>PAYMENT-SERVICE</app>` +
`<ipAddr>10.0.0.5</ipAddr><status>UP</status><port enabled="true">8443</port>` +
`<vipAddress>payment-service</vipAddress></instance></application></applications>`
const eurekaAppsJSON = `{"applications":{"versions__delta":"1","apps__hashcode":"UP_5_",` +
`"application":[{"name":"PAYMENT-SERVICE","instance":[{"instanceId":"p1",` +
`"hostName":"payment-1.svc.internal","ipAddr":"10.0.0.5","status":"UP",` +
`"vipAddress":"payment-service"}]}]}}`
const sbaInstancesJSON = `[{"id":"a1b2c3","version":12,"registration":{"name":"order-service",` +
`"managementUrl":"http://order-1.internal:8080/actuator",` +
`"healthUrl":"http://order-1.internal:8080/actuator/health",` +
`"serviceUrl":"http://order-1.internal:8080/","source":"http-api","metadata":{}},` +
`"registered":true,"statusInfo":{"status":"UP","timestamp":"2026-06-25T20:00:00Z","details":{}},` +
`"buildVersion":"1.0.0","tags":{}}]`
func runServiceRegistryModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func svcRegExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestServiceRegistryExposureModules(t *testing.T) {
const eureka = "../../modules/recon/eureka-registry-exposure.yaml"
const sba = "../../modules/recon/spring-boot-admin-exposure.yaml"
t.Run("an open eureka registry (xml) is flagged with the instance ip", func(t *testing.T) {
res := runServiceRegistryModule(t, eureka, 200, eurekaAppsXML)
if len(res.Findings) == 0 {
t.Fatal("expected a eureka finding")
}
if v := svcRegExtract(res, "eureka_instance_ip"); v != "10.0.0.5" {
t.Errorf("eureka_instance_ip=%q, want 10.0.0.5", v)
}
})
t.Run("an open eureka registry (json) is also flagged with the instance ip", func(t *testing.T) {
res := runServiceRegistryModule(t, eureka, 200, eurekaAppsJSON)
if len(res.Findings) == 0 {
t.Fatal("expected a eureka finding for the json form")
}
if v := svcRegExtract(res, "eureka_instance_ip"); v != "10.0.0.5" {
t.Errorf("eureka_instance_ip=%q, want 10.0.0.5", v)
}
})
t.Run("an envelope without apps__hashcode is not flagged", func(t *testing.T) {
body := `<applications><versions__delta>1</versions__delta></applications>`
if res := runServiceRegistryModule(t, eureka, 200, body); len(res.Findings) > 0 {
t.Errorf("an apps__hashcode-less envelope should not match, got %d findings", len(res.Findings))
}
})
t.Run("a page that merely mentions applications is not flagged", func(t *testing.T) {
body := `<html><body>Our applications support multiple versions of the eureka client.</body></html>`
if res := runServiceRegistryModule(t, eureka, 200, body); len(res.Findings) > 0 {
t.Errorf("a prose page should not match eureka, got %d findings", len(res.Findings))
}
})
t.Run("a secured eureka is not flagged", func(t *testing.T) {
if res := runServiceRegistryModule(t, eureka, 401, "Unauthorized"); len(res.Findings) > 0 {
t.Errorf("a 401 eureka should not match, got %d findings", len(res.Findings))
}
})
t.Run("a spring boot admin body does not match the eureka module", func(t *testing.T) {
if res := runServiceRegistryModule(t, eureka, 200, sbaInstancesJSON); len(res.Findings) > 0 {
t.Errorf("an sba body should not match eureka, got %d findings", len(res.Findings))
}
})
t.Run("an open spring boot admin is flagged with the health url", func(t *testing.T) {
res := runServiceRegistryModule(t, sba, 200, sbaInstancesJSON)
if len(res.Findings) == 0 {
t.Fatal("expected an sba finding")
}
if v := svcRegExtract(res, "sba_health_url"); v != "http://order-1.internal:8080/actuator/health" {
t.Errorf("sba_health_url=%q, want the internal actuator health url", v)
}
})
t.Run("a registration without statusInfo is not flagged", func(t *testing.T) {
body := `[{"registration":{"name":"x","healthUrl":"http://h:8080/health"}}]`
if res := runServiceRegistryModule(t, sba, 200, body); len(res.Findings) > 0 {
t.Errorf("a statusInfo-less body should not match sba, got %d findings", len(res.Findings))
}
})
t.Run("a secured spring boot admin is not flagged", func(t *testing.T) {
if res := runServiceRegistryModule(t, sba, 401, `{"error":"Unauthorized"}`); len(res.Findings) > 0 {
t.Errorf("a 401 sba should not match, got %d findings", len(res.Findings))
}
})
t.Run("a eureka body does not match the spring boot admin module", func(t *testing.T) {
if res := runServiceRegistryModule(t, sba, 200, eurekaAppsJSON); len(res.Findings) > 0 {
t.Errorf("a eureka body should not match sba, got %d findings", len(res.Findings))
}
})
t.Run("plain 200 bodies are not leaks", func(t *testing.T) {
if res := runServiceRegistryModule(t, eureka, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("a plain 200 should not match eureka, got %d findings", len(res.Findings))
}
if res := runServiceRegistryModule(t, sba, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("a 404 should not match sba, got %d findings", len(res.Findings))
}
})
}
@@ -0,0 +1,143 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
func runSpeechModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func speechExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestSpeechAudioExposureModules(t *testing.T) {
const speaches = "../../modules/recon/speaches-api-exposure.yaml"
const xtts = "../../modules/recon/xtts-api-server-exposure.yaml"
speachesSTT := `{"data":[{"id":"Systran/faster-whisper-small","created":0,"object":"model",` +
`"owned_by":"Systran","language":["en"],"task":"automatic-speech-recognition"}],"object":"list"}`
speachesTTS := `{"data":[{"id":"speaches-ai/Kokoro-82M-v1.0-ONNX","created":0,"object":"model",` +
`"owned_by":"speaches-ai","language":["en"],"task":"text-to-speech"}],"object":"list"}`
xttsFolders := `{"speaker_folder":"/app/speakers","output_folder":"/app/output",` +
`"model_folder":"/app/xtts_models"}`
t.Run("a speaches stt model list is flagged with its model id", func(t *testing.T) {
res := runSpeechModule(t, speaches, 200, speachesSTT)
if len(res.Findings) == 0 {
t.Fatal("expected a speaches finding")
}
if v := speechExtract(res, "speaches_model"); v != "Systran/faster-whisper-small" {
t.Errorf("speaches_model=%q, want Systran/faster-whisper-small", v)
}
})
t.Run("a speaches tts-only model list is flagged", func(t *testing.T) {
if res := runSpeechModule(t, speaches, 200, speachesTTS); len(res.Findings) == 0 {
t.Error("expected a speaches finding for a text-to-speech model list")
}
})
t.Run("a generic openai-compatible model list is not flagged as speaches", func(t *testing.T) {
body := `{"data":[{"id":"meta-llama/Llama-3","created":1234,"object":"model","owned_by":"vllm"}],"object":"list"}`
if res := runSpeechModule(t, speaches, 200, body); len(res.Findings) > 0 {
t.Errorf("a taskless model list should not match speaches, got %d findings", len(res.Findings))
}
})
t.Run("a model list with a non-speech task is not flagged as speaches", func(t *testing.T) {
body := `{"data":[{"id":"bert-base","object":"model","owned_by":"hf","task":"fill-mask"}],"object":"list"}`
if res := runSpeechModule(t, speaches, 200, body); len(res.Findings) > 0 {
t.Errorf("a non-speech task should not match speaches, got %d findings", len(res.Findings))
}
})
t.Run("an empty speaches model list is not flagged", func(t *testing.T) {
// A fresh speaches server with no models downloaded returns an empty data
// array with nothing speaches-specific to anchor on; this known miss is
// preferable to firing on every empty OpenAI-shaped list.
body := `{"data":[],"object":"list"}`
if res := runSpeechModule(t, speaches, 200, body); len(res.Findings) > 0 {
t.Errorf("an empty model list should not match speaches, got %d findings", len(res.Findings))
}
})
t.Run("an xtts get-folders is flagged with its model folder", func(t *testing.T) {
res := runSpeechModule(t, xtts, 200, xttsFolders)
if len(res.Findings) == 0 {
t.Fatal("expected an xtts finding")
}
if v := speechExtract(res, "xtts_model_folder"); v != "/app/xtts_models" {
t.Errorf("xtts_model_folder=%q, want /app/xtts_models", v)
}
})
t.Run("a body without speaker_folder is not flagged as xtts", func(t *testing.T) {
body := `{"output_folder":"/app/output","model_folder":"/app/xtts_models"}`
if res := runSpeechModule(t, xtts, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without speaker_folder should not match xtts, got %d findings", len(res.Findings))
}
})
t.Run("a body without model_folder is not flagged as xtts", func(t *testing.T) {
body := `{"speaker_folder":"/app/speakers","output_folder":"/app/output"}`
if res := runSpeechModule(t, xtts, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without model_folder should not match xtts, got %d findings", len(res.Findings))
}
})
t.Run("a body without output_folder is not flagged as xtts", func(t *testing.T) {
body := `{"speaker_folder":"/app/speakers","model_folder":"/app/xtts_models"}`
if res := runSpeechModule(t, xtts, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without output_folder should not match xtts, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{speaches, xtts} {
if res := runSpeechModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{speaches, xtts} {
if res := runSpeechModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
@@ -0,0 +1,155 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
func runVCSModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func vcsExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestVCSMetadataExposureModules(t *testing.T) {
const svn = "../../modules/recon/svn-exposure.yaml"
const hg = "../../modules/recon/mercurial-exposure.yaml"
const bzr = "../../modules/recon/bazaar-exposure.yaml"
svnWcDb := "SQLite format 3\x00" + strings.Repeat("\x00", 80) +
"CREATE TABLE WCROOT (id INTEGER PRIMARY KEY);" +
"CREATE TABLE REPOSITORY (root TEXT, uuid TEXT);" +
"\x01root\x00https://svn.example.com/myrepo/trunk\x00"
hgRequires := "revlogv1\nstore\nfncache\ndotencode\ngeneraldelta\nsparserevlog\n"
bzrFormat := "Bazaar-NG meta directory, format 1\n"
t.Run("an exposed svn wc.db leaks the repository url", func(t *testing.T) {
res := runVCSModule(t, svn, 200, svnWcDb)
if len(res.Findings) == 0 {
t.Fatal("expected an svn finding")
}
if v := vcsExtract(res, "svn_repository"); v != "https://svn.example.com/myrepo/trunk" {
t.Errorf("svn_repository=%q, want https://svn.example.com/myrepo/trunk", v)
}
})
t.Run("an exposed mercurial requires is flagged", func(t *testing.T) {
res := runVCSModule(t, hg, 200, hgRequires)
if len(res.Findings) == 0 {
t.Fatal("expected a mercurial finding")
}
if v := vcsExtract(res, "hg_requirement"); v != "revlogv1" {
t.Errorf("hg_requirement=%q, want revlogv1", v)
}
})
t.Run("an exposed bazaar branch-format is flagged", func(t *testing.T) {
res := runVCSModule(t, bzr, 200, bzrFormat)
if len(res.Findings) == 0 {
t.Fatal("expected a bazaar finding")
}
if v := vcsExtract(res, "bzr_format"); v != "Bazaar-NG meta directory, format 1" {
t.Errorf("bzr_format=%q, want the meta directory signature", v)
}
})
t.Run("a generic sqlite database without svn tables is not flagged", func(t *testing.T) {
body := "SQLite format 3\x00" + strings.Repeat("\x00", 80) +
"CREATE TABLE users (id INTEGER, name TEXT, email TEXT);"
if res := runVCSModule(t, svn, 200, body); len(res.Findings) > 0 {
t.Errorf("a generic sqlite db should not match svn, got %d findings", len(res.Findings))
}
})
t.Run("a generic sqlite with a nodes table is not an svn working copy", func(t *testing.T) {
body := "SQLite format 3\x00" + strings.Repeat("\x00", 80) +
"CREATE TABLE NODES (id INTEGER PRIMARY KEY, parent INTEGER, label TEXT);"
if res := runVCSModule(t, svn, 200, body); len(res.Findings) > 0 {
t.Errorf("a generic nodes table should not match svn, got %d findings", len(res.Findings))
}
})
t.Run("an svn magic that is not at byte zero is not flagged", func(t *testing.T) {
body := "<!DOCTYPE html><html><body><pre>SQLite format 3 WCROOT REPOSITORY</pre></body></html>"
if res := runVCSModule(t, svn, 200, body); len(res.Findings) > 0 {
t.Errorf("an unanchored magic should not match, got %d findings", len(res.Findings))
}
})
t.Run("a page naming mercurial without the requires format is not flagged", func(t *testing.T) {
body := "this project uses mercurial for distributed version control"
if res := runVCSModule(t, hg, 200, body); len(res.Findings) > 0 {
t.Errorf("prose naming mercurial should not match, got %d findings", len(res.Findings))
}
})
t.Run("an html page demonstrating hg requires is not a leak", func(t *testing.T) {
body := "<!DOCTYPE html><html><body><pre>revlogv1\nstore\ndotencode</pre></body></html>"
if res := runVCSModule(t, hg, 200, body); len(res.Findings) > 0 {
t.Errorf("an html hg tutorial should not match, got %d findings", len(res.Findings))
}
})
t.Run("a bazaar marketplace page is not a repository", func(t *testing.T) {
body := "Welcome to the Bazaar, the finest open air marketplace in town"
if res := runVCSModule(t, bzr, 200, body); len(res.Findings) > 0 {
t.Errorf("a marketplace page should not match, got %d findings", len(res.Findings))
}
})
t.Run("an html page demonstrating bzr branch-format is not a leak", func(t *testing.T) {
body := "<!DOCTYPE html><html><body><pre>Bazaar-NG meta directory, format 1</pre></body></html>"
if res := runVCSModule(t, bzr, 200, body); len(res.Findings) > 0 {
t.Errorf("an html bzr tutorial should not match, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{svn, hg, bzr} {
if res := runVCSModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{svn, hg, bzr} {
if res := runVCSModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
"github.com/vmfunc/sif/internal/modules"
)
func runVectorDBModule(t *testing.T, file string, status int, body string) *modules.Result {
@@ -0,0 +1,150 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/vmfunc/sif/internal/modules"
)
func runVectorSearchModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func vectorSearchExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestVectorSearchExposureModules(t *testing.T) {
const marqo = "../../modules/recon/marqo-exposure.yaml"
const vespa = "../../modules/recon/vespa-status-exposure.yaml"
const meilisearch = "../../modules/recon/meilisearch-exposure.yaml"
marqoRoot := `{"message":"Welcome to Marqo","version":"2.11.0"}`
vespaStatus := `{"application":{"vespa":{"version":"8.43.64"},"meta":{"name":"default","generation":11}},` +
`"abstractComponents":[],"handlers":[],"clients":[],"servers":[],"httpRequestFilters":[],` +
`"httpResponseFilters":[],"processingChains":[]}`
meiliVersion := `{"commitSha":"b46889b5","commitDate":"2026-01-15T00:00:00Z","pkgVersion":"1.12.0"}`
t.Run("a marqo root is flagged with its version", func(t *testing.T) {
res := runVectorSearchModule(t, marqo, 200, marqoRoot)
if len(res.Findings) == 0 {
t.Fatal("expected a marqo finding")
}
if v := vectorSearchExtract(res, "marqo_version"); v != "2.11.0" {
t.Errorf("marqo_version=%q, want 2.11.0", v)
}
})
t.Run("a generic root with a version is not flagged as marqo", func(t *testing.T) {
body := `{"message":"Welcome","version":"2.11.0","service":"something-else"}`
if res := runVectorSearchModule(t, marqo, 200, body); len(res.Findings) > 0 {
t.Errorf("a generic welcome should not match marqo, got %d findings", len(res.Findings))
}
})
t.Run("a marqo welcome without a version is not flagged", func(t *testing.T) {
if res := runVectorSearchModule(t, marqo, 200, `{"message":"Welcome to Marqo"}`); len(res.Findings) > 0 {
t.Errorf("a versionless welcome should not match marqo, got %d findings", len(res.Findings))
}
})
t.Run("an html page mentioning marqo is not flagged", func(t *testing.T) {
body := `<html><body><h1>Welcome to Marqo</h1><p>version 2.11.0 docs</p></body></html>`
if res := runVectorSearchModule(t, marqo, 200, body); len(res.Findings) > 0 {
t.Errorf("prose mentioning marqo should not match the structured response, got %d findings", len(res.Findings))
}
})
t.Run("a vespa status is flagged with its version", func(t *testing.T) {
res := runVectorSearchModule(t, vespa, 200, vespaStatus)
if len(res.Findings) == 0 {
t.Fatal("expected a vespa finding")
}
if v := vectorSearchExtract(res, "vespa_version"); v != "8.43.64" {
t.Errorf("vespa_version=%q, want 8.43.64", v)
}
})
t.Run("a body without abstractComponents is not flagged as vespa", func(t *testing.T) {
body := `{"handlers":[],"processingChains":[],"httpRequestFilters":[]}`
if res := runVectorSearchModule(t, vespa, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without abstractComponents should not match vespa, got %d findings", len(res.Findings))
}
})
t.Run("a body without processingChains is not flagged as vespa", func(t *testing.T) {
body := `{"abstractComponents":[],"httpRequestFilters":[]}`
if res := runVectorSearchModule(t, vespa, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without processingChains should not match vespa, got %d findings", len(res.Findings))
}
})
t.Run("a meilisearch version is flagged", func(t *testing.T) {
res := runVectorSearchModule(t, meilisearch, 200, meiliVersion)
if len(res.Findings) == 0 {
t.Fatal("expected a meilisearch finding")
}
if v := vectorSearchExtract(res, "meilisearch_version"); v != "1.12.0" {
t.Errorf("meilisearch_version=%q, want 1.12.0", v)
}
})
t.Run("a body without commitSha is not flagged as meilisearch", func(t *testing.T) {
body := `{"commitDate":"2026","pkgVersion":"1.12.0"}`
if res := runVectorSearchModule(t, meilisearch, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without commitSha should not match meilisearch, got %d findings", len(res.Findings))
}
})
t.Run("a body without pkgVersion is not flagged as meilisearch", func(t *testing.T) {
body := `{"commitSha":"abc","commitDate":"2026"}`
if res := runVectorSearchModule(t, meilisearch, 200, body); len(res.Findings) > 0 {
t.Errorf("a body without pkgVersion should not match meilisearch, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{marqo, vespa, meilisearch} {
if res := runVectorSearchModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{marqo, vespa, meilisearch} {
if res := runVectorSearchModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
@@ -7,7 +7,7 @@ import (
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
"github.com/vmfunc/sif/internal/modules"
)
func runWebSrvModule(t *testing.T, file string, status int, body string) *modules.Result {
+26 -9
View File
@@ -53,15 +53,17 @@ type YAMLModuleInfo struct {
// HTTPConfig defines HTTP module settings
type HTTPConfig struct {
Method string `yaml:"method"`
Paths []string `yaml:"paths"`
Payloads []string `yaml:"payloads,omitempty"`
Headers map[string]string `yaml:"headers,omitempty"`
Body string `yaml:"body,omitempty"`
Attack string `yaml:"attack,omitempty"` // clusterbomb (default), pitchfork
Threads int `yaml:"threads,omitempty"`
Matchers []Matcher `yaml:"matchers"`
Extractors []Extractor `yaml:"extractors,omitempty"`
Method string `yaml:"method"`
Paths []string `yaml:"paths"`
Wordlist string `yaml:"wordlist,omitempty"`
Payloads []string `yaml:"payloads,omitempty"`
Headers map[string]string `yaml:"headers,omitempty"`
Body string `yaml:"body,omitempty"`
Attack string `yaml:"attack,omitempty"` // clusterbomb (default), pitchfork
Threads int `yaml:"threads,omitempty"`
Matchers []Matcher `yaml:"matchers"`
MatchersCondition string `yaml:"matchers-condition,omitempty"` // and (default), or
Extractors []Extractor `yaml:"extractors,omitempty"`
}
// DNSConfig defines DNS module settings
@@ -104,6 +106,21 @@ func ParseYAMLModule(path string) (*YAMLModule, error) {
if err := validateAttack(ym.HTTP.Attack); err != nil {
return nil, fmt.Errorf("module %q: %w", ym.ID, err)
}
if err := validateMatchersCondition(ym.HTTP.MatchersCondition); err != nil {
return nil, fmt.Errorf("module %q: %w", ym.ID, err)
}
}
var matchers []Matcher
switch {
case ym.HTTP != nil:
matchers = ym.HTTP.Matchers
case ym.DNS != nil:
matchers = ym.DNS.Matchers
case ym.TCP != nil:
matchers = ym.TCP.Matchers
}
if err := validateMatchers(matchers); err != nil {
return nil, fmt.Errorf("module %q: %w", ym.ID, err)
}
return &ym, nil
+1 -1
View File
@@ -16,7 +16,7 @@ import (
"context"
"net/http"
"github.com/dropalldatabases/sif/internal/finding"
"github.com/vmfunc/sif/internal/finding"
)
// discordProvider posts to a discord webhook. discord's incoming-webhook body

Some files were not shown because too many files have changed in this diff Show More