Compare commits

..

99 Commits

Author SHA1 Message Date
vmfunc 8421cb8daa test(scan): fix integration_test SQL arity after calibrate param
#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:40 -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
Tigah f6f9a2bbf7 feat(modules): add spring, appsettings and wp-config exposure modules (#206)
modules/recon/spring-application-config-exposure.yaml flags an exposed Spring
application config, in either properties or yaml form, on a datasource marker
paired with a credential field, then extracts the jdbc url. requiring the
credential keeps a config that holds no secret from being reported.

modules/recon/appsettings-exposure.yaml flags an exposed ASP.NET Core
appsettings.json, the .NET Core counterpart to web.config, on a
ConnectionStrings section paired with an inline password, then extracts the
connection string.

modules/recon/wp-config-backup-exposure.yaml flags an exposed wp-config backup,
the leftover .bak or swap copy that serves raw php, on the DB_PASSWORD constant
paired with another db define, then extracts the database password.

internal/modules/app_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 with no credential, a password outside a
connection strings section, a passwordless connection string, prose that names
DB_PASSWORD, a config shown in an html page, a plain 200 body and a 404, none
of which may match.

verify: go test ./internal/modules, each matcher, marker, guard and extractor
proven to bite (break -> red, restore -> green).
2026-06-22 17:19:56 -07:00
Tigah a9fde8c695 feat(modules): add rails config secret exposure modules (#199)
modules/recon/rails-database-yml-exposure.yaml flags an exposed
config/database.yml on the adapter key paired with a credential key, then
extracts the database name. requiring a credential key keeps a credential free
sqlite config from being reported as a high severity leak.

modules/recon/rails-secrets-yml-exposure.yaml flags an exposed
config/secrets.yml on the secret_key_base key and extracts the secret, the
value an attacker needs to forge rails sessions.

modules/recon/rails-master-key-exposure.yaml flags an exposed master key, the
32 hex value that decrypts the encrypted credentials store. the matcher anchors
the hex to the whole body so a longer digest such as a sha256 served at the
same path does not match, and the same probe covers config/credentials.

internal/modules/rails_secret_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 credential free sqlite config, a longer hex
digest, a hex value away from the body start, an html page naming the markers,
a config without the markers and a 404, none of which may match.

verify: go test ./internal/modules, each matcher, guard, anchor and extractor
proven to bite (break -> red, restore -> green).
2026-06-22 17:19:50 -07:00
Tigah 94d375fc3b feat(modules): add editor sftp deploy config exposure modules (#198)
modules/recon/vscode-sftp-exposure.yaml flags an exposed vscode-sftp config on
its tool keys, remotePath and uploadOnSave, then extracts the deploy host. the
tool keys keep an unrelated json config that merely carries host and credential
fields from matching.

modules/recon/sublime-sftp-exposure.yaml flags an exposed Sublime SFTP config
on its snake case keys, upload_on_save and sync_down_on_open, and extracts the
deploy host.

modules/recon/ftpconfig-exposure.yaml flags an exposed remote-ftp config on its
connection timeout keys, connTimeout and pasvTimeout, and extracts the deploy
host.

each module requires a credential field alongside the tool key and rejects an
html body, so a login page served on the same path is not a leak and an
unrelated json config is not a high severity credential finding.

internal/modules/deploy_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: an html login page carrying the same keys, a plain
json config without the tool keys, a tool config with a host but no credential
field and a 404, none of which may match. it also pins a key auth config with
no password as a leak the credential matcher must still catch.

verify: go test ./internal/modules, each matcher, guard and extractor proven to
bite (break -> red, restore -> green).
2026-06-22 17:19:43 -07:00
Tigah d16391186f feat(modules): add pitchfork attack mode (#193)
The attack field was parsed but never read, so every module ran the
clusterbomb cross-product. Honor it: pitchfork pairs path[i] with
payload[i] and stops at the shorter list, clusterbomb stays the default.
Unknown attack values are rejected at parse time instead of silently
ignored.
2026-06-22 17:19:35 -07:00
Tigah c6741e0f16 feat(modules): add htpasswd, web.config and htaccess exposure modules (#202)
modules/recon/htpasswd-exposure.yaml flags an exposed htpasswd file on a line
that holds a recognised password hash, an apache md5, a bcrypt, a sha crypt or
a {SHA} digest, then extracts the user. matching the hash format keeps a line
that holds a plaintext value from being reported.

modules/recon/webconfig-exposure.yaml flags an exposed asp.net web.config on the
configuration root paired with a dotnet section, then extracts a connection
string, the value that carries the database server and password.

modules/recon/htaccess-exposure.yaml flags an exposed htaccess file on its
apache directives and extracts the AuthUserFile path, which points at the
password file to fetch next.

internal/modules/webserver_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 plaintext htpasswd line, a configuration without
a dotnet section, an html page, a plain 200 body and a 404, none of which may
match.

verify: go test ./internal/modules, each matcher, hash format, section gate,
guard and extractor proven to bite (break -> red, restore -> green).
2026-06-22 17:15:01 -07:00
Tigah 6a8ce9c07b feat(modules): add vault, consul and etcd api exposure modules (#207)
modules/recon/vault-api-exposure.yaml flags an internet reachable HashiCorp
Vault through its unauthenticated seal-status, keyed on the sealed flag paired
with a vault-only status field, then extracts the version for cve matching.

modules/recon/consul-api-exposure.yaml flags a Consul http api that answers
without an acl token, keyed on the Datacenter field paired with an agent or
node marker, then extracts the datacenter.

modules/recon/etcd-api-exposure.yaml flags an exposed etcd through its version
endpoint, keyed on the etcdserver and etcdcluster fields that a generic version
response does not carry, then extracts the server version.

internal/modules/orchestration_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 sealed flag with no vault field, a
datacenter field alone, a version response from another service, a partial etcd
reply, a plain 200 body and a 404, none of which may match.

verify: go test ./internal/modules, each matcher and extractor proven to bite
(break -> red, restore -> green).
2026-06-22 17:13:07 -07:00
Tigah 355df83b59 feat(modules): add private key, git and pypi secret exposure modules (#205)
modules/recon/private-key-exposure.yaml flags an exposed PEM private key on
the BEGIN PRIVATE KEY marker, so a public key or prose that merely names a key
is left alone, then extracts the key type.

modules/recon/git-credentials-exposure.yaml flags an exposed git credential
store on a remote url that carries an inline password, paired with a guard
that drops a url shown inside an html page, then extracts the host the
credential reaches.

modules/recon/pypirc-exposure.yaml flags an exposed pypirc on an index section
paired with a credential field, then extracts the pypi upload token. requiring
the credential keeps a bare index listing from being reported.

internal/modules/secret_file_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 public key, prose that names a key, a remote url
with no password, a pypi section with no credential, a credential shown in an
html page, a plain 200 body and a 404, none of which may match.

verify: go test ./internal/modules, each matcher, marker, guard and extractor
proven to bite (break -> red, restore -> green).
2026-06-22 17:10:45 -07:00
Tigah 570592c317 feat(modules): add airflow, flink and kafka connect exposure modules (#214)
modules/recon/airflow-api-exposure.yaml flags an exposed Apache Airflow webserver
through its unauthenticated health endpoint, keyed on the metadatabase and
scheduler health blocks, then extracts the scheduler heartbeat.

modules/recon/flink-api-exposure.yaml flags an exposed Apache Flink dashboard,
keyed on the flink version paired with the slot total that a generic overview does
not carry, then extracts the flink version.

modules/recon/kafka-connect-api-exposure.yaml flags an exposed Kafka Connect rest
api, keyed on the kafka cluster id paired with the version, then extracts the
version.

internal/modules/data_pipeline_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: each service with one keying field missing, a generic
health response, 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 17:07:25 -07:00
Tigah 761e570d59 feat(modules): add sql dump, sqlite and redis rdb exposure modules (#204)
modules/recon/sql-dump-exposure.yaml flags an exposed SQL dump on its
mysqldump and pg_dump idioms paired against a guard that drops SQL shown
inside an html page, then extracts the dumped table name.

modules/recon/sqlite-database-exposure.yaml flags an exposed SQLite file on
the 16 byte format magic anchored to the start of the body, then extracts a
schema table name. anchoring the magic keeps a page that merely embeds the
header from being reported.

modules/recon/redis-dump-exposure.yaml flags an exposed Redis RDB snapshot on
the RDB magic anchored to the start of the body, then extracts the format
version.

internal/modules/database_file_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 SQL tutorial page, a bare select, prose
that names the sqlite or redis format, a header embedded mid body, a plain 200
body and a 404, none of which may match.

verify: go test ./internal/modules, each matcher, magic anchor, guard and
extractor proven to bite (break -> red, restore -> green).
2026-06-22 17:07:15 -07:00
Tigah 28a01f0f83 feat(modules): add riak, couchbase and druid api exposure modules (#216)
modules/recon/riak-api-exposure.yaml flags an exposed Riak http api reachable
without authentication, keyed on the riak kv version paired with the core version,
then extracts the kv version.

modules/recon/couchbase-api-exposure.yaml flags an exposed Couchbase cluster
management api, keyed on the implementation version paired with the components
version that the bootstrap pool reports, then extracts the implementation version.

modules/recon/druid-api-exposure.yaml flags an exposed Apache Druid process that
runs without authentication, keyed on the druid package namespace paired with the
process memory block, then extracts the druid version.

internal/modules/distributed_db_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: each service with one keying field missing, a generic
version response, 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 17:06:51 -07:00
Tigah 7788550722 feat(modules): add qdrant, weaviate and chroma exposure modules (#222)
modules/recon/qdrant-api-exposure.yaml flags a Qdrant vector database that
serves its collections without an api key, keyed on the result envelope wrapping
a collections array paired with the ok status, then extracts the first
collection name. Qdrant gates /collections behind the api key, so an answer here
means the catalog is readable.

modules/recon/weaviate-api-exposure.yaml flags a Weaviate vector database that
leaks its host address and version over the meta api, keyed on the url-valued
hostname paired with the version field, then extracts the hostname. Weaviate
drops the modules field when none are enabled, so the match leans on the
hostname, which it always renders as a scheme and host.

modules/recon/chroma-api-exposure.yaml flags a reachable Chroma vector database
by its heartbeat api on both the v1 and v2 paths, keyed on the nanosecond
heartbeat field. The heartbeat stays anonymous by design, so this is rated as a
reachability signal rather than an auth bypass.

internal/modules/vector_db_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: each service with one keying field missing, a bare hostname,
a generic version response, 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 16:54:03 -07:00
Tigah 40482a8409 feat(modules): add argo cd exposure module (#219)
modules/recon/argocd-api-exposure.yaml flags an exposed Argo CD api server through
its unauthenticated /api/version endpoint, keyed on the KustomizeVersion and
HelmVersion fields a generic version response does not carry, then extracts the
server version.

internal/modules/argocd_exposure_test.go drives the module through ExecuteHTTPModule
and asserts the leak alongside the near misses: each keying field missing on its own,
a generic version 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 16:49:52 -07:00
Tigah 3ed9ea4b6f feat(modules): add kong, jolokia and nats api exposure modules (#215)
modules/recon/kong-api-exposure.yaml flags an exposed Kong admin api that grants
full control of the gateway, keyed on the available plugins map paired with the
admin listen address that the node reports, then extracts the kong version.

modules/recon/jolokia-api-exposure.yaml flags an exposed Jolokia agent that
bridges http to jmx, keyed on the agent and protocol fields of its version
response, then extracts the agent version.

modules/recon/nats-api-exposure.yaml flags an exposed NATS monitoring endpoint
that leaks the server topology, keyed on the server id paired with the max
payload, then extracts the server version.

internal/modules/management_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: each service with one keying field missing, a generic
version response, 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 16:49:45 -07:00
Tigah 612bb61d00 feat(modules): add solr, spark and hadoop yarn api exposure modules (#213)
modules/recon/solr-api-exposure.yaml flags an exposed Apache Solr admin api,
keyed on the solr spec version paired with the solr home that a generic json
endpoint does not carry, then extracts the solr version.

modules/recon/spark-api-exposure.yaml flags an exposed Apache Spark master whose
cluster state is reachable without authentication, keyed on a spark:// master url
paired with the alive worker count, then extracts the master url.

modules/recon/hadoop-yarn-api-exposure.yaml flags an exposed Hadoop YARN resource
manager, keyed on the cluster info wrapper paired with the resource manager
version, then extracts the hadoop version.

internal/modules/bigdata_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: each service with one keying field missing, a non spark url
behind the worker count, a generic json endpoint, 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 16:49:38 -07:00
Tigah ec53d15a9f docs: require go 1.25 to match go.mod (#161) 2026-06-22 16:49:13 -07:00
dependabot[bot] 064484ff4d chore(deps): bump actions/checkout from 6 to 7 (#143)
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 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/v6...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-06-22 16:49:01 -07:00
Tigah e2a26c19c6 docs: pin golangci-lint version to match ci (#135)
the lint steps said `golangci-lint run` with no version. ci pins
v2.11.4 and .golangci.yml is a v2 config tuned for it, so a contributor
on another version gets spurious findings from unrelated linters.
document the pinned invocation in both the dev guide and the readme so
local runs match ci.

fixes #65
2026-06-22 16:48:55 -07:00
Tigah 95523bc344 chore: remove stale lunchcat domain references (#134)
the lunchcat org and lunchcat.dev are inactive, so point the repo
link at github.com/vmfunc/sif and drop the dead lunchcat.dev support
link.

- Makefile: repo url -> vmfunc/sif; drop the lunchcat.dev line
- README + .all-contributorsrc: contributor links and projectOwner ->
  vmfunc
- config: drop "lunchcat" from the -api flag help text

copyright and attribution to lunchcat are kept for continuity
(license headers, LICENSE, man page) per the issue.

fixes #50
2026-06-22 16:48:49 -07:00
Tigah d0e986736d feat(modules): add influxdb, arangodb and neo4j exposure modules (#221)
modules/recon/influxdb-api-exposure.yaml flags an exposed InfluxDB instance through
its unauthenticated /health endpoint, keyed on the influxdb name paired with the
ready-for-queries health message, then extracts the version.

modules/recon/arangodb-api-exposure.yaml flags an ArangoDB instance reachable
anonymously through its /_api/version endpoint, keyed on the arango server name
paired with the version field, then extracts the version. the 200 gate is what
proves anonymous reach: an auth-enabled instance answers with a 401.

modules/recon/neo4j-api-exposure.yaml flags an exposed Neo4j instance through its
unauthenticated root discovery endpoint, keyed on the neo4j version paired with the
neo4j edition, then extracts the version.

internal/modules/http_database_exposure_test.go drives the three modules through
ExecuteHTTPModule and asserts the leak alongside the near misses a strict review
wants pinned: each service with one keying field missing, a non-arango response, an
arango that requires auth, a generic health 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 16:48:41 -07:00
Tigah 72f59532cf feat(modules): add metabase, zeppelin and jupyter exposure modules (#220)
modules/recon/metabase-api-exposure.yaml flags a Metabase instance that exposes
a live setup token without authentication, keyed on a non-null uuid token paired
with the anonymous tracking setting, then extracts the version tag. A live token
is the pre-auth chain behind CVE-2023-38646; a patched instance reports it as
null and is left alone.

modules/recon/zeppelin-api-exposure.yaml flags an Apache Zeppelin server that
discloses its version and build commit over the anonymous version api, keyed on
the version banner paired with the git commit id, then extracts the version. The
endpoint stays anonymous even on a shiro-secured instance, so this is rated as a
version leak rather than an auth bypass.

modules/recon/jupyter-api-exposure.yaml flags a Jupyter server whose status api
answers without a token, keyed on the activity, connections and kernels fields
it reports, then extracts the running kernel count.

internal/modules/analytics_ui_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: each service with one keying field missing, a
patched metabase that nulls its token, a generic version response, 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 16:48:34 -07:00
Tigah 24b573a368 fix(scan): scope passive ct-feed subdomains to the target (#181)
shared/multi-SAN certificates list unrelated names, so collecting every
crt.sh/certspotter entry reported off-scope hosts as subdomains of the
target. keep only the target or a subdomain of it.
2026-06-22 16:48:23 -07:00
Tigah 4d680074b8 fix(scan): don't count a reflected payload as lfi evidence (#179)
the data:// wrapper payload contains the base64 "PD9waHA" that the
base64-php evidence pattern matches, so a server reflecting the request
path was flagged as a critical inclusion. skip the self-reflected match.
2026-06-22 16:48:17 -07:00
Tigah f0aa1895e9 fix(scan): accept quoted hsts max-age values (#176)
rfc 6797 permits a quoted-string directive value, so a quoted
max-age="31536000" failed strconv.Atoi and got flagged as too short.
2026-06-22 16:48:11 -07:00
Tigah 6d903a4752 fix(scan): drop "query" from generic db admin-panel match (#174)
isAdminPanel's default branch (the generic /db/, /sql/, /mongodb/ paths) matched
the keyword "query", which is a substring of jQuery and querySelector. any site
whose catch-all returns 200/403/401 at one of those paths and runs any javascript
was reported as a database admin panel at high severity.

drop "query". the remaining keywords (database, sql, mysql, postgresql, mongodb)
only match db-topical pages, so real generic interfaces are still detected via a
sibling keyword.
2026-06-22 16:48:05 -07:00
Tigah 9c241cf185 fix(scan): key joomla detection on the generator tag (#173)
detectJoomla matched any body containing the lowercase word "joomla", so pages
that merely mention it (joomla hosting marketing like hostinger.com) were
misreported as Joomla. the canonical Joomla! generator tag is capitalised, so
the lowercase match also missed sites whose only tell is that tag.

match the Joomla! generator meta tag and the joomla asset paths
(/media/vendor/joomla, /media/system/js/core.js) instead. keying on the meta
attribute rather than the plain tagline also rejects prose that quotes it.
2026-06-22 16:47:58 -07:00
Tigah 26ccbea888 fix(scan): detect open redirects through scheme-malformed payloads (#172)
pointsAtSentinel parsed the reflected Location with url.Parse, which only
finds the authority when the scheme is followed by "//". the redirect probe
already sends "https:/host" and "https:host" payloads, but a server that
reflected them landed the host in Opaque or the path, so the off-site
redirect went unreported. normalise the scheme slash count to "//" before
parsing so those forms resolve to the host a browser would navigate to.
scoped to http(s), so an opaque "mailto:host" is left untouched.
2026-06-22 16:47:52 -07:00
Tigah bca4831df1 fix(frameworks): stop ghost detector matching the generic ghost-button (#163)
the ghost detector keyed on the bare "ghost-" body substring, which fires on
any page using the common "ghost-button" or skeleton-loader CSS class (e.g. a
WordPress theme), scoring 0.65 and reporting it as Ghost CMS. key on the
canonical `<meta name="generator" content="Ghost` tag instead, matching the
existing astro detector. the /ghost/api/ path and Ghost header markers are
unchanged.

a ghost site that emits neither the generator meta nor a /ghost/api/
reference is no longer detected; the Ghost response header alone scores
below the detection floor.
2026-06-22 16:47:43 -07:00
Tigah 291846dde5 fix(frameworks): make Phoenix and AdonisJS detection specific (#153)
the phoenix and adonis detectors matched bare substrings ("phx-", "phoenix",
"adonis") that fire on unrelated pages: a "phx-" css class on a phoenix,
arizona site, or any markup containing the word "adonis". replace them with
markers the frameworks actually emit. phoenix keys on the liveview container
attributes data-phx-main, data-phx-session and data-phx-static; adonis on its
default adonis-session cookie.

this narrows detection: plain (non-liveview) phoenix and session-less adonis
apis are no longer matched. the markers we now key on (liveview's container
attributes, adonis's default session cookie) are ones ordinary prose cannot
forge. each detector gains a true-positive test and a false-positive tripwire.
2026-06-22 16:47:34 -07:00
Tigah 21c1d1c8a5 feat(modules): implement size matcher and kv extractor (#140)
the engine declared size matchers and kv extractors but the executor
dropped them (size fell through to the default case, kv was never read).
wire both: size matches the response body length in bytes, kv records
every response header as a key-value pair namespaced by the extractor
name.

this unblocks the headers.go conversion in #52, which needs a full header
dump the known-set regex extractors cannot reproduce; the headers.yaml
module and the headers.go removal are a separate follow-up. the extractor
is named kv to match docs/modules.md (the struct comment said kval). the
declared json extractor stays deferred since it needs a json-path
dependency and a path-syntax decision.

refs #52
2026-06-22 16:47:25 -07:00
Tigah 68075b6901 feat(modules): add ghost, magento and typo3 detection modules (#138)
cover three platforms the built-in cms scanner misses (it only handles
wordpress, drupal and joomla). markers are structural: generator meta,
framework-specific js init and asset paths, not bare brand strings, so a
page that merely mentions the cms does not match. ghost also extracts its
version from the generator meta.
2026-06-22 16:47:18 -07:00
Tigah 1bbcefa685 feat(modules): add joomla cms detection module (#136)
the yaml module set had wordpress and drupal but not joomla, while the legacy
internal/scan/cms.go detects all three. the new module fills that gap so
--all-modules and -mt cms cover joomla too.

matches the generator meta (version-independent, joomla 1.5 through 5) plus
structural markers /media/system/js/core.js, /media/jui/ and
joomla-script-options, on the root and /administrator/. verified against live
and archived joomla sites, with no false positives on pages that only mention
joomla. version comes from a versioned generator or a leaked
X-Content-Encoded-By header.

additive: cms.go is untouched, the -cms scan is unchanged. whether converted
scanners should also run in the default flow is the open question on #52.

refs #52
2026-06-22 16:47:11 -07:00
celeste aa22e6965a Merge pull request #131 from vmfunc/dependabot/go_modules/github.com/twmb/murmur3-1.1.8
chore(deps): bump github.com/twmb/murmur3 from 1.1.6 to 1.1.8
2026-06-15 18:01:12 -07:00
celeste 33c1c421c3 Merge pull request #127 from vmfunc/dependabot/go_modules/github.com/gocolly/colly/v2-2.3.0
chore(deps): bump github.com/gocolly/colly/v2 from 2.1.0 to 2.3.0
2026-06-15 18:01:09 -07:00
dependabot[bot] 27c76e350c chore(deps): bump github.com/gocolly/colly/v2 from 2.1.0 to 2.3.0
Bumps [github.com/gocolly/colly/v2](https://github.com/gocolly/colly) from 2.1.0 to 2.3.0.
- [Release notes](https://github.com/gocolly/colly/releases)
- [Changelog](https://github.com/gocolly/colly/blob/master/CHANGELOG.md)
- [Commits](https://github.com/gocolly/colly/compare/v2.1.0...v2.3.0)

---
updated-dependencies:
- dependency-name: github.com/gocolly/colly/v2
  dependency-version: 2.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-16 00:55:14 +00:00
dependabot[bot] 2e89a94a25 chore(deps): bump github.com/twmb/murmur3 from 1.1.6 to 1.1.8
Bumps [github.com/twmb/murmur3](https://github.com/twmb/murmur3) from 1.1.6 to 1.1.8.
- [Commits](https://github.com/twmb/murmur3/compare/v1.1.6...v1.1.8)

---
updated-dependencies:
- dependency-name: github.com/twmb/murmur3
  dependency-version: 1.1.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-16 00:54:40 +00:00
celeste 6f88625997 Merge pull request #129 from vmfunc/dependabot/go_modules/golang.org/x/net-0.56.0
chore(deps): bump golang.org/x/net from 0.53.0 to 0.56.0
2026-06-15 17:53:09 -07:00
celeste 4cc48597a5 Merge pull request #130 from vmfunc/dependabot/github_actions/codecov/codecov-action-7
chore(deps): bump codecov/codecov-action from 6 to 7
2026-06-15 17:52:54 -07:00
celeste 82a36886fa Merge pull request #128 from vmfunc/dependabot/go_modules/github.com/charmbracelet/glamour-1.0.0
chore(deps): bump github.com/charmbracelet/glamour from 0.10.0 to 1.0.0
2026-06-15 17:52:38 -07:00
dependabot[bot] 33e8668456 chore(deps): bump codecov/codecov-action from 6 to 7
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 6 to 7.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v6...v7)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-12 12:43:49 +00:00
dependabot[bot] fc3f11fb61 chore(deps): bump golang.org/x/net from 0.53.0 to 0.56.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.53.0 to 0.56.0.
- [Commits](https://github.com/golang/net/compare/v0.53.0...v0.56.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-12 12:43:46 +00:00
dependabot[bot] 28acb16a46 chore(deps): bump github.com/charmbracelet/glamour from 0.10.0 to 1.0.0
Bumps [github.com/charmbracelet/glamour](https://github.com/charmbracelet/glamour) from 0.10.0 to 1.0.0.
- [Release notes](https://github.com/charmbracelet/glamour/releases)
- [Commits](https://github.com/charmbracelet/glamour/compare/v0.10.0...v1.0.0)

---
updated-dependencies:
- dependency-name: github.com/charmbracelet/glamour
  dependency-version: 1.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-12 12:43:41 +00:00
218 changed files with 13366 additions and 506 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"projectName": "sif",
"projectOwner": "lunchcat",
"projectOwner": "vmfunc",
"files": [
"README.md"
],
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the latest code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: automatic rebase
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
name: check for large files
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: check for large files
run: |
large_files=$(find . -path ./.git -prune -o -type f -size +5M -print)
+44
View File
@@ -0,0 +1,44 @@
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:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
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@v4
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
@@ -22,7 +22,7 @@ jobs:
security-events: write
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: set up go
uses: actions/setup-go@v5
with:
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: checkout repository
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: dependency review
uses: actions/dependency-review-action@v5
continue-on-error: ${{ github.event_name == 'push' }}
+3 -3
View File
@@ -17,7 +17,7 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: set up go
uses: actions/setup-go@v6
with:
@@ -33,7 +33,7 @@ jobs:
matrix:
go-version: ["1.25"]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: set up go
uses: actions/setup-go@v6
with:
@@ -43,7 +43,7 @@ jobs:
- name: run tests with coverage
run: go test -race -coverprofile=coverage.out -covermode=atomic ./...
- name: upload coverage to codecov
uses: codecov/codecov-action@v6
uses: codecov/codecov-action@v7
with:
files: ./coverage.out
fail_ci_if_error: false
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
govulncheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: set up go
uses: actions/setup-go@v5
with:
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
check-headers:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: check license headers
run: |
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
name: profanity check
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Profanity check step
uses: tailaiw/mind-your-language-action@v1.0.3
env:
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
name: runner / markdownlint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: markdownlint
uses: reviewdog/action-markdownlint@v0.26.2
with:
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
name: runner / misspell
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: misspell
uses: reviewdog/action-misspell@v1.27.0
with:
+2 -3
View File
@@ -1,7 +1,7 @@
name: pr bot
on:
pull_request:
pull_request_target:
types: [opened, synchronize, reopened, edited]
permissions:
@@ -9,7 +9,7 @@ permissions:
pull-requests: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
@@ -23,7 +23,6 @@ jobs:
size:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: label pr size
uses: actions/github-script@v9
with:
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: set up go
uses: actions/setup-go@v5
with:
+1 -1
View File
@@ -18,6 +18,6 @@ jobs:
update-report-card:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: update go report card
uses: creekorful/goreportcard-action@v1.0
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: set up go
uses: actions/setup-go@v5
with:
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
security-events: write
id-token: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: run scorecard
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
name: runner / shellcheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: shellcheck
uses: reviewdog/action-shellcheck@v1.32.0
with:
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
name: runner / yamllint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: yamllint
uses: reviewdog/action-yamllint@v1.21.0
with:
+1 -1
View File
@@ -33,7 +33,7 @@ When opening an issue, please use the search tool and make sure that the issue h
### Development
To develop sif, you'll need version 1.23 or later of the Go toolchain. After making your changes, run the program using `go run ./cmd/sif` to make sure it compiles and runs properly.
To develop sif, you'll need version 1.25 or later of the Go toolchain. After making your changes, run the program using `go run ./cmd/sif` to make sure it compiles and runs properly.
_Nix users:_ the repository provides a flake that can be used to develop and run sif. Use `nix run`, `nix develop`, `nix build`, etc. Make sure to run `gomod2nix` if `go.mod` is changed.
+1 -2
View File
@@ -38,8 +38,7 @@ define SUPPORT_MESSAGE
│ 🌟 Enjoying sif? Please consider:
│ • Starring our repo: https://github.com/lunchcat/sif
│ • Supporting the devs: https://lunchcat.dev
│ • Starring our repo: https://github.com/vmfunc/sif
Your support helps us continue improving sif!
+6 -6
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) |
@@ -365,7 +365,7 @@ contributions welcome. see [contributing.md](CONTRIBUTING.md) for guidelines.
gofmt -w .
# lint
golangci-lint run
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4 run
# test
go test ./...
@@ -385,13 +385,13 @@ join our discord for support, feature discussions, and pentesting tips:
<table>
<tbody>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://vmfunc.re"><img src="https://avatars.githubusercontent.com/u/59031302?v=4?s=100" width="100px;" alt="vmfunc"/><br /><sub><b>vmfunc</b></sub></a><br /><a href="#maintenance-vmfunc" title="Maintenance">🚧</a> <a href="#mentoring-vmfunc" title="Mentoring">🧑‍🏫</a> <a href="#projectManagement-vmfunc" title="Project Management">📆</a> <a href="#security-vmfunc" title="Security">🛡️</a> <a href="https://github.com/lunchcat/sif/commits?author=vmfunc" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://vmfunc.re"><img src="https://avatars.githubusercontent.com/u/59031302?v=4?s=100" width="100px;" alt="vmfunc"/><br /><sub><b>vmfunc</b></sub></a><br /><a href="#maintenance-vmfunc" title="Maintenance">🚧</a> <a href="#mentoring-vmfunc" title="Mentoring">🧑‍🏫</a> <a href="#projectManagement-vmfunc" title="Project Management">📆</a> <a href="#security-vmfunc" title="Security">🛡️</a> <a href="https://github.com/vmfunc/sif/commits?author=vmfunc" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://projectdiscovery.io"><img src="https://avatars.githubusercontent.com/u/50994705?v=4?s=100" width="100px;" alt="ProjectDiscovery"/><br /><sub><b>ProjectDiscovery</b></sub></a><br /><a href="#platform-projectdiscovery" title="Packaging/porting to new platform">📦</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/macdoos"><img src="https://avatars.githubusercontent.com/u/127897805?v=4?s=100" width="100px;" alt="macdoos"/><br /><sub><b>macdoos</b></sub></a><br /><a href="https://github.com/lunchcat/sif/commits?author=macdoos" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/macdoos"><img src="https://avatars.githubusercontent.com/u/127897805?v=4?s=100" width="100px;" alt="macdoos"/><br /><sub><b>macdoos</b></sub></a><br /><a href="https://github.com/vmfunc/sif/commits?author=macdoos" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://epitech.eu"><img src="https://avatars.githubusercontent.com/u/75166283?v=4?s=100" width="100px;" alt="Matthieu Witrowiez"/><br /><sub><b>Matthieu Witrowiez</b></sub></a><br /><a href="#ideas-D3adPlays" title="Ideas, Planning, & Feedback">🤔</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/tessa-u-k"><img src="https://avatars.githubusercontent.com/u/109355732?v=4?s=100" width="100px;" alt="tessa "/><br /><sub><b>tessa </b></sub></a><br /><a href="#infra-tessa-u-k" title="Infrastructure (Hosting, Build-Tools, etc)">🚇</a> <a href="#question-tessa-u-k" title="Answering Questions">💬</a> <a href="#userTesting-tessa-u-k" title="User Testing">📓</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/xyzeva"><img src="https://avatars.githubusercontent.com/u/133499694?v=4?s=100" width="100px;" alt="Eva"/><br /><sub><b>Eva</b></sub></a><br /><a href="#blog-xyzeva" title="Blogposts">📝</a> <a href="#content-xyzeva" title="Content">🖋</a> <a href="#research-xyzeva" title="Research">🔬</a> <a href="#security-xyzeva" title="Security">🛡️</a> <a href="https://github.com/lunchcat/sif/commits?author=xyzeva" title="Tests">⚠️</a> <a href="https://github.com/lunchcat/sif/commits?author=xyzeva" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/vxfemboy"><img src="https://avatars.githubusercontent.com/u/79362520?v=4?s=100" width="100px;" alt="Zoa Hickenlooper"/><br /><sub><b>Zoa Hickenlooper</b></sub></a><br /><a href="https://github.com/lunchcat/sif/commits?author=vxfemboy" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/xyzeva"><img src="https://avatars.githubusercontent.com/u/133499694?v=4?s=100" width="100px;" alt="Eva"/><br /><sub><b>Eva</b></sub></a><br /><a href="#blog-xyzeva" title="Blogposts">📝</a> <a href="#content-xyzeva" title="Content">🖋</a> <a href="#research-xyzeva" title="Research">🔬</a> <a href="#security-xyzeva" title="Security">🛡️</a> <a href="https://github.com/vmfunc/sif/commits?author=xyzeva" title="Tests">⚠️</a> <a href="https://github.com/vmfunc/sif/commits?author=xyzeva" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/vxfemboy"><img src="https://avatars.githubusercontent.com/u/79362520?v=4?s=100" width="100px;" alt="Zoa Hickenlooper"/><br /><sub><b>Zoa Hickenlooper</b></sub></a><br /><a href="https://github.com/vmfunc/sif/commits?author=vxfemboy" title="Code">💻</a></td>
</tr>
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/0xatrilla"><img src="https://avatars.githubusercontent.com/u/107285362?v=4?s=100" width="100px;" alt="acxtrilla"/><br /><sub><b>acxtrilla</b></sub></a><br /><a href="#platform-0xatrilla" title="Packaging/porting to new platform">📦</a></td>
+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
+5 -2
View File
@@ -60,8 +60,11 @@ gofmt -w .
### lint
ci pins golangci-lint v2.11.4 (`.github/workflows/go.yml`); other versions
report spurious issues against the v2 config, so pin it locally too:
```bash
golangci-lint run
go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.11.4 run
```
### test
@@ -164,7 +167,7 @@ go test -tags=integration ./internal/scan/...
1. fork the repository
2. create a feature branch
3. make changes
4. run `gofmt -w .` and `golangci-lint run`
4. run `gofmt -w .` and `golangci-lint run` (pinned version, see [lint](#lint))
5. submit pr
### commit messages
+94 -1
View File
@@ -115,6 +115,29 @@ http:
each payload creates a separate request for each path.
#### attack
how paths and payloads combine into requests.
```yaml
http:
attack: pitchfork
```
- `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.
@@ -199,6 +222,42 @@ matchers:
condition: or
```
### size matcher
match the response body length in bytes (measured after the 5 MB response cap, so larger sizes never match).
```yaml
matchers:
- type: size
size:
- 0
- 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.
@@ -218,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.
@@ -238,7 +316,7 @@ extractors:
### kv extractor
extract key-value pairs.
record every response header as a key-value pair, namespaced by `name`.
```yaml
extractors:
@@ -247,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.
+65 -54
View File
@@ -4,19 +4,20 @@ go 1.25.7
require (
github.com/antchfx/htmlquery v1.3.6
github.com/charmbracelet/glamour v0.10.0
github.com/charmbracelet/glamour v1.0.0
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/charmbracelet/log v1.0.0
github.com/gocolly/colly/v2 v2.1.0
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.9.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/twmb/murmur3 v1.1.6
golang.org/x/net v0.53.0
golang.org/x/time v0.14.0
github.com/tidwall/gjson v1.18.0
github.com/twmb/murmur3 v1.1.8
golang.org/x/net v0.56.0
golang.org/x/time v0.15.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -33,16 +34,18 @@ 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/FalconOpsLLC/goexec v0.3.0 // indirect
github.com/Masterminds/semver/v3 v3.4.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/goimpacket v0.0.0-20260422121140-7085336a0415 // indirect
github.com/Mzack9999/goja v0.0.0-20250507184235-e46100e9c697 // indirect
github.com/Mzack9999/goja_nodejs v0.0.0-20250507184139-66bcbf65c883 // 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
@@ -55,7 +58,7 @@ require (
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/andygrunwald/go-jira v1.16.1 // indirect
github.com/antchfx/xmlquery v1.4.4 // indirect
github.com/antchfx/xmlquery v1.5.0 // indirect
github.com/antchfx/xpath v1.3.6 // indirect
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect
@@ -80,7 +83,7 @@ require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/bits-and-blooms/bitset v1.22.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
@@ -97,7 +100,7 @@ require (
github.com/cespare/xxhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.3.2 // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/ansi v0.10.2 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250908092851-c2208eb08494 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
@@ -117,8 +120,7 @@ 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/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 +133,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 +143,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 +165,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
@@ -191,6 +192,7 @@ 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
@@ -199,6 +201,7 @@ require (
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,7 +214,7 @@ 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
@@ -229,8 +232,8 @@ 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-runewidth v0.0.16 // 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
github.com/mholt/acmez/v3 v3.1.3 // indirect
@@ -243,16 +246,21 @@ 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
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/nlnwa/whatwg-url v0.6.2 // indirect
github.com/nwaples/rardecode/v2 v2.2.2 // indirect
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
github.com/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
@@ -268,40 +276,42 @@ 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.39 // indirect
github.com/projectdiscovery/clistats v0.1.4 // indirect
github.com/projectdiscovery/dsl v0.8.19 // indirect
github.com/projectdiscovery/fastdialer v0.5.10 // 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/gologger v1.1.70 // 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-20260504230327-80320480bb6e // 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.40 // 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.14 // 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.84 // 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
@@ -310,17 +320,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
@@ -363,35 +373,36 @@ 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
go4.org v0.0.0-20230225012048-214862532bf5 // indirect
goftp.io/server/v2 v2.0.1 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/crypto v0.53.0 // 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.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/term v0.42.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/tools v0.44.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.36.6 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/term v0.44.0 // indirect
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.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
)
+150 -142
View File
@@ -73,8 +73,8 @@ github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03
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/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/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.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
@@ -86,6 +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/goimpacket v0.0.0-20260422121140-7085336a0415 h1:lpSZPcbowbxvKFaFvE1reLTCStezWXcRVk0zzBtUatg=
github.com/Mzack9999/goimpacket v0.0.0-20260422121140-7085336a0415/go.mod h1:Wvb2f1Aq6NVL4Fza/dPNxv6/canpeizpgmiTCBGMD50=
github.com/Mzack9999/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=
@@ -101,6 +103,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 +129,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=
@@ -149,13 +153,13 @@ github.com/antchfx/htmlquery v1.3.6 h1:RNHHL7YehO5XdO8IM8CynwLKONwRHWkrghbYhQIk9
github.com/antchfx/htmlquery v1.3.6/go.mod h1:kcVUqancxPygm26X2rceEcagZFFVkLEE7xgLkGSDl/4=
github.com/antchfx/xmlquery v1.2.4/go.mod h1:KQQuESaxSlqugE2ZBcM/qn+ebIpt+d+4Xx7YcSGAIrM=
github.com/antchfx/xmlquery v1.3.15/go.mod h1:zMDv5tIGjOxY/JCNNinnle7V/EwthZ5IT8eeCGJKRWA=
github.com/antchfx/xmlquery v1.4.4 h1:mxMEkdYP3pjKSftxss4nUHfjBhnMk4imGoR96FRY2dg=
github.com/antchfx/xmlquery v1.4.4/go.mod h1:AEPEEPYE9GnA2mj5Ur2L5Q5/2PycJ0N9Fusrx9b12fc=
github.com/antchfx/xmlquery v1.5.0 h1:uAi+mO40ZWfyU6mlUBxRVvL6uBNZ6LMU4M3+mQIBV4c=
github.com/antchfx/xmlquery v1.5.0/go.mod h1:lJfWRXzYMK1ss32zm1GQV3gMIW/HFey3xDZmkP1SuNc=
github.com/antchfx/xpath v1.1.6/go.mod h1:Yee4kTMuNiPYJ7nSNorELQMr1J33uOpXDMByNYhvtNk=
github.com/antchfx/xpath v1.1.8/go.mod h1:Yee4kTMuNiPYJ7nSNorELQMr1J33uOpXDMByNYhvtNk=
github.com/antchfx/xpath v1.2.3/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
github.com/antchfx/xpath v1.2.4/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
github.com/antchfx/xpath v1.3.3/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
github.com/antchfx/xpath v1.3.5/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
github.com/antchfx/xpath v1.3.6 h1:s0y+ElRRtTQdfHP609qFu0+c6bglDv20pqOViQjjdPI=
github.com/antchfx/xpath v1.3.6/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
@@ -212,8 +216,9 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bits-and-blooms/bitset v1.8.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bits-and-blooms/bitset v1.22.0 h1:Tquv9S8+SGaS3EhyA+up3FXzmkhxPGjQQCkcs2uw7w4=
github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
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=
@@ -245,8 +250,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=
@@ -259,14 +262,14 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI=
github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI=
github.com/charmbracelet/glamour v0.10.0 h1:MtZvfwsYCx8jEPFJm3rIBFIMZUfUJ765oX8V6kXldcY=
github.com/charmbracelet/glamour v0.10.0/go.mod h1:f+uf+I/ChNmqo087elLnVdCiVgjSKWuXa/l6NU2ndYk=
github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
github.com/charmbracelet/log v1.0.0 h1:HVVVMmfOorfj3BA9i8X8UL69Hoz9lI0PYwXfJvOdRc4=
github.com/charmbracelet/log v1.0.0/go.mod h1:uYgY3SmLpwJWxmlrPwXvzVYujxis1vAKRV/0VQB7yWA=
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/ansi v0.10.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvAPA+Pzw=
github.com/charmbracelet/x/ansi v0.10.2/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8=
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30=
@@ -305,8 +308,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=
@@ -330,10 +332,8 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
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=
@@ -373,10 +373,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=
@@ -405,8 +405,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,12 +464,12 @@ github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakr
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/gocolly/colly v1.2.0/go.mod h1:Hof5T3ZswNVsOHYmba1u03W65HDWgpV5HifSuueE0EA=
github.com/gocolly/colly/v2 v2.1.0 h1:k0DuZkDoCsx51bKpRJNEmcxcp+W5N8ziuwGaSDuFoGs=
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=
@@ -569,13 +569,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=
@@ -608,6 +610,8 @@ 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=
@@ -665,13 +669,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=
@@ -722,13 +725,16 @@ 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.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-runewidth v0.0.17 h1:78v8ZlW0bP43XfmAfPsdXcoNCelfMHsDmd/pkENfrjQ=
github.com/mattn/go-runewidth v0.0.17/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
@@ -758,14 +764,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=
@@ -781,8 +783,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=
@@ -791,6 +791,8 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nlnwa/whatwg-url v0.6.2 h1:jU61lU2ig4LANydbEJmA2nPrtCGiKdtgT0rmMd2VZ/Q=
github.com/nlnwa/whatwg-url v0.6.2/go.mod h1:x0FPXJzzOEieQtsBT/AKvbiBbQ46YlL6Xa7m02M1ECk=
github.com/nwaples/rardecode/v2 v2.2.2 h1:/5oL8dzYivRM/tqX9VcTSWfbpwcbwKG1QtSJr3b3KcU=
github.com/nwaples/rardecode/v2 v2.2.2/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY=
@@ -799,6 +801,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=
@@ -847,14 +855,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.39 h1:gNE2dyaK+ZqEdEWyVUFlq8PvromEhSxamhsmFZR2Voc=
github.com/projectdiscovery/cdncheck v1.2.39/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.19 h1:qA5OFJMfghSCjKqS4AdsEtnur/SoriXDw3geE7+mReU=
github.com/projectdiscovery/dsl v0.8.19/go.mod h1:Twk93q7fxQ43v/8nR+0TJV8/eFTdBAC5tIXe3qzua9Y=
github.com/projectdiscovery/fastdialer v0.5.10 h1:dB9MSu4cSo22qne4pHiK9iYSxfOgpwlKB6zfOHvz3RI=
github.com/projectdiscovery/fastdialer v0.5.10/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=
@@ -865,14 +873,16 @@ 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/gologger v1.1.70 h1:A1ZAsUJfRUXO6qqwTwyTWXLVlBrVu/Gpi1zzL1hg5LY=
github.com/projectdiscovery/gologger v1.1.70/go.mod h1:kpLKNafZWRN9P7WpJYtIOY/XvY/v41GDdU9NzICdKmo=
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-20260504230327-80320480bb6e h1:o+ulEIaC2+9V2Ezr6mI5xEhKWsf0V/+FUQIS723Aj6U=
github.com/projectdiscovery/govaluate v0.0.0-20260504230327-80320480bb6e/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=
@@ -885,34 +895,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.40 h1:kYin4u1/dgb0nuz5fE1bz4Q0Zh66mOKIdkSHJ00bjGY=
github.com/projectdiscovery/networkpolicy v0.1.40/go.mod h1:9ULLaMbdv9UnT0C5rmuK4nIwYs0o776xMnkPUb8TtaE=
github.com/projectdiscovery/nuclei/v3 v3.9.0 h1:kNHrWZH7mM8Ntf5qacYgHNCEGzmPtywcU0feKm2YnhU=
github.com/projectdiscovery/nuclei/v3 v3.9.0/go.mod h1:6gkhTSiX+7ay5NTHM62+WUUCg7toWwHaWady+3tblbY=
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.14 h1:vCBLwK8iIuua3i97jEac5/+TWkYTLhTkGblHu9ETPVc=
github.com/projectdiscovery/retryablehttp-go v1.3.14/go.mod h1:reVhQ+DzMAPYEQHdawCQ6h0tX3CpFyMH4XjcAyq9+U8=
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.84 h1:19c+ea8KZCnZIuZPztafFKK2uczDXxcZ/z6/l6DEEEs=
github.com/projectdiscovery/wappalyzergo v0.2.84/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=
@@ -956,6 +966,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=
@@ -980,8 +992,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=
@@ -996,8 +1009,10 @@ 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=
@@ -1061,8 +1076,9 @@ github.com/trivago/tgo v1.0.7 h1:uaWH/XIy9aWYWpjm2CU3RpcqZXmX2ysQ9/Go+d9gyrM=
github.com/trivago/tgo v1.0.7/go.mod h1:w4dpD+3tzNIIiIfkWWa85w5/B77tlvdZckQ+6PkFnhc=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/twmb/murmur3 v1.1.6 h1:mqrRot1BRxm+Yct+vavLMou2/iJt0tNVTTC0QoIjaZg=
github.com/twmb/murmur3 v1.1.6/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ=
github.com/twmb/murmur3 v1.1.8 h1:8Yt9taO/WN3l08xErzjeschgZU2QSrwm1kclYq+0aRg=
github.com/twmb/murmur3 v1.1.8/go.mod h1:Qq/R7NUyOfr65zD+6Q5IHKsJLwP7exErjN6lyyq3OSQ=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
@@ -1133,7 +1149,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=
@@ -1163,8 +1178,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=
@@ -1176,24 +1191,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=
@@ -1234,8 +1243,9 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -1246,8 +1256,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=
@@ -1273,8 +1283,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -1310,7 +1320,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=
@@ -1331,8 +1340,9 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.27.0/go.mod h1:dDi0PyhWNoiUOrAS8uXv/vnScO4wnHQO4mj9fn/RytE=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -1352,7 +1362,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=
@@ -1361,8 +1370,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -1417,6 +1426,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=
@@ -1429,8 +1439,9 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
@@ -1446,8 +1457,9 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -1466,14 +1478,14 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
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=
@@ -1513,17 +1525,15 @@ 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=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -1550,8 +1560,9 @@ google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
@@ -1581,11 +1592,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=
@@ -1598,8 +1604,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=
@@ -1616,8 +1620,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.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
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=
@@ -1658,6 +1662,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=
+34 -9
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
@@ -110,9 +111,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 +131,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 +170,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",
@@ -194,7 +195,7 @@ func Parse() *Settings {
)
flagSet.CreateGroup("api", "API",
flagSet.BoolVar(&settings.ApiMode, "api", false, "Enable API mode. Only useful for internal lunchcat usage"),
flagSet.BoolVar(&settings.ApiMode, "api", false, "Enable API mode. Only useful for internal usage"),
)
flagSet.CreateGroup("modules", "Modules",
@@ -204,8 +205,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
+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)
}
}
@@ -0,0 +1,164 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runAnalyticsModule(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 analyticsExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestAnalyticsUIExposureModules(t *testing.T) {
const metabase = "../../modules/recon/metabase-api-exposure.yaml"
const zeppelin = "../../modules/recon/zeppelin-api-exposure.yaml"
const jupyter = "../../modules/recon/jupyter-api-exposure.yaml"
metabaseProps := `{"engines":{"postgres":{"driver-name":"PostgreSQL"}},` +
`"setup-token":"245f5f7c-8f0b-4c20-9a1e-6b2d7e1f0a33","anon-tracking-enabled":true,` +
`"available-locales":[["en","English"]],"password-complexity":{"total":6},` +
`"version":{"date":"2023-10-01","tag":"v0.47.2","branch":"release-x.47.x","hash":"abc1234"}}`
zeppelinVersion := `{"status":"OK","message":"Zeppelin version",` +
`"body":{"version":"0.10.1","git-commit-id":"a1b2c3d4e5","git-timestamp":"2022-01-15 10:00:00"}}`
jupyterStatus := `{"started":"2024-01-01T00:00:00.000000Z",` +
`"last_activity":"2024-01-01T01:23:45.000000Z","connections":2,"kernels":3}`
t.Run("an exposed metabase properties api is flagged and versioned", func(t *testing.T) {
res := runAnalyticsModule(t, metabase, 200, metabaseProps)
if len(res.Findings) == 0 {
t.Fatal("expected a metabase finding")
}
if v := analyticsExtract(res, "metabase_version"); v != "v0.47.2" {
t.Errorf("metabase_version=%q, want v0.47.2", v)
}
})
t.Run("an exposed zeppelin server is flagged and versioned", func(t *testing.T) {
res := runAnalyticsModule(t, zeppelin, 200, zeppelinVersion)
if len(res.Findings) == 0 {
t.Fatal("expected a zeppelin finding")
}
if v := analyticsExtract(res, "zeppelin_version"); v != "0.10.1" {
t.Errorf("zeppelin_version=%q, want 0.10.1", v)
}
})
t.Run("an exposed jupyter status api is flagged with the kernel count", func(t *testing.T) {
res := runAnalyticsModule(t, jupyter, 200, jupyterStatus)
if len(res.Findings) == 0 {
t.Fatal("expected a jupyter finding")
}
if v := analyticsExtract(res, "jupyter_active_kernels"); v != "3" {
t.Errorf("jupyter_active_kernels=%q, want 3", v)
}
})
t.Run("a live metabase token without the tracking setting is not flagged", func(t *testing.T) {
body := `{"setup-token":"245f5f7c-8f0b-4c20-9a1e-6b2d7e1f0a33","name":"app"}`
if res := runAnalyticsModule(t, metabase, 200, body); len(res.Findings) > 0 {
t.Errorf("a setup token alone should not match metabase, got %d findings", len(res.Findings))
}
})
t.Run("a metabase tracking setting without a setup token is not flagged", func(t *testing.T) {
body := `{"anon-tracking-enabled":true,"name":"app"}`
if res := runAnalyticsModule(t, metabase, 200, body); len(res.Findings) > 0 {
t.Errorf("a tracking setting alone should not match metabase, got %d findings", len(res.Findings))
}
})
t.Run("a patched metabase with a null setup token is not flagged", func(t *testing.T) {
body := `{"setup-token":null,"anon-tracking-enabled":true,` +
`"version":{"tag":"v0.47.2"}}`
if res := runAnalyticsModule(t, metabase, 200, body); len(res.Findings) > 0 {
t.Errorf("a null setup token should not match metabase, got %d findings", len(res.Findings))
}
})
t.Run("a zeppelin banner without a git commit id is not flagged", func(t *testing.T) {
body := `{"status":"OK","message":"Zeppelin version","body":{}}`
if res := runAnalyticsModule(t, zeppelin, 200, body); len(res.Findings) > 0 {
t.Errorf("a banner alone should not match zeppelin, got %d findings", len(res.Findings))
}
})
t.Run("a git commit id without the zeppelin banner is not flagged", func(t *testing.T) {
body := `{"git-commit-id":"a1b2c3d","name":"app"}`
if res := runAnalyticsModule(t, zeppelin, 200, body); len(res.Findings) > 0 {
t.Errorf("a commit id alone should not match zeppelin, got %d findings", len(res.Findings))
}
})
t.Run("a jupyter status without a kernels field is not flagged", func(t *testing.T) {
body := `{"started":"2024-01-01T00:00:00Z","last_activity":"2024-01-01T01:00:00Z","connections":2}`
if res := runAnalyticsModule(t, jupyter, 200, body); len(res.Findings) > 0 {
t.Errorf("a status without kernels should not match jupyter, got %d findings", len(res.Findings))
}
})
t.Run("a jupyter status without a connections field is not flagged", func(t *testing.T) {
body := `{"started":"2024-01-01T00:00:00Z","last_activity":"2024-01-01T01:00:00Z","kernels":3}`
if res := runAnalyticsModule(t, jupyter, 200, body); len(res.Findings) > 0 {
t.Errorf("a status without connections should not match jupyter, got %d findings", len(res.Findings))
}
})
t.Run("a generic version json is not an analytics service", func(t *testing.T) {
body := `{"version":"1.0.0","name":"app"}`
for _, file := range []string{metabase, zeppelin, jupyter} {
if res := runAnalyticsModule(t, file, 200, body); len(res.Findings) > 0 {
t.Errorf("%s: a generic version 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{metabase, zeppelin, jupyter} {
if res := runAnalyticsModule(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{metabase, zeppelin, jupyter} {
if res := runAnalyticsModule(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,173 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runAppCfgModule(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 appCfgExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestAppConfigExposureModules(t *testing.T) {
const spring = "../../modules/recon/spring-application-config-exposure.yaml"
const appsettings = "../../modules/recon/appsettings-exposure.yaml"
const wpconfig = "../../modules/recon/wp-config-backup-exposure.yaml"
springProps := "spring.application.name=billing\n" +
"spring.datasource.url=jdbc:mysql://db.internal:3306/billing\n" +
"spring.datasource.username=app\nspring.datasource.password=s3cr3tP@ss\n" +
"spring.jpa.hibernate.ddl-auto=update\nserver.port=8080\n"
springYaml := "spring:\n datasource:\n url: jdbc:postgresql://pg.internal:5432/app\n" +
" username: app\n password: hunter2\nserver:\n port: 8443\n"
appSettings := `{` + "\n" +
` "Logging": { "LogLevel": { "Default": "Information" } },` + "\n" +
` "ConnectionStrings": {` + "\n" +
` "DefaultConnection": "Server=db;Database=app;User Id=sa;Password=P@ssw0rd;"` + "\n" +
` },` + "\n" +
` "AllowedHosts": "*"` + "\n}"
wpConfig := "<?php\ndefine( 'DB_NAME', 'wordpress' );\ndefine( 'DB_USER', 'wp' );\n" +
"define( 'DB_PASSWORD', 'Tr0ub4dor&3' );\ndefine( 'DB_HOST', 'localhost' );\n" +
"$table_prefix = 'wp_';\n"
t.Run("a spring properties file leaks the jdbc url", func(t *testing.T) {
res := runAppCfgModule(t, spring, 200, springProps)
if len(res.Findings) == 0 {
t.Fatal("expected a spring config finding")
}
if v := appCfgExtract(res, "jdbc_url"); v != "jdbc:mysql://db.internal:3306/billing" {
t.Errorf("jdbc_url=%q, want the mysql url", v)
}
})
t.Run("a spring yaml file also matches and names the jdbc url", func(t *testing.T) {
res := runAppCfgModule(t, spring, 200, springYaml)
if len(res.Findings) == 0 {
t.Fatal("expected a spring config finding for yaml")
}
if v := appCfgExtract(res, "jdbc_url"); v != "jdbc:postgresql://pg.internal:5432/app" {
t.Errorf("jdbc_url=%q, want the postgres url", v)
}
})
t.Run("an appsettings json leaks the connection string", func(t *testing.T) {
res := runAppCfgModule(t, appsettings, 200, appSettings)
if len(res.Findings) == 0 {
t.Fatal("expected an appsettings finding")
}
want := "Server=db;Database=app;User Id=sa;Password=P@ssw0rd;"
if v := appCfgExtract(res, "connection_string"); v != want {
t.Errorf("connection_string=%q, want %q", v, want)
}
})
t.Run("a wp-config backup leaks the database password", func(t *testing.T) {
res := runAppCfgModule(t, wpconfig, 200, wpConfig)
if len(res.Findings) == 0 {
t.Fatal("expected a wp-config finding")
}
if v := appCfgExtract(res, "db_password"); v != "Tr0ub4dor&3" {
t.Errorf("db_password=%q, want Tr0ub4dor&3", v)
}
})
t.Run("a spring config with no credential is not flagged", func(t *testing.T) {
body := "spring.application.name=app\nserver.port=8080\n"
if res := runAppCfgModule(t, spring, 200, body); len(res.Findings) > 0 {
t.Errorf("a credential-free config should not match, got %d findings", len(res.Findings))
}
})
t.Run("a spring config inside an html page is not flagged", func(t *testing.T) {
body := "<!DOCTYPE html><html><body><pre>spring.datasource.password=x</pre></body></html>"
if res := runAppCfgModule(t, spring, 200, body); len(res.Findings) > 0 {
t.Errorf("an html page should not match, got %d findings", len(res.Findings))
}
})
t.Run("an appsettings without a connection string is not flagged", func(t *testing.T) {
body := `{"Logging":{"LogLevel":{"Default":"Information"}},"AllowedHosts":"*"}`
if res := runAppCfgModule(t, appsettings, 200, body); len(res.Findings) > 0 {
t.Errorf("a config without a connection string should not match, got %d findings", len(res.Findings))
}
})
t.Run("an appsettings with no password is not a credential leak", func(t *testing.T) {
body := `{"ConnectionStrings":{"Db":"Server=db;Database=app;Integrated Security=true;"}}`
if res := runAppCfgModule(t, appsettings, 200, body); len(res.Findings) > 0 {
t.Errorf("a passwordless connection string should not match, got %d findings", len(res.Findings))
}
})
t.Run("an appsettings password outside a connection strings section is not flagged", func(t *testing.T) {
body := `{"Smtp":{"Host":"Server=mail;Password=relaypass;"}}`
if res := runAppCfgModule(t, appsettings, 200, body); len(res.Findings) > 0 {
t.Errorf("a password outside ConnectionStrings should not match, got %d findings", len(res.Findings))
}
})
t.Run("prose that names the wp-config password is not a backup", func(t *testing.T) {
body := "set the DB_PASSWORD env var before running the installer"
if res := runAppCfgModule(t, wpconfig, 200, body); len(res.Findings) > 0 {
t.Errorf("prose naming DB_PASSWORD should not match, got %d findings", len(res.Findings))
}
})
t.Run("a wp-config shown in an html page is not flagged", func(t *testing.T) {
body := "<html><head><title>setup</title></head><body>define( 'DB_PASSWORD', 'x' ); DB_NAME</body></html>"
if res := runAppCfgModule(t, wpconfig, 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{spring, appsettings, wpconfig} {
if res := runAppCfgModule(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{spring, appsettings, wpconfig} {
if res := runAppCfgModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
+92
View File
@@ -0,0 +1,92 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runArgocdModule(t *testing.T, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule("../../modules/recon/argocd-api-exposure.yaml")
if err != nil {
t.Fatalf("parse argocd module: %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 argocd module: %v", err)
}
return res
}
func argocdExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestArgocdExposureModule(t *testing.T) {
argocdVersion := `{"Version":"v2.9.3+a1b2c3d","BuildDate":"2024-01-15T12:00:00Z","GitCommit":"a1b2c3d",` +
`"GitTreeState":"clean","GoVersion":"go1.21.5","Compiler":"gc","Platform":"linux/amd64",` +
`"KustomizeVersion":"v5.2.1 2023-10-19","HelmVersion":"v3.13.2+gadc03ef",` +
`"KubectlVersion":"v0.26.11","JsonnetVersion":"v0.20.0"}`
t.Run("an exposed argocd version endpoint is flagged and versioned", func(t *testing.T) {
res := runArgocdModule(t, 200, argocdVersion)
if len(res.Findings) == 0 {
t.Fatal("expected an argocd finding")
}
if v := argocdExtract(res, "argocd_version"); v != "v2.9.3+a1b2c3d" {
t.Errorf("argocd_version=%q, want v2.9.3+a1b2c3d", v)
}
})
t.Run("an argocd kustomize version without a helm version is not flagged", func(t *testing.T) {
body := `{"Version":"v2.9.3","KustomizeVersion":"v5.2.1 2023-10-19"}`
if res := runArgocdModule(t, 200, body); len(res.Findings) > 0 {
t.Errorf("a kustomize version alone should not match argocd, got %d findings", len(res.Findings))
}
})
t.Run("an argocd helm version without a kustomize version is not flagged", func(t *testing.T) {
body := `{"Version":"v2.9.3","HelmVersion":"v3.13.2+gadc03ef"}`
if res := runArgocdModule(t, 200, body); len(res.Findings) > 0 {
t.Errorf("a helm version alone should not match argocd, got %d findings", len(res.Findings))
}
})
t.Run("a generic version endpoint is not argocd", func(t *testing.T) {
body := `{"Version":"v1.0.0","GitCommit":"abc"}`
if res := runArgocdModule(t, 200, body); len(res.Findings) > 0 {
t.Errorf("a generic version json should not match argocd, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
if res := runArgocdModule(t, 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 := runArgocdModule(t, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("a 404 should not match, got %d findings", len(res.Findings))
}
})
}
+148
View File
@@ -0,0 +1,148 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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"
"os"
"path/filepath"
"reflect"
"sort"
"sync"
"testing"
"github.com/dropalldatabases/sif/internal/httpx"
)
func reqURLs(reqs []*httpRequest) []string {
urls := make([]string, len(reqs))
for i, r := range reqs {
urls[i] = r.URL
}
sort.Strings(urls)
return urls
}
func TestGenerateHTTPRequestsAttack(t *testing.T) {
const target = "http://t"
paths2 := []string{"{{BaseURL}}/a?x={{payload}}", "{{BaseURL}}/b?x={{payload}}"}
pay2 := []string{"1", "2"}
cross := []string{"http://t/a?x=1", "http://t/a?x=2", "http://t/b?x=1", "http://t/b?x=2"}
paired := []string{"http://t/a?x=1", "http://t/b?x=2"}
tests := []struct {
name string
paths []string
payloads []string
attack string
want []string
}{
{"clusterbomb default crosses all", paths2, pay2, "", cross},
{"clusterbomb explicit crosses all", paths2, pay2, "clusterbomb", cross},
{"pitchfork pairs by index", paths2, pay2, "pitchfork", paired},
{"pitchfork stops at fewer payloads", append(paths2, "{{BaseURL}}/c?x={{payload}}"), pay2, "pitchfork", paired},
{"pitchfork stops at fewer paths", paths2, []string{"1", "2", "3"}, "pitchfork", paired},
{"attack is case insensitive", paths2, pay2, "Pitchfork", paired},
{"no payloads ignores attack", []string{"{{BaseURL}}/a", "{{BaseURL}}/b"}, nil, "pitchfork", []string{"http://t/a", "http://t/b"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &HTTPConfig{Paths: tt.paths, Payloads: tt.payloads, Attack: tt.attack}
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) {
t.Errorf("attack %q:\n got %v\nwant %v", tt.attack, got, want)
}
})
}
}
func TestValidateAttack(t *testing.T) {
for _, ok := range []string{"", "clusterbomb", "pitchfork", "Pitchfork", "CLUSTERBOMB"} {
if err := validateAttack(ok); err != nil {
t.Errorf("validateAttack(%q) = %v, want nil", ok, err)
}
}
for _, bad := range []string{"sniper", "batteringram", "bogus"} {
if err := validateAttack(bad); err == nil {
t.Errorf("validateAttack(%q) = nil, want error", bad)
}
}
}
func TestParseAttackValidation(t *testing.T) {
dir := t.TempDir()
write := func(name, body string) string {
p := filepath.Join(dir, name)
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
return p
}
good := write("good.yaml", "id: ok\ntype: http\nhttp:\n attack: pitchfork\n paths: [\"{{BaseURL}}/\"]\n")
if _, err := ParseYAMLModule(good); err != nil {
t.Fatalf("valid attack rejected: %v", err)
}
bad := write("bad.yaml", "id: bad\ntype: http\nhttp:\n attack: sniper\n paths: [\"{{BaseURL}}/\"]\n")
if _, err := ParseYAMLModule(bad); err == nil {
t.Fatal("invalid attack accepted")
}
}
// TestExecuteHTTPModulePitchfork drives the executor end to end and confirms
// pitchfork only fires the index-paired requests, not the full cross product.
func TestExecuteHTTPModulePitchfork(t *testing.T) {
var mu sync.Mutex
var hits []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
hits = append(hits, r.URL.Path+"?"+r.URL.RawQuery)
mu.Unlock()
_, _ = w.Write([]byte("ok"))
}))
defer srv.Close()
def := &YAMLModule{
ID: "pf",
Type: TypeHTTP,
HTTP: &HTTPConfig{
Attack: "pitchfork",
Paths: []string{"{{BaseURL}}/a?x={{payload}}", "{{BaseURL}}/b?x={{payload}}"},
Payloads: []string{"1", "2"},
Matchers: []Matcher{{Type: "word", Part: "body", Words: []string{"ok"}}},
},
}
opts := Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)}
if _, err := ExecuteHTTPModule(context.Background(), srv.URL, def, opts); err != nil {
t.Fatalf("ExecuteHTTPModule: %v", err)
}
mu.Lock()
got := append([]string(nil), hits...)
mu.Unlock()
sort.Strings(got)
want := []string{"/a?x=1", "/b?x=2"}
if !reflect.DeepEqual(got, want) {
t.Errorf("pitchfork hit %v, want %v (clusterbomb would also hit /a?x=2 and /b?x=1)", got, want)
}
}
@@ -0,0 +1,156 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runBigDataModule(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 bigDataExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestBigDataAPIExposureModules(t *testing.T) {
const solr = "../../modules/recon/solr-api-exposure.yaml"
const spark = "../../modules/recon/spark-api-exposure.yaml"
const hadoop = "../../modules/recon/hadoop-yarn-api-exposure.yaml"
solrSystem := `{"responseHeader":{"status":0,"QTime":15},"mode":"std",` +
`"solr_home":"/var/solr/data","lucene":{"solr-spec-version":"9.4.0",` +
`"solr-impl-version":"9.4.0","lucene-spec-version":"9.8.0","lucene-impl-version":"9.8.0"},` +
`"jvm":{"version":"17.0.9"}}`
sparkState := `{"url":"spark://master:7077","workers":[{"id":"worker-1","host":"10.0.0.5"}],` +
`"aliveworkers":2,"cores":8,"coresused":0,"memory":15360,"activeapps":[],` +
`"completedapps":[],"status":"ALIVE"}`
hadoopInfo := `{"clusterInfo":{"id":1700000000000,"startedOn":1700000000000,"state":"STARTED",` +
`"haState":"ACTIVE","resourceManagerVersion":"3.3.6","resourceManagerBuildVersion":"3.3.6 from abc",` +
`"hadoopVersion":"3.3.6","hadoopBuildVersion":"3.3.6 from abc","hadoopVersionBuiltOn":"2023-06-18"}}`
t.Run("an exposed solr admin api is flagged and versioned", func(t *testing.T) {
res := runBigDataModule(t, solr, 200, solrSystem)
if len(res.Findings) == 0 {
t.Fatal("expected a solr finding")
}
if v := bigDataExtract(res, "solr_version"); v != "9.4.0" {
t.Errorf("solr_version=%q, want 9.4.0", v)
}
})
t.Run("an exposed spark master leaks its url", func(t *testing.T) {
res := runBigDataModule(t, spark, 200, sparkState)
if len(res.Findings) == 0 {
t.Fatal("expected a spark finding")
}
if v := bigDataExtract(res, "spark_master_url"); v != "spark://master:7077" {
t.Errorf("spark_master_url=%q, want spark://master:7077", v)
}
})
t.Run("an exposed hadoop yarn api is flagged and versioned", func(t *testing.T) {
res := runBigDataModule(t, hadoop, 200, hadoopInfo)
if len(res.Findings) == 0 {
t.Fatal("expected a hadoop finding")
}
if v := bigDataExtract(res, "hadoop_version"); v != "3.3.6" {
t.Errorf("hadoop_version=%q, want 3.3.6", v)
}
})
t.Run("a solr spec version without a solr home is not solr", func(t *testing.T) {
body := `{"lucene":{"solr-spec-version":"9.4.0"},"name":"otherservice"}`
if res := runBigDataModule(t, solr, 200, body); len(res.Findings) > 0 {
t.Errorf("spec version alone should not match solr, got %d findings", len(res.Findings))
}
})
t.Run("a solr home without a spec version is not solr", func(t *testing.T) {
body := `{"solr_home":"/var/solr/data","mode":"std"}`
if res := runBigDataModule(t, solr, 200, body); len(res.Findings) > 0 {
t.Errorf("solr home alone should not match solr, got %d findings", len(res.Findings))
}
})
t.Run("a spark url without alive workers is not flagged", func(t *testing.T) {
body := `{"url":"spark://master:7077","workers":[],"status":"ALIVE"}`
if res := runBigDataModule(t, spark, 200, body); len(res.Findings) > 0 {
t.Errorf("a spark url alone should not match, got %d findings", len(res.Findings))
}
})
t.Run("alive workers behind a non spark url is not flagged", func(t *testing.T) {
body := `{"url":"http://internal:8080","aliveworkers":2}`
if res := runBigDataModule(t, spark, 200, body); len(res.Findings) > 0 {
t.Errorf("a non spark url should not match, got %d findings", len(res.Findings))
}
})
t.Run("a cluster info without a resource manager version is not hadoop", func(t *testing.T) {
body := `{"clusterInfo":{"id":1,"state":"STARTED","hadoopVersion":"3.3.6"}}`
if res := runBigDataModule(t, hadoop, 200, body); len(res.Findings) > 0 {
t.Errorf("cluster info alone should not match hadoop, got %d findings", len(res.Findings))
}
})
t.Run("a resource manager version without a cluster info is not hadoop", func(t *testing.T) {
body := `{"resourceManagerVersion":"3.3.6","app":"custom"}`
if res := runBigDataModule(t, hadoop, 200, body); len(res.Findings) > 0 {
t.Errorf("rm version alone should not match hadoop, got %d findings", len(res.Findings))
}
})
t.Run("a generic json endpoint is not a spark master", func(t *testing.T) {
body := `{"url":"http://app","workers":5,"name":"myservice"}`
if res := runBigDataModule(t, spark, 200, body); len(res.Findings) > 0 {
t.Errorf("a generic json should not match spark, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{solr, spark, hadoop} {
if res := runBigDataModule(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{solr, spark, hadoop} {
if res := runBigDataModule(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/dropalldatabases/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,205 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/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/dropalldatabases/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,151 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runPipelineModule(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 pipelineExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestDataPipelineAPIExposureModules(t *testing.T) {
const airflow = "../../modules/recon/airflow-api-exposure.yaml"
const flink = "../../modules/recon/flink-api-exposure.yaml"
const kafka = "../../modules/recon/kafka-connect-api-exposure.yaml"
airflowHealth := `{"metadatabase":{"status":"healthy"},"scheduler":{"status":"healthy",` +
`"latest_scheduler_heartbeat":"2023-09-13T09:35:49.123456+00:00"}}`
flinkOverview := `{"taskmanagers":1,"slots-total":4,"slots-available":4,"jobs-running":0,` +
`"jobs-finished":2,"jobs-cancelled":0,"jobs-failed":0,"flink-version":"1.17.1","flink-commit":"2750d5c"}`
kafkaConnect := `{"version":"3.5.0","commit":"c97b88d5db4de28d","kafka_cluster_id":"M_oad8FjQ1eMShri6_jjQg"}`
t.Run("an exposed airflow health endpoint is flagged", func(t *testing.T) {
res := runPipelineModule(t, airflow, 200, airflowHealth)
if len(res.Findings) == 0 {
t.Fatal("expected an airflow finding")
}
if v := pipelineExtract(res, "airflow_scheduler_heartbeat"); v != "2023-09-13T09:35:49.123456+00:00" {
t.Errorf("airflow_scheduler_heartbeat=%q, want the heartbeat timestamp", v)
}
})
t.Run("an exposed flink dashboard is flagged and versioned", func(t *testing.T) {
res := runPipelineModule(t, flink, 200, flinkOverview)
if len(res.Findings) == 0 {
t.Fatal("expected a flink finding")
}
if v := pipelineExtract(res, "flink_version"); v != "1.17.1" {
t.Errorf("flink_version=%q, want 1.17.1", v)
}
})
t.Run("an exposed kafka connect api is flagged and versioned", func(t *testing.T) {
res := runPipelineModule(t, kafka, 200, kafkaConnect)
if len(res.Findings) == 0 {
t.Fatal("expected a kafka connect finding")
}
if v := pipelineExtract(res, "kafka_version"); v != "3.5.0" {
t.Errorf("kafka_version=%q, want 3.5.0", v)
}
})
t.Run("an airflow metadatabase without a scheduler is not flagged", func(t *testing.T) {
body := `{"metadatabase":{"status":"healthy"}}`
if res := runPipelineModule(t, airflow, 200, body); len(res.Findings) > 0 {
t.Errorf("metadatabase alone should not match airflow, got %d findings", len(res.Findings))
}
})
t.Run("an airflow scheduler without a metadatabase is not flagged", func(t *testing.T) {
body := `{"scheduler":{"status":"healthy","latest_scheduler_heartbeat":"2023-09-13T09:35:49.123456+00:00"}}`
if res := runPipelineModule(t, airflow, 200, body); len(res.Findings) > 0 {
t.Errorf("scheduler alone should not match airflow, got %d findings", len(res.Findings))
}
})
t.Run("a flink version without a slot total is not flagged", func(t *testing.T) {
body := `{"flink-version":"1.17.1","taskmanagers":1}`
if res := runPipelineModule(t, flink, 200, body); len(res.Findings) > 0 {
t.Errorf("flink version alone should not match flink, got %d findings", len(res.Findings))
}
})
t.Run("a slot total without a flink version is not flagged", func(t *testing.T) {
body := `{"slots-total":4,"jobs-running":0}`
if res := runPipelineModule(t, flink, 200, body); len(res.Findings) > 0 {
t.Errorf("a slot total alone should not match flink, got %d findings", len(res.Findings))
}
})
t.Run("a kafka cluster id without a version is not flagged", func(t *testing.T) {
body := `{"kafka_cluster_id":"M_oad8FjQ1eMShri6_jjQg","commit":"abc"}`
if res := runPipelineModule(t, kafka, 200, body); len(res.Findings) > 0 {
t.Errorf("a cluster id alone should not match kafka connect, got %d findings", len(res.Findings))
}
})
t.Run("a version without a kafka cluster id is not flagged", func(t *testing.T) {
body := `{"version":"3.5.0","name":"someservice"}`
if res := runPipelineModule(t, kafka, 200, body); len(res.Findings) > 0 {
t.Errorf("a version alone should not match kafka connect, got %d findings", len(res.Findings))
}
})
t.Run("a generic health json is not airflow", func(t *testing.T) {
body := `{"status":"UP","components":{"db":{"status":"UP"}}}`
if res := runPipelineModule(t, airflow, 200, body); len(res.Findings) > 0 {
t.Errorf("a generic health should not match airflow, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{airflow, flink, kafka} {
if res := runPipelineModule(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{airflow, flink, kafka} {
if res := runPipelineModule(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,166 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runDBFileModule(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 dbFileExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestDatabaseFileExposureModules(t *testing.T) {
const sqlDump = "../../modules/recon/sql-dump-exposure.yaml"
const sqlite = "../../modules/recon/sqlite-database-exposure.yaml"
const redis = "../../modules/recon/redis-dump-exposure.yaml"
mysqldump := "-- MySQL dump 10.13 Distrib 8.0.32, for Linux (x86_64)\n--\n" +
"-- Host: localhost Database: appdb\n--\n-- Server version\t8.0.32\n\n" +
"DROP TABLE IF EXISTS `users`;\nCREATE TABLE `users` (\n" +
" `id` int NOT NULL AUTO_INCREMENT,\n `email` varchar(255) DEFAULT NULL,\n" +
" PRIMARY KEY (`id`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;\n" +
"INSERT INTO `users` VALUES (1,'admin@x.com');\n"
pgdump := "--\n-- PostgreSQL database dump\n--\n\nSET statement_timeout = 0;\n" +
"CREATE TABLE public.accounts (\n id integer NOT NULL,\n email text\n);\n" +
"COPY public.accounts (id, email) FROM stdin;\n1\tadmin@x.com\n\\.\n"
sqliteFile := "SQLite format 3\x00" + strings.Repeat("\x00", 84) +
"\x05\x00CREATE TABLE users(id INTEGER PRIMARY KEY, email TEXT, password TEXT)\x00"
redisDump := "REDIS0011\xfa\x09redis-ver\x055.0.7\xfa\x0aredis-bits\xc0@\xfe\x00\xfb\x02\x00" +
"\x03key\x05value\xff\x00\x00\x00\x00\x00\x00\x00\x00"
t.Run("a mysqldump leaks the dumped table", func(t *testing.T) {
res := runDBFileModule(t, sqlDump, 200, mysqldump)
if len(res.Findings) == 0 {
t.Fatal("expected a sql dump finding")
}
if v := dbFileExtract(res, "dump_table"); v != "users" {
t.Errorf("dump_table=%q, want users", v)
}
})
t.Run("a postgresql dump also matches and names its table", func(t *testing.T) {
res := runDBFileModule(t, sqlDump, 200, pgdump)
if len(res.Findings) == 0 {
t.Fatal("expected a sql dump finding for pg_dump")
}
if v := dbFileExtract(res, "dump_table"); v != "accounts" {
t.Errorf("dump_table=%q, want accounts", v)
}
})
t.Run("a sqlite database file leaks its schema table", func(t *testing.T) {
res := runDBFileModule(t, sqlite, 200, sqliteFile)
if len(res.Findings) == 0 {
t.Fatal("expected a sqlite finding")
}
if v := dbFileExtract(res, "table_name"); v != "users" {
t.Errorf("table_name=%q, want users", v)
}
})
t.Run("a redis rdb snapshot leaks its format version", func(t *testing.T) {
res := runDBFileModule(t, redis, 200, redisDump)
if len(res.Findings) == 0 {
t.Fatal("expected a redis rdb finding")
}
if v := dbFileExtract(res, "rdb_version"); v != "0011" {
t.Errorf("rdb_version=%q, want 0011", v)
}
})
t.Run("sql shown inside an html page is not a dump", func(t *testing.T) {
body := "<!DOCTYPE html><html><head><title>SQL tutorial</title></head><body>" +
"<pre>DROP TABLE IF EXISTS users; CREATE TABLE users (id int); INSERT INTO users VALUES (1);</pre>" +
"</body></html>"
if res := runDBFileModule(t, sqlDump, 200, body); len(res.Findings) > 0 {
t.Errorf("an html tutorial should not match, got %d findings", len(res.Findings))
}
})
t.Run("a sql file with no dump idiom is not flagged", func(t *testing.T) {
body := "-- migration notes\nSELECT id FROM users WHERE active = 1;\n"
if res := runDBFileModule(t, sqlDump, 200, body); len(res.Findings) > 0 {
t.Errorf("a bare select should not match, got %d findings", len(res.Findings))
}
})
t.Run("a page that names the sqlite format is not the file", func(t *testing.T) {
body := "This page documents the SQLite format 3 on-disk structure for readers."
if res := runDBFileModule(t, sqlite, 200, body); len(res.Findings) > 0 {
t.Errorf("prose about sqlite should not match, got %d findings", len(res.Findings))
}
})
t.Run("a page that names redis is not an rdb snapshot", func(t *testing.T) {
body := "redis-server is running on this host as the REDIS cache backend."
if res := runDBFileModule(t, redis, 200, body); len(res.Findings) > 0 {
t.Errorf("prose about redis should not match, got %d findings", len(res.Findings))
}
})
t.Run("the sqlite magic only counts at the start of the file", func(t *testing.T) {
body := "<pre>hexdump of a header: " + sqliteFile + "</pre>"
if res := runDBFileModule(t, sqlite, 200, body); len(res.Findings) > 0 {
t.Errorf("an embedded sqlite header should not match, got %d findings", len(res.Findings))
}
})
t.Run("the rdb magic only counts at the start of the file", func(t *testing.T) {
body := "log line: loaded snapshot " + redisDump
if res := runDBFileModule(t, redis, 200, body); len(res.Findings) > 0 {
t.Errorf("an embedded rdb header 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{sqlDump, sqlite, redis} {
if res := runDBFileModule(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{sqlDump, sqlite, redis} {
if res := runDBFileModule(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/dropalldatabases/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/dropalldatabases/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))
}
}
})
}
@@ -0,0 +1,134 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runDeployModule(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 deployExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestDeployConfigExposureModules(t *testing.T) {
const vscode = "../../modules/recon/vscode-sftp-exposure.yaml"
const sublime = "../../modules/recon/sublime-sftp-exposure.yaml"
const ftpconfig = "../../modules/recon/ftpconfig-exposure.yaml"
t.Run("vscode sftp config leaks the deploy host", func(t *testing.T) {
body := `{"name":"prod","host":"deploy.example.com","protocol":"sftp",` +
`"username":"root","password":"s3cr3t","remotePath":"/var/www","uploadOnSave":true}`
res := runDeployModule(t, vscode, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a vscode sftp finding")
}
if v := deployExtract(res, "remote_host"); v != "deploy.example.com" {
t.Errorf("remote_host=%q, want deploy.example.com", v)
}
})
t.Run("vscode sftp config with key auth still flags and extracts the host", func(t *testing.T) {
body := `{"host":"key.example.com","protocol":"sftp",` +
`"username":"deploy","privateKeyPath":"~/.ssh/id_rsa","uploadOnSave":true}`
res := runDeployModule(t, vscode, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a vscode sftp finding for a key-auth config")
}
if v := deployExtract(res, "remote_host"); v != "key.example.com" {
t.Errorf("remote_host=%q, want key.example.com", v)
}
})
t.Run("sublime sftp config leaks the deploy host", func(t *testing.T) {
body := `{"type":"sftp","host":"sftp.example.org","user":"www","password":"hunter2",` +
`"remote_path":"/srv","upload_on_save":true,"sync_down_on_open":false}`
res := runDeployModule(t, sublime, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a sublime sftp finding")
}
if v := deployExtract(res, "remote_host"); v != "sftp.example.org" {
t.Errorf("remote_host=%q, want sftp.example.org", v)
}
})
t.Run("atom remote-ftp config leaks the deploy host", func(t *testing.T) {
body := `{"protocol":"ftp","host":"ftp.example.net","port":21,"user":"upload",` +
`"pass":"letmein","remote":"/","connTimeout":10000,"pasvTimeout":10000}`
res := runDeployModule(t, ftpconfig, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected an atom remote-ftp finding")
}
if v := deployExtract(res, "remote_host"); v != "ftp.example.net" {
t.Errorf("remote_host=%q, want ftp.example.net", v)
}
})
t.Run("an html login page carrying the same keys is not a leak", func(t *testing.T) {
body := `<html><head><title>Sign in</title></head><body>` +
`config keys "remotePath" "password" "host":"evil.example.com"</body></html>`
if res := runDeployModule(t, vscode, 200, body); len(res.Findings) > 0 {
t.Errorf("an html page should not match, got %d findings", len(res.Findings))
}
})
t.Run("a plain json config without the tool keys is not a leak", func(t *testing.T) {
body := `{"host":"db.internal","username":"admin","user":"admin","pass":"x","password":"hunter2"}`
for _, file := range []string{vscode, sublime, ftpconfig} {
if res := runDeployModule(t, file, 200, body); len(res.Findings) > 0 {
t.Errorf("%s: a config without the tool keys should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a tool config with a host but no credential field is not a leak", func(t *testing.T) {
bodies := map[string]string{
vscode: `{"host":"h.example.com","remotePath":"/var/www","uploadOnSave":true}`,
sublime: `{"type":"sftp","host":"h.example.com","upload_on_save":true}`,
ftpconfig: `{"protocol":"ftp","host":"h.example.com","connTimeout":10000,"pasvTimeout":10000}`,
}
for file, body := range bodies {
if res := runDeployModule(t, file, 200, body); len(res.Findings) > 0 {
t.Errorf("%s: a config with no credential field 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{vscode, sublime, ftpconfig} {
if res := runDeployModule(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,159 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runDistDBModule(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 distDBExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestDistributedDBExposureModules(t *testing.T) {
const riak = "../../modules/recon/riak-api-exposure.yaml"
const couchbase = "../../modules/recon/couchbase-api-exposure.yaml"
const druid = "../../modules/recon/druid-api-exposure.yaml"
riakStats := `{"riak_kv_version":"3.0.16","riak_core_version":"3.0.99","riak_pipe_version":"3.0.16",` +
`"sys_otp_release":"22","ring_members":["riak@10.0.0.1"],"ring_num_partitions":64,` +
`"storage_backend":"riak_kv_bitcask_backend"}`
couchbasePools := `{"pools":[{"name":"default","uri":"/pools/default?uuid=abc",` +
`"streamingUri":"/poolsStreaming/default?uuid=abc"}],"isAdminCreds":false,"isEnterprise":true,` +
`"implementationVersion":"7.2.0-6053-enterprise","uuid":"abc",` +
`"componentsVersion":{"ns_server":"7.2.0-6053","couchdb":"3.1.1"}}`
druidStatus := `{"version":"0.22.1","modules":[{"name":"org.apache.druid.server.initialization.jetty.JettyServerModule",` +
`"artifact":"druid-server","version":"0.22.1"},{"name":"org.apache.druid.guice.AnnouncerModule",` +
`"artifact":"druid-server","version":"0.22.1"}],"memory":{"maxMemory":1037959168,` +
`"totalMemory":1037959168,"freeMemory":900000000,"directMemory":134217728}}`
t.Run("an exposed riak http api is flagged and versioned", func(t *testing.T) {
res := runDistDBModule(t, riak, 200, riakStats)
if len(res.Findings) == 0 {
t.Fatal("expected a riak finding")
}
if v := distDBExtract(res, "riak_version"); v != "3.0.16" {
t.Errorf("riak_version=%q, want 3.0.16", v)
}
})
t.Run("an exposed couchbase cluster api is flagged and versioned", func(t *testing.T) {
res := runDistDBModule(t, couchbase, 200, couchbasePools)
if len(res.Findings) == 0 {
t.Fatal("expected a couchbase finding")
}
if v := distDBExtract(res, "couchbase_version"); v != "7.2.0-6053-enterprise" {
t.Errorf("couchbase_version=%q, want 7.2.0-6053-enterprise", v)
}
})
t.Run("an exposed druid process is flagged and versioned", func(t *testing.T) {
res := runDistDBModule(t, druid, 200, druidStatus)
if len(res.Findings) == 0 {
t.Fatal("expected a druid finding")
}
if v := distDBExtract(res, "druid_version"); v != "0.22.1" {
t.Errorf("druid_version=%q, want 0.22.1", v)
}
})
t.Run("a riak kv version without a core version is not flagged", func(t *testing.T) {
body := `{"riak_kv_version":"3.0.16","name":"app"}`
if res := runDistDBModule(t, riak, 200, body); len(res.Findings) > 0 {
t.Errorf("a kv version alone should not match riak, got %d findings", len(res.Findings))
}
})
t.Run("a riak core version without a kv version is not flagged", func(t *testing.T) {
body := `{"riak_core_version":"3.0.16","name":"app"}`
if res := runDistDBModule(t, riak, 200, body); len(res.Findings) > 0 {
t.Errorf("a core version alone should not match riak, got %d findings", len(res.Findings))
}
})
t.Run("a couchbase impl version without a components version is not flagged", func(t *testing.T) {
body := `{"implementationVersion":"7.2.0","name":"app"}`
if res := runDistDBModule(t, couchbase, 200, body); len(res.Findings) > 0 {
t.Errorf("an impl version alone should not match couchbase, got %d findings", len(res.Findings))
}
})
t.Run("a couchbase components version without an impl version is not flagged", func(t *testing.T) {
body := `{"componentsVersion":{"ns_server":"7.2.0"},"name":"app"}`
if res := runDistDBModule(t, couchbase, 200, body); len(res.Findings) > 0 {
t.Errorf("a components version alone should not match couchbase, got %d findings", len(res.Findings))
}
})
t.Run("a druid package without a memory block is not flagged", func(t *testing.T) {
body := `{"modules":[{"name":"org.apache.druid.cli.Main"}],"app":"x"}`
if res := runDistDBModule(t, druid, 200, body); len(res.Findings) > 0 {
t.Errorf("a druid package alone should not match druid, got %d findings", len(res.Findings))
}
})
t.Run("a memory block without a druid package is not flagged", func(t *testing.T) {
body := `{"memory":{"maxMemory":123},"app":"x"}`
if res := runDistDBModule(t, druid, 200, body); len(res.Findings) > 0 {
t.Errorf("a memory block alone should not match druid, got %d findings", len(res.Findings))
}
})
t.Run("a generic version json is not a distributed db", func(t *testing.T) {
body := `{"version":"1.0.0","name":"app"}`
for _, file := range []string{riak, couchbase, druid} {
if res := runDistDBModule(t, file, 200, body); len(res.Findings) > 0 {
t.Errorf("%s: a generic version 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{riak, couchbase, druid} {
if res := runDistDBModule(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{riak, couchbase, druid} {
if res := runDistDBModule(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,164 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/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/dropalldatabases/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))
}
})
}
+172 -32
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,26 +158,106 @@ func generateHTTPRequests(target string, cfg *HTTPConfig) []*httpRequest {
Original: path,
})
}
return requests
return requests, nil
}
// Generate requests with payloads
for _, path := range cfg.Paths {
// 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(paths)
if len(cfg.Payloads) < n {
n = len(cfg.Payloads)
}
for i := 0; i < n; i++ {
requests = append(requests, newPayloadRequest(method, target, paths[i], cfg.Payloads[i], cfg))
}
return requests, nil
}
for _, path := range paths {
for _, payload := range cfg.Payloads {
url := substituteVariables(path, target, payload)
body := substituteVariables(cfg.Body, target, payload)
requests = append(requests, &httpRequest{
Method: method,
URL: url,
Headers: cfg.Headers,
Body: body,
Payload: payload,
Original: path,
})
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
// substituted for the given payload.
func newPayloadRequest(method, target, path, payload string, cfg *HTTPConfig) *httpRequest {
return &httpRequest{
Method: method,
URL: substituteVariables(path, target, payload),
Headers: cfg.Headers,
Body: substituteVariables(cfg.Body, target, payload),
Payload: payload,
Original: path,
}
}
// validateAttack rejects an attack mode that is not "", "clusterbomb", or
// "pitchfork"; an empty value defaults to clusterbomb.
func validateAttack(attack string) error {
switch strings.ToLower(attack) {
case "", "clusterbomb", "pitchfork":
return nil
default:
return fmt.Errorf("invalid attack %q (want \"clusterbomb\" or \"pitchfork\")", attack)
}
}
// substituteVariables replaces template variables in a string.
@@ -212,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 {
@@ -261,10 +371,22 @@ 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.
for _, n := range m.Size {
if len(body) == n {
return true
}
}
return false
default:
return false
@@ -356,9 +478,9 @@ func runExtractors(extractors []Extractor, resp *http.Response, body string) map
result := make(map[string]string)
for _, e := range extractors {
part := getPart(e.Part, resp, body)
if e.Type == "regex" {
switch e.Type {
case "regex":
part := getPart(e.Part, resp, body)
for _, pattern := range e.Regex {
re, err := regexp.Compile(pattern)
if err != nil {
@@ -370,6 +492,24 @@ func runExtractors(extractors []Extractor, resp *http.Response, body string) map
break
}
}
case "kv":
// kv records response header key/values, namespaced by the extractor
// name when set (e.g. a headers module surfacing every header).
for k, v := range resp.Header {
key := k
if e.Name != "" {
key = e.Name + "." + k
}
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
}
}
}
}
+117
View File
@@ -17,6 +17,8 @@ import (
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
@@ -143,6 +145,79 @@ func TestExecuteHTTPModulePayloadExpansion(t *testing.T) {
}
}
// TestExecuteHTTPModuleSizeMatcher pins the size matcher: it fires when the
// response body length equals a listed value and stays silent otherwise.
func TestExecuteHTTPModuleSizeMatcher(t *testing.T) {
body := "1234567890" // 10 bytes
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
opts := Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)}
mod := func(id string, size int) *YAMLModule {
return &YAMLModule{
ID: id, Type: TypeHTTP,
HTTP: &HTTPConfig{
Paths: []string{"{{BaseURL}}/"},
Matchers: []Matcher{{Type: "size", Size: []int{size}}},
},
}
}
hit, err := ExecuteHTTPModule(context.Background(), srv.URL, mod("size-hit", len(body)), opts)
if err != nil {
t.Fatalf("ExecuteHTTPModule(hit): %v", err)
}
if len(hit.Findings) != 1 {
t.Fatalf("size match: got %d findings, want 1", len(hit.Findings))
}
miss, err := ExecuteHTTPModule(context.Background(), srv.URL, mod("size-miss", len(body)+1), opts)
if err != nil {
t.Fatalf("ExecuteHTTPModule(miss): %v", err)
}
if len(miss.Findings) != 0 {
t.Fatalf("size mismatch: got %d findings, want 0", len(miss.Findings))
}
}
// TestExecuteHTTPModuleKvExtractor pins the kv extractor: it records response
// header key/values onto the finding, namespaced by the extractor name.
func TestExecuteHTTPModuleKvExtractor(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Server", "nginx/1.25.3")
w.Header().Set("X-Powered-By", "PHP/8.2.0")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("hello"))
}))
defer srv.Close()
def := &YAMLModule{
ID: "kv-mod", Type: TypeHTTP,
HTTP: &HTTPConfig{
Paths: []string{"{{BaseURL}}/"},
Matchers: []Matcher{{Type: "status", Status: []int{200}}},
Extractors: []Extractor{{Type: "kv", Name: "headers", Part: "header"}},
},
}
result, err := ExecuteHTTPModule(context.Background(), srv.URL, def, Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)})
if err != nil {
t.Fatalf("ExecuteHTTPModule: %v", err)
}
if len(result.Findings) != 1 {
t.Fatalf("got %d findings, want 1", len(result.Findings))
}
ex := result.Findings[0].Extracted
if ex["headers.Server"] != "nginx/1.25.3" {
t.Errorf("kv headers.Server = %q, want nginx/1.25.3", ex["headers.Server"])
}
if ex["headers.X-Powered-By"] != "PHP/8.2.0" {
t.Errorf("kv headers.X-Powered-By = %q, want PHP/8.2.0", ex["headers.X-Powered-By"])
}
}
func TestExecuteHTTPModuleNoConfig(t *testing.T) {
def := &YAMLModule{ID: "x", Type: TypeHTTP}
if _, err := ExecuteHTTPModule(context.Background(), "http://h", def, Options{}); err == nil {
@@ -249,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/dropalldatabases/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/dropalldatabases/sif/internal/fingerprint"
"github.com/dropalldatabases/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,168 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runHTTPDBModule(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 httpdbExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestHTTPDatabaseExposureModules(t *testing.T) {
const influxdb = "../../modules/recon/influxdb-api-exposure.yaml"
const arangodb = "../../modules/recon/arangodb-api-exposure.yaml"
const neo4j = "../../modules/recon/neo4j-api-exposure.yaml"
influxHealth := `{"name":"influxdb","message":"ready for queries and writes","status":"pass",` +
`"checks":[],"version":"2.9.1","commit":"a1b2c3d4"}`
arangoVersion := `{"server":"arango","version":"3.11.5","license":"community"}`
neo4jDiscovery := `{"bolt_routing":"neo4j://localhost:7687","transaction":"http://localhost:7474/db/{databaseName}/tx",` +
`"bolt_direct":"bolt://localhost:7687","neo4j_version":"5.13.0","neo4j_edition":"community"}`
t.Run("an exposed influxdb health endpoint is flagged and versioned", func(t *testing.T) {
res := runHTTPDBModule(t, influxdb, 200, influxHealth)
if len(res.Findings) == 0 {
t.Fatal("expected an influxdb finding")
}
if v := httpdbExtract(res, "influxdb_version"); v != "2.9.1" {
t.Errorf("influxdb_version=%q, want 2.9.1", v)
}
})
t.Run("an anonymous arangodb version endpoint is flagged and versioned", func(t *testing.T) {
res := runHTTPDBModule(t, arangodb, 200, arangoVersion)
if len(res.Findings) == 0 {
t.Fatal("expected an arangodb finding")
}
if v := httpdbExtract(res, "arangodb_version"); v != "3.11.5" {
t.Errorf("arangodb_version=%q, want 3.11.5", v)
}
})
t.Run("an exposed neo4j discovery endpoint is flagged and versioned", func(t *testing.T) {
res := runHTTPDBModule(t, neo4j, 200, neo4jDiscovery)
if len(res.Findings) == 0 {
t.Fatal("expected a neo4j finding")
}
if v := httpdbExtract(res, "neo4j_version"); v != "5.13.0" {
t.Errorf("neo4j_version=%q, want 5.13.0", v)
}
})
t.Run("an influxdb name without the health message is not flagged", func(t *testing.T) {
body := `{"name":"influxdb","status":"pass"}`
if res := runHTTPDBModule(t, influxdb, 200, body); len(res.Findings) > 0 {
t.Errorf("an influxdb name alone should not match, got %d findings", len(res.Findings))
}
})
t.Run("a health message without the influxdb name is not flagged", func(t *testing.T) {
body := `{"name":"telegraf","message":"ready for queries and writes"}`
if res := runHTTPDBModule(t, influxdb, 200, body); len(res.Findings) > 0 {
t.Errorf("the message alone should not match influxdb, got %d findings", len(res.Findings))
}
})
t.Run("an arango without a license field is still flagged", func(t *testing.T) {
body := `{"server":"arango","version":"3.11.5"}`
res := runHTTPDBModule(t, arangodb, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected an arangodb finding without a license field (pre-3.12)")
}
if v := httpdbExtract(res, "arangodb_version"); v != "3.11.5" {
t.Errorf("arangodb_version=%q, want 3.11.5", v)
}
})
t.Run("a non-arango version response is not flagged", func(t *testing.T) {
body := `{"server":"foundationdb","version":"1.0.0"}`
if res := runHTTPDBModule(t, arangodb, 200, body); len(res.Findings) > 0 {
t.Errorf("a non-arango server should not match, got %d findings", len(res.Findings))
}
})
t.Run("an arango response without a version is not flagged", func(t *testing.T) {
body := `{"server":"arango"}`
if res := runHTTPDBModule(t, arangodb, 200, body); len(res.Findings) > 0 {
t.Errorf("an arango without a version should not match, got %d findings", len(res.Findings))
}
})
t.Run("an arango that requires auth is not flagged", func(t *testing.T) {
if res := runHTTPDBModule(t, arangodb, 401, arangoVersion); len(res.Findings) > 0 {
t.Errorf("a 401 arango should not match, got %d findings", len(res.Findings))
}
})
t.Run("a neo4j version without an edition is not flagged", func(t *testing.T) {
body := `{"neo4j_version":"5.13.0","transaction":"http://localhost:7474/db/neo4j/tx"}`
if res := runHTTPDBModule(t, neo4j, 200, body); len(res.Findings) > 0 {
t.Errorf("a neo4j version alone should not match, got %d findings", len(res.Findings))
}
})
t.Run("a neo4j edition without a version is not flagged", func(t *testing.T) {
body := `{"neo4j_edition":"community","bolt_routing":"neo4j://localhost:7687"}`
if res := runHTTPDBModule(t, neo4j, 200, body); len(res.Findings) > 0 {
t.Errorf("a neo4j edition alone should not match, got %d findings", len(res.Findings))
}
})
t.Run("a generic health json is not influxdb", func(t *testing.T) {
body := `{"status":"UP","components":{"db":{"status":"UP"}}}`
if res := runHTTPDBModule(t, influxdb, 200, body); len(res.Findings) > 0 {
t.Errorf("a generic health should not match influxdb, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{influxdb, arangodb, neo4j} {
if res := runHTTPDBModule(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{influxdb, arangodb, neo4j} {
if res := runHTTPDBModule(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/dropalldatabases/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))
}
}
})
}
+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/dropalldatabases/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)
}
}
+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/dropalldatabases/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))
}
}
})
}
@@ -0,0 +1,155 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runMgmtModule(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 mgmtExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestManagementAPIExposureModules(t *testing.T) {
const kong = "../../modules/recon/kong-api-exposure.yaml"
const jolokia = "../../modules/recon/jolokia-api-exposure.yaml"
const nats = "../../modules/recon/nats-api-exposure.yaml"
kongRoot := `{"version":"3.4.0","tagline":"Welcome to kong","hostname":"kong-node","node_id":"abc",` +
`"lua_version":"LuaJIT 2.1.0","plugins":{"available_on_server":{}},` +
`"configuration":{"database":"postgres","admin_listen":["0.0.0.0:8001"]}}`
jolokiaVersion := `{"request":{"type":"version"},"value":{"agent":"1.7.2","protocol":"7.2",` +
`"config":{"agentType":"servlet"},"info":{"product":"tomcat"}},"status":200,"timestamp":1694598949}`
natsVarz := `{"server_id":"NDABC","server_name":"NDABC","version":"2.10.1","proto":1,"go":"go1.21.1",` +
`"host":"0.0.0.0","port":4222,"max_connections":65536,"max_payload":1048576,"connections":3,"total_connections":10}`
t.Run("an exposed kong admin api is flagged and versioned", func(t *testing.T) {
res := runMgmtModule(t, kong, 200, kongRoot)
if len(res.Findings) == 0 {
t.Fatal("expected a kong finding")
}
if v := mgmtExtract(res, "kong_version"); v != "3.4.0" {
t.Errorf("kong_version=%q, want 3.4.0", v)
}
})
t.Run("an exposed jolokia agent is flagged and versioned", func(t *testing.T) {
res := runMgmtModule(t, jolokia, 200, jolokiaVersion)
if len(res.Findings) == 0 {
t.Fatal("expected a jolokia finding")
}
if v := mgmtExtract(res, "jolokia_agent_version"); v != "1.7.2" {
t.Errorf("jolokia_agent_version=%q, want 1.7.2", v)
}
})
t.Run("an exposed nats monitor is flagged and versioned", func(t *testing.T) {
res := runMgmtModule(t, nats, 200, natsVarz)
if len(res.Findings) == 0 {
t.Fatal("expected a nats finding")
}
if v := mgmtExtract(res, "nats_version"); v != "2.10.1" {
t.Errorf("nats_version=%q, want 2.10.1", v)
}
})
t.Run("an available plugins map without an admin listen is not flagged", func(t *testing.T) {
body := `{"plugins":{"available_on_server":{}},"version":"3.4.0"}`
if res := runMgmtModule(t, kong, 200, body); len(res.Findings) > 0 {
t.Errorf("an available plugins map alone should not match kong, got %d findings", len(res.Findings))
}
})
t.Run("an admin listen without an available plugins map is not flagged", func(t *testing.T) {
body := `{"configuration":{"admin_listen":["0.0.0.0:8001"]},"version":"1.0"}`
if res := runMgmtModule(t, kong, 200, body); len(res.Findings) > 0 {
t.Errorf("an admin listen alone should not match kong, got %d findings", len(res.Findings))
}
})
t.Run("a jolokia agent without a protocol is not flagged", func(t *testing.T) {
body := `{"value":{"agent":"1.7.2"}}`
if res := runMgmtModule(t, jolokia, 200, body); len(res.Findings) > 0 {
t.Errorf("an agent alone should not match jolokia, got %d findings", len(res.Findings))
}
})
t.Run("a jolokia protocol without an agent is not flagged", func(t *testing.T) {
body := `{"value":{"protocol":"7.2"},"info":{}}`
if res := runMgmtModule(t, jolokia, 200, body); len(res.Findings) > 0 {
t.Errorf("a protocol alone should not match jolokia, got %d findings", len(res.Findings))
}
})
t.Run("a nats server id without a max payload is not flagged", func(t *testing.T) {
body := `{"server_id":"NDABC","version":"2.10.1"}`
if res := runMgmtModule(t, nats, 200, body); len(res.Findings) > 0 {
t.Errorf("a server id alone should not match nats, got %d findings", len(res.Findings))
}
})
t.Run("a max payload without a nats server id is not flagged", func(t *testing.T) {
body := `{"max_payload":1048576,"port":4222}`
if res := runMgmtModule(t, nats, 200, body); len(res.Findings) > 0 {
t.Errorf("a max payload alone should not match nats, got %d findings", len(res.Findings))
}
})
t.Run("a generic version json is not a management api", func(t *testing.T) {
body := `{"version":"1.0.0","name":"app"}`
for _, file := range []string{kong, jolokia, nats} {
if res := runMgmtModule(t, file, 200, body); len(res.Findings) > 0 {
t.Errorf("%s: a generic version 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{kong, jolokia, nats} {
if res := runMgmtModule(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{kong, jolokia, nats} {
if res := runMgmtModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
+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/dropalldatabases/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))
}
}
+72 -6
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)
}
})
@@ -339,9 +341,9 @@ func TestRunExtractors(t *testing.T) {
wantNil: true,
},
{
name: "non-regex extractor type is ignored",
name: "unknown extractor type is ignored",
extractors: []Extractor{
{Type: "kval", Name: "session", Part: "body", Regex: []string{`"session":"([^"]+)"`}, Group: 1},
{Type: "bogus", Name: "session", Part: "body", Regex: []string{`"session":"([^"]+)"`}, Group: 1},
},
wantNil: true,
},
@@ -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/dropalldatabases/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))
}
}
})
}
+5 -2
View File
@@ -86,11 +86,13 @@ 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"`
Hash []int64 `yaml:"hash,omitempty"` // favicon: shodan mmh3 hashes (signed or unsigned)
Condition string `yaml:"condition"` // and, or
Negative bool `yaml:"negative"`
}
@@ -98,9 +100,10 @@ type Matcher struct {
// Extractor defines data extraction from responses.
// Extractors pull specific data from matched responses for reporting.
type Extractor struct {
Type string `yaml:"type"` // regex, kval, json
Type string `yaml:"type"` // regex, kv, json
Name string `yaml:"name"`
Part string `yaml:"part"`
Regex []string `yaml:"regex,omitempty"`
JSON []string `yaml:"json,omitempty"`
Group int `yaml:"group"`
}
+112
View File
@@ -0,0 +1,112 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/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)
}
}
})
}
}
@@ -0,0 +1,132 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runOrchModule(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 orchExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestOrchestrationAPIExposureModules(t *testing.T) {
const vault = "../../modules/recon/vault-api-exposure.yaml"
const consul = "../../modules/recon/consul-api-exposure.yaml"
const etcd = "../../modules/recon/etcd-api-exposure.yaml"
vaultSeal := `{"type":"shamir","initialized":true,"sealed":false,"t":3,"n":5,` +
`"progress":0,"nonce":"","version":"1.15.2","build_date":"2023-11-06T11:33:49Z",` +
`"migration":false,"cluster_name":"vault-cluster-9d52b1f1","recovery_seal":false,` +
`"storage_type":"raft"}`
consulSelf := `{"Config":{"Datacenter":"dc1","NodeName":"consul-server-1","Server":true,` +
`"Version":"1.17.0"},"Member":{"Name":"consul-server-1","Addr":"10.0.0.5","Port":8301}}`
etcdVersion := `{"etcdserver":"3.5.9","etcdcluster":"3.5.0"}`
t.Run("an exposed vault seal-status is flagged and versioned", func(t *testing.T) {
res := runOrchModule(t, vault, 200, vaultSeal)
if len(res.Findings) == 0 {
t.Fatal("expected a vault finding")
}
if v := orchExtract(res, "vault_version"); v != "1.15.2" {
t.Errorf("vault_version=%q, want 1.15.2", v)
}
})
t.Run("an exposed consul agent self leaks the datacenter", func(t *testing.T) {
res := runOrchModule(t, consul, 200, consulSelf)
if len(res.Findings) == 0 {
t.Fatal("expected a consul finding")
}
if v := orchExtract(res, "consul_datacenter"); v != "dc1" {
t.Errorf("consul_datacenter=%q, want dc1", v)
}
})
t.Run("an exposed etcd version endpoint is flagged and versioned", func(t *testing.T) {
res := runOrchModule(t, etcd, 200, etcdVersion)
if len(res.Findings) == 0 {
t.Fatal("expected an etcd finding")
}
if v := orchExtract(res, "etcd_version"); v != "3.5.9" {
t.Errorf("etcd_version=%q, want 3.5.9", v)
}
})
t.Run("a sealed flag without the other vault keys is not vault", func(t *testing.T) {
body := `{"sealed":"yes","status":"ok"}`
if res := runOrchModule(t, vault, 200, body); len(res.Findings) > 0 {
t.Errorf("a bare sealed flag should not match, got %d findings", len(res.Findings))
}
})
t.Run("a datacenter field alone is not consul", func(t *testing.T) {
body := `{"Datacenter":"dc1"}`
if res := runOrchModule(t, consul, 200, body); len(res.Findings) > 0 {
t.Errorf("a bare datacenter field should not match, got %d findings", len(res.Findings))
}
})
t.Run("a version response from another service is not etcd", func(t *testing.T) {
body := `{"version":"1.2.3","service":"myapp"}`
if res := runOrchModule(t, etcd, 200, body); len(res.Findings) > 0 {
t.Errorf("another service version should not match, got %d findings", len(res.Findings))
}
})
t.Run("an etcdserver without an etcdcluster is not flagged", func(t *testing.T) {
body := `{"etcdserver":"3.5.9"}`
if res := runOrchModule(t, etcd, 200, body); len(res.Findings) > 0 {
t.Errorf("a partial etcd response 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{vault, consul, etcd} {
if res := runOrchModule(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{vault, consul, etcd} {
if res := runOrchModule(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,131 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runRailsModule(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 railsExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestRailsSecretExposureModules(t *testing.T) {
const database = "../../modules/recon/rails-database-yml-exposure.yaml"
const secrets = "../../modules/recon/rails-secrets-yml-exposure.yaml"
const masterKey = "../../modules/recon/rails-master-key-exposure.yaml"
const keyBase = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
const masterKeyValue = "0123456789abcdef0123456789abcdef"
t.Run("database config leaks the database name and credentials", func(t *testing.T) {
body := "default: &default\n adapter: postgresql\n encoding: unicode\n pool: 5\n" +
" username: app_user\n password: s3cr3tdbpass\n host: db.internal\n\n" +
"production:\n <<: *default\n database: myapp_production\n"
res := runRailsModule(t, database, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a database config finding")
}
if v := railsExtract(res, "database"); v != "myapp_production" {
t.Errorf("database=%q, want myapp_production", v)
}
})
t.Run("a credential free sqlite database config is not a leak", func(t *testing.T) {
body := "production:\n adapter: sqlite3\n database: db/production.sqlite3\n pool: 5\n"
if res := runRailsModule(t, database, 200, body); len(res.Findings) > 0 {
t.Errorf("a sqlite config without credentials should not match, got %d findings", len(res.Findings))
}
})
t.Run("secrets config leaks the secret key base", func(t *testing.T) {
body := "development:\n secret_key_base: " + keyBase + "\n"
res := runRailsModule(t, secrets, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a secrets config finding")
}
if v := railsExtract(res, "secret_key_base"); v != keyBase {
t.Errorf("secret_key_base=%q, want %q", v, keyBase)
}
})
t.Run("master key file leaks the key", func(t *testing.T) {
res := runRailsModule(t, masterKey, 200, masterKeyValue)
if len(res.Findings) == 0 {
t.Fatal("expected a master key finding")
}
if v := railsExtract(res, "master_key"); v != masterKeyValue {
t.Errorf("master_key=%q, want %q", v, masterKeyValue)
}
})
t.Run("a longer hex digest is not the master key", func(t *testing.T) {
body := masterKeyValue + masterKeyValue
if res := runRailsModule(t, masterKey, 200, body); len(res.Findings) > 0 {
t.Errorf("a 64 char digest should not match the 32 char key, got %d findings", len(res.Findings))
}
})
t.Run("a hex value not at the body start is not the master key", func(t *testing.T) {
body := "key=" + masterKeyValue
if res := runRailsModule(t, masterKey, 200, body); len(res.Findings) > 0 {
t.Errorf("a hex value away from the start should not match, got %d findings", len(res.Findings))
}
})
t.Run("an html page naming the rails markers is not a leak", func(t *testing.T) {
body := "<html><head><title>Error</title></head><body>secret_key_base: " + keyBase + "</body></html>"
if res := runRailsModule(t, secrets, 200, body); len(res.Findings) > 0 {
t.Errorf("an html page should not match, got %d findings", len(res.Findings))
}
})
t.Run("a config without the rails markers is not a leak", func(t *testing.T) {
body := "password: hunter2\nusername: admin\nhost: db.internal\n"
for _, file := range []string{database, secrets, masterKey} {
if res := runRailsModule(t, file, 200, body); len(res.Findings) > 0 {
t.Errorf("%s: a config without the rails markers 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{database, secrets, masterKey} {
if res := runRailsModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
+118
View File
@@ -0,0 +1,118 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/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/dropalldatabases/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))
}
}
})
}
@@ -0,0 +1,162 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runSecretModule(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 secretExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestSecretFileExposureModules(t *testing.T) {
const privkey = "../../modules/recon/private-key-exposure.yaml"
const gitcred = "../../modules/recon/git-credentials-exposure.yaml"
const pypirc = "../../modules/recon/pypirc-exposure.yaml"
opensshKey := "-----BEGIN OPENSSH PRIVATE KEY-----\n" +
"b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZWQy\n" +
"NTUxOQAAACD1aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n" +
"-----END OPENSSH PRIVATE KEY-----\n"
rsaKey := "-----BEGIN RSA PRIVATE KEY-----\n" +
"MIIEpAIBAAKCAQEArandombase64payloadthatstandsinforakeybodyhere1234567890\n" +
"-----END RSA PRIVATE KEY-----\n"
gitCreds := "https://octocat:ghp_AbCdEf0123456789AbCdEf0123456789@github.com\n" +
"https://deploy:s3cr3t@gitlab.example.com\n"
pypiConfig := "[distutils]\nindex-servers =\n pypi\n\n[pypi]\n" +
"username = __token__\npassword = pypi-AgEIcHlwaS5vcmcCJDQ2Y2Q\n"
t.Run("an openssh private key is flagged and typed", func(t *testing.T) {
res := runSecretModule(t, privkey, 200, opensshKey)
if len(res.Findings) == 0 {
t.Fatal("expected a private key finding")
}
if v := secretExtract(res, "key_type"); v != "OPENSSH" {
t.Errorf("key_type=%q, want OPENSSH", v)
}
})
t.Run("an rsa private key is flagged and typed", func(t *testing.T) {
res := runSecretModule(t, privkey, 200, rsaKey)
if len(res.Findings) == 0 {
t.Fatal("expected a private key finding")
}
if v := secretExtract(res, "key_type"); v != "RSA" {
t.Errorf("key_type=%q, want RSA", v)
}
})
t.Run("a git credential store leaks its host", func(t *testing.T) {
res := runSecretModule(t, gitcred, 200, gitCreds)
if len(res.Findings) == 0 {
t.Fatal("expected a git credential finding")
}
if v := secretExtract(res, "git_host"); v != "github.com" {
t.Errorf("git_host=%q, want github.com", v)
}
})
t.Run("a pypirc leaks the upload token", func(t *testing.T) {
res := runSecretModule(t, pypirc, 200, pypiConfig)
if len(res.Findings) == 0 {
t.Fatal("expected a pypirc finding")
}
if v := secretExtract(res, "pypi_token"); v != "pypi-AgEIcHlwaS5vcmcCJDQ2Y2Q" {
t.Errorf("pypi_token=%q, want the pypi- token", v)
}
})
t.Run("a public key is not a private key", func(t *testing.T) {
body := "-----BEGIN PUBLIC KEY-----\nMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAK\n" +
"-----END PUBLIC KEY-----\nssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAB user@host\n"
if res := runSecretModule(t, privkey, 200, body); len(res.Findings) > 0 {
t.Errorf("a public key should not match, got %d findings", len(res.Findings))
}
})
t.Run("prose that names a private key is not the key", func(t *testing.T) {
body := "Generate your private key with ssh-keygen and keep id_rsa secret."
if res := runSecretModule(t, privkey, 200, body); len(res.Findings) > 0 {
t.Errorf("prose about keys should not match, got %d findings", len(res.Findings))
}
})
t.Run("a git remote url without a password is not a credential store", func(t *testing.T) {
body := "https://github.com/octocat/hello-world.git\n"
if res := runSecretModule(t, gitcred, 200, body); len(res.Findings) > 0 {
t.Errorf("a bare remote url should not match, got %d findings", len(res.Findings))
}
})
t.Run("a pypi section without a credential is not a leak", func(t *testing.T) {
body := "[distutils]\nindex-servers =\n pypi\n"
if res := runSecretModule(t, pypirc, 200, body); len(res.Findings) > 0 {
t.Errorf("a section with no credential should not match, got %d findings", len(res.Findings))
}
})
t.Run("credentials shown in an html page are not a store", func(t *testing.T) {
body := "<!DOCTYPE html><html><body>clone with https://user:pass@host.example</body></html>"
if res := runSecretModule(t, gitcred, 200, body); len(res.Findings) > 0 {
t.Errorf("an html page should not match, got %d findings", len(res.Findings))
}
})
t.Run("a pypi config inside an html page is not a leak", func(t *testing.T) {
body := "<html><head><title>docs</title></head><body><pre>[pypi]\npassword = pypi-x</pre></body></html>"
if res := runSecretModule(t, pypirc, 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{privkey, gitcred, pypirc} {
if res := runSecretModule(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{privkey, gitcred, pypirc} {
if res := runSecretModule(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/dropalldatabases/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))
}
}
})
}
+143
View File
@@ -0,0 +1,143 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runVectorDBModule(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 vectorDBExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestVectorDBExposureModules(t *testing.T) {
const qdrant = "../../modules/recon/qdrant-api-exposure.yaml"
const weaviate = "../../modules/recon/weaviate-api-exposure.yaml"
const chroma = "../../modules/recon/chroma-api-exposure.yaml"
qdrantCollections := `{"result":{"collections":[{"name":"documents"},{"name":"embeddings"}]},` +
`"status":"ok","time":0.000018}`
weaviateMeta := `{"hostname":"http://[::]:8080","modules":{"text2vec-openai":{"version":"v1.0.0"}},` +
`"version":"1.23.7"}`
chromaHeartbeat := `{"nanosecond heartbeat":1718900000000000000}`
t.Run("a qdrant collections api is flagged and named", func(t *testing.T) {
res := runVectorDBModule(t, qdrant, 200, qdrantCollections)
if len(res.Findings) == 0 {
t.Fatal("expected a qdrant finding")
}
if v := vectorDBExtract(res, "qdrant_collection"); v != "documents" {
t.Errorf("qdrant_collection=%q, want documents", v)
}
})
t.Run("a weaviate meta api is flagged with its hostname", func(t *testing.T) {
res := runVectorDBModule(t, weaviate, 200, weaviateMeta)
if len(res.Findings) == 0 {
t.Fatal("expected a weaviate finding")
}
if v := vectorDBExtract(res, "weaviate_hostname"); v != "http://[::]:8080" {
t.Errorf("weaviate_hostname=%q, want http://[::]:8080", v)
}
})
t.Run("a chroma heartbeat api is flagged", func(t *testing.T) {
res := runVectorDBModule(t, chroma, 200, chromaHeartbeat)
if len(res.Findings) == 0 {
t.Fatal("expected a chroma finding")
}
})
t.Run("a qdrant status without a collections result is not flagged", func(t *testing.T) {
body := `{"result":{"points":[{"id":1}]},"status":"ok","time":0.001}`
if res := runVectorDBModule(t, qdrant, 200, body); len(res.Findings) > 0 {
t.Errorf("a points result should not match qdrant, got %d findings", len(res.Findings))
}
})
t.Run("a qdrant collections result without an ok status is not flagged", func(t *testing.T) {
body := `{"result":{"collections":[{"name":"x"}]}}`
if res := runVectorDBModule(t, qdrant, 200, body); len(res.Findings) > 0 {
t.Errorf("a collections result without ok status should not match qdrant, got %d findings", len(res.Findings))
}
})
t.Run("a weaviate meta without a version is not flagged", func(t *testing.T) {
body := `{"hostname":"http://x:8080","modules":{"a":{}}}`
if res := runVectorDBModule(t, weaviate, 200, body); len(res.Findings) > 0 {
t.Errorf("a meta without a version should not match weaviate, got %d findings", len(res.Findings))
}
})
t.Run("a weaviate hostname that is not a url is not flagged", func(t *testing.T) {
body := `{"hostname":"db-internal","version":"1.23.7"}`
if res := runVectorDBModule(t, weaviate, 200, body); len(res.Findings) > 0 {
t.Errorf("a bare hostname should not match weaviate, got %d findings", len(res.Findings))
}
})
t.Run("a chroma 200 without the heartbeat key is not flagged", func(t *testing.T) {
body := `{"heartbeat":1718900000}`
if res := runVectorDBModule(t, chroma, 200, body); len(res.Findings) > 0 {
t.Errorf("a plain heartbeat key should not match chroma, got %d findings", len(res.Findings))
}
})
t.Run("a generic version json is not a vector db", func(t *testing.T) {
body := `{"version":"1.0.0","name":"app"}`
for _, file := range []string{qdrant, weaviate, chroma} {
if res := runVectorDBModule(t, file, 200, body); len(res.Findings) > 0 {
t.Errorf("%s: a generic version 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{qdrant, weaviate, chroma} {
if res := runVectorDBModule(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{qdrant, weaviate, chroma} {
if res := runVectorDBModule(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,136 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runWebSrvModule(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 webSrvExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestWebserverConfigExposureModules(t *testing.T) {
const htpasswd = "../../modules/recon/htpasswd-exposure.yaml"
const webconfig = "../../modules/recon/webconfig-exposure.yaml"
const htaccess = "../../modules/recon/htaccess-exposure.yaml"
t.Run("htpasswd leaks the user and an apache md5 hash", func(t *testing.T) {
body := "admin:$apr1$z9c.x1pq$Q8r6Jm0pYh0pX2yq4nN3l1\nbackup:$apr1$ab$cd\n"
res := runWebSrvModule(t, htpasswd, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected an htpasswd finding")
}
if v := webSrvExtract(res, "htpasswd_user"); v != "admin" {
t.Errorf("htpasswd_user=%q, want admin", v)
}
})
t.Run("htpasswd with a bcrypt hash also matches", func(t *testing.T) {
body := "deploy:$2y$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZ\n"
if res := runWebSrvModule(t, htpasswd, 200, body); len(res.Findings) == 0 {
t.Fatal("expected an htpasswd finding for a bcrypt hash")
}
})
t.Run("web.config leaks a connection string", func(t *testing.T) {
body := `<?xml version="1.0"?><configuration><connectionStrings>` +
`<add name="Default" connectionString="Server=db;Database=app;User Id=sa;Password=p@ss;" ` +
`providerName="System.Data.SqlClient" /></connectionStrings></configuration>`
res := runWebSrvModule(t, webconfig, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a web.config finding")
}
want := "Server=db;Database=app;User Id=sa;Password=p@ss;"
if v := webSrvExtract(res, "connection_string"); v != want {
t.Errorf("connection_string=%q, want %q", v, want)
}
})
t.Run("htaccess leaks the password file path", func(t *testing.T) {
body := "RewriteEngine On\nAuthType Basic\nAuthName \"Restricted\"\n" +
"AuthUserFile /var/www/.htpasswd\nRequire valid-user\n"
res := runWebSrvModule(t, htaccess, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected an htaccess finding")
}
if v := webSrvExtract(res, "auth_user_file"); v != "/var/www/.htpasswd" {
t.Errorf("auth_user_file=%q, want /var/www/.htpasswd", v)
}
})
t.Run("a minimal htaccess with only access control still flags", func(t *testing.T) {
body := "Options -Indexes\nDeny from all\n"
if res := runWebSrvModule(t, htaccess, 200, body); len(res.Findings) == 0 {
t.Fatal("expected a finding for a deny-from-all htaccess")
}
})
t.Run("a plaintext password line is not a hash", func(t *testing.T) {
body := "admin:notahashedpassword\n"
if res := runWebSrvModule(t, htpasswd, 200, body); len(res.Findings) > 0 {
t.Errorf("a plaintext line should not match, got %d findings", len(res.Findings))
}
})
t.Run("a configuration element without a dotnet section is not a leak", func(t *testing.T) {
body := `<?xml version="1.0"?><configuration><customRoot><foo/></customRoot></configuration>`
if res := runWebSrvModule(t, webconfig, 200, body); len(res.Findings) > 0 {
t.Errorf("a non dotnet configuration should not match, got %d findings", len(res.Findings))
}
})
t.Run("an html page is not an htaccess", func(t *testing.T) {
body := "<html><head><title>x</title></head><body>RewriteEngine On AuthType Basic</body></html>"
if res := runWebSrvModule(t, htaccess, 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{htpasswd, webconfig, htaccess} {
if res := runWebSrvModule(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{htpasswd, webconfig, htaccess} {
if res := runWebSrvModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
+32 -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"` // sniper, pitchfork, clusterbomb
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
@@ -100,6 +102,27 @@ func ParseYAMLModule(path string) (*YAMLModule, error) {
return nil, fmt.Errorf("module missing required field: type")
}
if ym.HTTP != nil {
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
}
+15 -8
View File
@@ -84,15 +84,22 @@ func CloudStorage(url string, timeout time.Duration, logdir string) ([]CloudStor
}
func extractPotentialBuckets(url string) []string {
// TODO: handle non-adjacent label combos and strip the tld
parts := strings.Split(url, ".")
var buckets []string
for i, part := range parts {
buckets = append(buckets, part, part+"-s3", "s3-"+part)
labels := strings.Split(url, ".")
// drop the tld label so we don't waste guesses on it ("com", "com-s3", ...);
// a single-label host has no tld to strip.
if len(labels) > 1 {
labels = labels[:len(labels)-1]
}
if i < len(parts)-1 {
domainExtension := part + "-" + parts[i+1]
buckets = append(buckets, domainExtension, parts[i+1]+"-"+part)
var buckets []string
for _, label := range labels {
buckets = append(buckets, label, label+"-s3", "s3-"+label)
}
// combine every label with every other, not just adjacent ones, so a deep
// host like shop.cdn.example yields shop-example too.
for i, a := range labels {
for _, b := range labels[i+1:] {
buckets = append(buckets, a+"-"+b, b+"-"+a)
}
}
return buckets
+62
View File
@@ -0,0 +1,62 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package scan
import (
"slices"
"testing"
)
func TestExtractPotentialBuckets(t *testing.T) {
tests := []struct {
name string
host string
want []string // candidates that must be generated
absent []string // candidates that must not be generated
}{
{
name: "strips the tld and pairs labels both ways",
host: "shop.example.com",
want: []string{"shop", "shop-s3", "s3-shop", "example", "shop-example", "example-shop"},
absent: []string{"com", "com-s3", "s3-com", "example-com", "com-example"},
},
{
name: "combines non-adjacent labels",
host: "a.b.c.example.com",
want: []string{"a-c", "c-a", "a-example", "example-a", "b-example"},
absent: []string{"com", "example-com"},
},
{
name: "single-label host keeps its only label and makes no pairs",
host: "localhost",
want: []string{"localhost", "localhost-s3", "s3-localhost"},
absent: []string{"localhost-localhost", ""},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := extractPotentialBuckets(tt.host)
for _, w := range tt.want {
if !slices.Contains(got, w) {
t.Errorf("extractPotentialBuckets(%q) missing %q; got %v", tt.host, w, got)
}
}
for _, a := range tt.absent {
if slices.Contains(got, a) {
t.Errorf("extractPotentialBuckets(%q) should not generate %q; got %v", tt.host, a, got)
}
}
})
}
}
+40 -10
View File
@@ -78,7 +78,7 @@ func CMS(url string, timeout time.Duration, logdir string) (*CMSResult, error) {
}
// Drupal
if strings.Contains(resp.Header.Get("X-Drupal-Cache"), "HIT") || strings.Contains(bodyString, "Drupal.settings") {
if detectDrupal(resp.Header, bodyString) {
spin.Stop()
result := &CMSResult{Name: "Drupal", Version: "Unknown"}
log.Success("Detected CMS: %s", output.Highlight.Render(result.Name))
@@ -87,7 +87,7 @@ func CMS(url string, timeout time.Duration, logdir string) (*CMSResult, error) {
}
// Joomla
if strings.Contains(bodyString, "joomla") || strings.Contains(bodyString, "/media/system/js/core.js") {
if detectJoomla(bodyString) {
spin.Stop()
result := &CMSResult{Name: "Joomla", Version: "Unknown"}
log.Success("Detected CMS: %s", output.Highlight.Render(result.Name))
@@ -102,12 +102,12 @@ func CMS(url string, timeout time.Duration, logdir string) (*CMSResult, error) {
}
func detectWordPress(url string, client *http.Client, bodyString string) bool {
// Check for common WordPress indicators in the HTML
// wordpress asset paths only; the bare word "wordpress" matched pages that
// merely mention it (wp-hosting marketing), so it is dropped.
wpIndicators := []string{
"wp-content",
"wp-includes",
"wp-json",
"wordpress",
}
for _, indicator := range wpIndicators {
@@ -128,12 +128,23 @@ func detectWordPress(url string, client *http.Client, bodyString string) bool {
if err != nil {
continue
}
resp, err := client.Do(req) //nolint:bodyclose // drained and closed via httpx.DrainClose
if err == nil {
found := resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusFound
// status only; drain so the conn returns to the pool.
httpx.DrainClose(resp)
if found {
resp, err := client.Do(req)
if err != nil {
continue
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1024*1024))
resp.Body.Close()
if err != nil {
continue
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusFound {
continue
}
// the client follows redirects, so soft-404 and catch-all sites also land
// here with a 200; require an actual WordPress marker in the body.
probeBody := string(body)
for _, indicator := range wpIndicators {
if strings.Contains(probeBody, indicator) {
return true
}
}
@@ -141,3 +152,22 @@ func detectWordPress(url string, client *http.Client, bodyString string) bool {
return false
}
// detectJoomla keys on the capital Joomla! generator and joomla asset paths. a
// bare "joomla" mention (the old check) matched marketing pages, so it is gone.
func detectJoomla(body string) bool {
return strings.Contains(body, `generator" content="Joomla!`) ||
strings.Contains(body, "/media/vendor/joomla") ||
strings.Contains(body, "/media/system/js/core.js")
}
// detectDrupal reports whether the response looks like Drupal. the X-Drupal-* and
// X-Generator headers survive cdn caching when the body markers do not, and an
// X-Drupal-Cache of any value (even MISS) is a tell.
func detectDrupal(header http.Header, body string) bool {
return strings.Contains(header.Get("X-Generator"), "Drupal") ||
header.Get("X-Drupal-Cache") != "" ||
header.Get("X-Drupal-Dynamic-Cache") != "" ||
strings.Contains(body, "Drupal.settings") ||
strings.Contains(body, "drupalSettings")
}
+69
View File
@@ -0,0 +1,69 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package scan
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
// header cases mirror live Drupal 8-11 (acquia, georgia, london): the X-Drupal-*
// and X-Generator headers tell even when the body has no marker.
func TestDetectDrupal_ModernSignals(t *testing.T) {
cases := []struct {
name string
header http.Header
body string
want bool
}{
{"x-generator drupal 10", http.Header{"X-Generator": {"Drupal 10 (https://www.drupal.org)"}}, "", true},
{"x-drupal-cache miss", http.Header{"X-Drupal-Cache": {"MISS"}}, "", true},
{"x-drupal-dynamic-cache", http.Header{"X-Drupal-Dynamic-Cache": {"HIT"}}, "", true},
{"drupalSettings body (8+)", http.Header{}, `<script>window.drupalSettings = {};</script>`, true},
{"Drupal.settings body (7)", http.Header{}, `<script>Drupal.settings = {};</script>`, true},
{"plain page", http.Header{"Server": {"nginx"}}, "<html><body>hello</body></html>", false},
{"x-generator wordpress", http.Header{"X-Generator": {"WordPress 6.5"}}, "", false},
{"bare drupal prose", http.Header{}, "we migrated off Drupal CMS last year", false},
}
for _, c := range cases {
if got := detectDrupal(c.header, c.body); got != c.want {
t.Errorf("%s: detectDrupal = %v, want %v", c.name, got, c.want)
}
}
}
// end-to-end: a modern Drupal whose only tell is X-Drupal-Dynamic-Cache (the live
// london.gov.uk case) must be detected.
func TestCMS_ModernDrupalDetected(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// a real Drupal site has no wordpress paths; 404 them so the wordpress
// probe does not claim the host before the Drupal check runs.
if r.URL.Path != "/" {
w.WriteHeader(http.StatusNotFound)
return
}
w.Header().Set("X-Drupal-Dynamic-Cache", "MISS")
_, _ = w.Write([]byte("<html><body>news and updates</body></html>"))
}))
defer srv.Close()
result, err := CMS(srv.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("CMS: %v", err)
}
if result == nil || result.Name != "Drupal" {
t.Errorf("modern Drupal (X-Drupal-Dynamic-Cache) not detected, got %+v", result)
}
}
+80
View File
@@ -0,0 +1,80 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package scan
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
// a bare "joomla" mention must not match; only the real signals do.
func TestDetectJoomla_Signals(t *testing.T) {
cases := []struct {
name string
body string
want bool
}{
{"generator", `<meta name="generator" content="Joomla! - Open Source Content Management" />`, true},
{"vendor asset path", `<script src="/media/vendor/joomla-custom-elements/js/joomla-alert.min.js"></script>`, true},
{"core.js path", `<script src="/media/system/js/core.js"></script>`, true},
{"bare mention", "we offer managed joomla hosting", false},
{"capital prose", "migrating from Joomla to something else", false},
{"tagline prose", "the Joomla! - Open Source Content Management project", false},
{"plain", "<html><body>hello</body></html>", false},
}
for _, c := range cases {
if got := detectJoomla(c.body); got != c.want {
t.Errorf("%s: detectJoomla = %v, want %v", c.name, got, c.want)
}
}
}
// joomlaServer serves homeBody at / and 404s elsewhere, so the wordpress probe
// cannot claim the host before the Joomla check.
func joomlaServer(t *testing.T, homeBody string) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
w.WriteHeader(http.StatusNotFound)
return
}
_, _ = w.Write([]byte(homeBody))
}))
t.Cleanup(srv.Close)
return srv
}
// the capital-J Joomla! generator was missed by the old lowercase check.
func TestCMS_JoomlaGeneratorDetected(t *testing.T) {
srv := joomlaServer(t, `<meta name="generator" content="Joomla! - Open Source Content Management" />`)
result, err := CMS(srv.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("CMS: %v", err)
}
if result == nil || result.Name != "Joomla" {
t.Errorf("Joomla generator not detected, got %+v", result)
}
}
func TestCMS_JoomlaBareMentionNotFlagged(t *testing.T) {
srv := joomlaServer(t, "<html><body>we offer managed joomla hosting</body></html>")
result, err := CMS(srv.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("CMS: %v", err)
}
if result != nil && result.Name == "Joomla" {
t.Error("a page merely mentioning joomla was flagged as Joomla")
}
}
@@ -0,0 +1,49 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package scan
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
// notFound serves 404 for every path so the file probe never fires; only the
// passed homepage body decides the result.
func notFound(t *testing.T) string {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
t.Cleanup(srv.Close)
return srv.URL
}
// a page that only mentions wordpress in prose (no asset paths) is not running it.
func TestDetectWordPress_BareMentionNotFlagged(t *testing.T) {
client := &http.Client{Timeout: 5 * time.Second}
body := "<html><body>we offer managed wordpress hosting</body></html>"
if detectWordPress(notFound(t), client, body) {
t.Error("a page merely mentioning wordpress was flagged as WordPress")
}
}
// a real wordpress homepage references wp-content asset paths.
func TestDetectWordPress_AssetPathsDetected(t *testing.T) {
client := &http.Client{Timeout: 5 * time.Second}
body := `<link href="/wp-content/themes/x/style.css">`
if !detectWordPress(notFound(t), client, body) {
t.Error("wp-content asset path should be detected as WordPress")
}
}
+102
View File
@@ -0,0 +1,102 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package scan
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
// a soft-404 (200 for every path) is not wordpress; a bare 200 on the probe must
// not flag without a marker in the body.
func TestDetectWordPress_SoftFourOhFourNotFlagged(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("<html><body>welcome to my static site</body></html>"))
}))
defer srv.Close()
client := &http.Client{Timeout: 5 * time.Second}
if detectWordPress(srv.URL, client, "<html><body>welcome to my static site</body></html>") {
t.Error("soft-404 site (200 for every path, no wordpress markers) wrongly detected as WordPress")
}
}
// a catch-all 302 is followed to a non-wordpress 200; without a marker it must
// not flag.
func TestDetectWordPress_CatchAllRedirectNotFlagged(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/home", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("<html><body>landing page</body></html>"))
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/home", http.StatusFound)
})
srv := httptest.NewServer(mux)
defer srv.Close()
client := &http.Client{Timeout: 5 * time.Second}
if detectWordPress(srv.URL, client, "<html><body>landing page</body></html>") {
t.Error("catch-all 302 redirect to a non-wordpress homepage wrongly detected as WordPress")
}
}
// a real wp-login.php response references wp-includes assets even when the
// homepage hides its wordpress markers, so the file probe should still detect it.
func TestDetectWordPress_LoginPageProbeDetected(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/wp-login.php" {
_, _ = w.Write([]byte(`<link rel="stylesheet" href="/wp-includes/css/dashicons.min.css">`))
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
client := &http.Client{Timeout: 5 * time.Second}
if !detectWordPress(srv.URL, client, "<html><body>custom theme, no markers</body></html>") {
t.Error("wp-login.php referencing wp-includes assets should be detected as WordPress")
}
}
// end-to-end through CMS() with the real redirect-following client: a soft-404
// host must not be reported as a CMS.
func TestCMS_SoftFourOhFourNotWordPress(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("<html><body>welcome to my static site</body></html>"))
}))
defer srv.Close()
result, err := CMS(srv.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("CMS: %v", err)
}
if result != nil {
t.Errorf("soft-404 host wrongly classified as CMS %q", result.Name)
}
}
// a probe that hits a redirect loop errors out in the client; it must be skipped
// gracefully, never panicking or counting as a detection.
func TestDetectWordPress_RedirectLoopHandled(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/loop", http.StatusFound)
}))
defer srv.Close()
client := &http.Client{Timeout: 5 * time.Second}
if detectWordPress(srv.URL, client, "<html><body>no markers</body></html>") {
t.Error("redirect loop wrongly detected as WordPress")
}
}
+4 -9
View File
@@ -42,10 +42,6 @@ type CORSFinding struct {
Note string `json:"note"`
}
// corsMaxRedirects caps the redirect chain so we read the cors headers off the
// host we actually asked about, not whatever it bounces us to.
const corsMaxRedirects = 3
// the sentinel attacker origin; if it comes back in Access-Control-Allow-Origin
// the target reflects arbitrary origins and any site can read the response.
const corsEvilOrigin = "https://sif-cors-probe.evil.com"
@@ -97,11 +93,10 @@ func CORS(targetURL string, timeout time.Duration, threads int, logdir string) (
host := parsedURL.Host
client := httpx.Client(timeout)
client.CheckRedirect = func(_ *http.Request, via []*http.Request) error {
if len(via) >= corsMaxRedirects {
return http.ErrUseLastResponse
}
return nil
// don't follow redirects: cors is judged on the host we asked about, so a
// bounce to a permissive third party can't be pinned on the target.
client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
}
result := &CORSResult{Findings: make([]CORSFinding, 0, len(corsOrigins))}
+33
View File
@@ -15,6 +15,7 @@ package scan
import (
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)
@@ -132,6 +133,38 @@ func TestCORS_NoFalsePositiveOnSafeServer(t *testing.T) {
}
}
// TestCORS_JudgesRequestedHostNotRedirectTarget pins the redirect behavior: the
// requested host bounces to a reflecting third party, so following the redirect would
// pin that party's misconfig on the target. the counter proves we never left the host.
func TestCORS_JudgesRequestedHostNotRedirectTarget(t *testing.T) {
var destHits int32
dest := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&destHits, 1)
if origin := r.Header.Get("Origin"); origin != "" {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
}
w.WriteHeader(http.StatusOK)
}))
defer dest.Close()
redirector := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, dest.URL, http.StatusFound)
}))
defer redirector.Close()
result, err := CORS(redirector.URL, 5*time.Second, 3, "")
if err != nil {
t.Fatalf("CORS: %v", err)
}
if n := atomic.LoadInt32(&destHits); n != 0 {
t.Errorf("followed the redirect to the reflecting host %d time(s); cors must stay on the requested host", n)
}
if result != nil && len(result.Findings) > 0 {
t.Errorf("expected no findings: the reflection is on the redirect target, not the requested host; got %+v", result.Findings)
}
}
func TestCORSResult_ResultType(t *testing.T) {
r := &CORSResult{}
if r.ResultType() != "cors" {
+85 -22
View File
@@ -50,8 +50,9 @@ const dirlistBodyCap = 512 * 1024
// cannot exist, then treat any response shape they share as the wildcard
// baseline. deterministic (no rng) so the workflow stays reproducible.
const (
calibrationProbes = 3
calibrationPrefix = "/sif-cal-"
calibrationProbes = 3
calibrationPrefix = "/sif-cal-"
calibrationPadStep = 8 // per-probe suffix growth; see calibrationSuffix
)
// statusNotFound / statusForbidden are the historical default "not interesting"
@@ -90,6 +91,20 @@ type responseMeta struct {
words int
}
// anySize, as a baseline size, marks a catch-all whose body size is unreliable (it
// reflects the request path), so the baseline matches on status and word count alone.
const anySize = -1
// matchesBaseline reports whether meta looks like the calibrated soft-404 shape b.
// a normal baseline compares status, size and words exactly; a reflecting catch-all
// (b.size == anySize) compares status and words only, since its size is not stable.
func (b responseMeta) matchesBaseline(meta responseMeta) bool {
if b.status != meta.status || b.words != meta.words {
return false
}
return b.size == anySize || b.size == meta.size
}
// matcher decides whether a response is "interesting" using the same precedence
// as ffuf/feroxbuster: an explicit filter (-fc/-fs/-fw/-fr or a calibrated
// baseline) drops the response, otherwise the match-code set decides.
@@ -154,13 +169,10 @@ func newMatcher(opts *DirlistOptions) (*matcher, error) {
// over matches: a calibrated baseline, an -fc/-fs/-fw hit, or an -fr body match
// always drops the response; otherwise the -mc set (when set) gates it.
func (m *matcher) Matches(meta responseMeta, body []byte) bool {
// a calibrated soft-404 shape is the same response the catch-all hands every
// bogus path, so drop anything that matches a baseline exactly.
for i := 0; i < len(m.baselines); i++ {
b := m.baselines[i]
if b.status == meta.status && b.size == meta.size && b.words == meta.words {
return false
}
// a calibrated soft-404 shape is the response the catch-all hands every bogus
// path, so drop anything matching a baseline.
if containsBaseline(m.baselines, meta) {
return false
}
if _, drop := m.filterCodes[meta.status]; drop {
@@ -303,7 +315,11 @@ func fetchWordlist(listURL string, client *http.Client) ([]string, error) {
return nil, fmt.Errorf("download wordlist %q: %w", listURL, err)
}
defer resp.Body.Close()
return scanLines(resp.Body), nil
lines, err := scanLines(resp.Body)
if err != nil {
return nil, fmt.Errorf("download wordlist %q: %w", listURL, err)
}
return lines, nil
}
// readWordlistFile loads a local wordlist file.
@@ -313,11 +329,15 @@ func readWordlistFile(path string) ([]string, error) {
return nil, fmt.Errorf("open wordlist %q: %w", path, err)
}
defer f.Close()
return scanLines(f), nil
lines, err := scanLines(f)
if err != nil {
return nil, fmt.Errorf("read wordlist %q: %w", path, err)
}
return lines, nil
}
// scanLines reads non-empty lines into a slice.
func scanLines(r io.Reader) []string {
func scanLines(r io.Reader) ([]string, error) {
var lines []string
scanner := bufio.NewScanner(r)
scanner.Split(bufio.ScanLines)
@@ -327,16 +347,25 @@ func scanLines(r io.Reader) []string {
lines = append(lines, line)
}
}
return lines
// a line past bufio's 64k cap halts the scan; surface it instead of
// silently dropping that line and everything after it.
return lines, scanner.Err()
}
// calibrate probes a few paths that cannot exist and records the response shapes
// the catch-all hands them. those baselines feed the matcher so a soft-404 200
// (the SPA wildcard) is suppressed before the real run. deterministic by design:
// the probe paths come from the loop index, never a random source.
// (the SPA wildcard) is suppressed before the real run.
func calibrate(m *matcher, baseURL string, client *http.Client) {
m.baselines = baselinesFromProbes(probeCalibration(baseURL, client))
}
// probeCalibration requests calibrationProbes bogus paths and returns their soft
// (non-hard-404) response shapes. paths grow in length (see calibrationSuffix) so a
// path-reflecting catch-all is detectable. deterministic: paths come from the index.
func probeCalibration(baseURL string, client *http.Client) []responseMeta {
probes := make([]responseMeta, 0, calibrationProbes)
for i := 0; i < calibrationProbes; i++ {
probe := baseURL + calibrationPrefix + strconv.Itoa(i)
probe := baseURL + calibrationPrefix + calibrationSuffix(i)
req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, probe, http.NoBody)
if err != nil {
charmlog.Debugf("dirlist: build calibration request: %v", err)
@@ -355,17 +384,51 @@ func calibrate(m *matcher, baseURL string, client *http.Client) {
if meta.status == statusNotFound {
continue
}
if !containsBaseline(m.baselines, meta) {
m.baselines = append(m.baselines, meta)
}
probes = append(probes, meta)
}
return probes
}
// containsBaseline reports whether the shape is already recorded, so repeated
// probes returning the same soft-404 don't bloat the baseline set.
// calibrationSuffix returns the i-th probe suffix. each suffix is unique and longer
// than the last, so a path-reflecting catch-all returns a different size per probe.
func calibrationSuffix(i int) string {
return strconv.Itoa(i) + strings.Repeat("a", i*calibrationPadStep)
}
// baselinesFromProbes reduces raw calibration responses to the soft-404 shapes to
// suppress. probes sharing status/word-count but differing in size are a reflecting
// catch-all, collapsed to one word-count-tolerant baseline (size anySize); others exact.
func baselinesFromProbes(probes []responseMeta) []responseMeta {
type shapeKey struct{ status, words int }
order := make([]shapeKey, 0, len(probes))
sizes := make(map[shapeKey]map[int]struct{})
for _, p := range probes {
k := shapeKey{p.status, p.words}
if sizes[k] == nil {
sizes[k] = make(map[int]struct{})
order = append(order, k)
}
sizes[k][p.size] = struct{}{}
}
baselines := make([]responseMeta, 0, len(order))
for _, k := range order {
size := anySize
if len(sizes[k]) == 1 {
// one stable size for this status/words: keep exact-shape matching.
for s := range sizes[k] {
size = s
}
}
baselines = append(baselines, responseMeta{status: k.status, size: size, words: k.words})
}
return baselines
}
// containsBaseline reports whether meta matches any calibrated soft-404 baseline.
func containsBaseline(baselines []responseMeta, meta responseMeta) bool {
for i := 0; i < len(baselines); i++ {
if baselines[i] == meta {
if baselines[i].matchesBaseline(meta) {
return true
}
}
+136
View File
@@ -13,6 +13,8 @@
package scan
import (
"bufio"
"errors"
"net/http"
"net/http/httptest"
"os"
@@ -358,3 +360,137 @@ func has(set map[string]struct{}, key string) bool {
_, ok := set[key]
return ok
}
func TestScanLines(t *testing.T) {
got, err := scanLines(strings.NewReader("admin\n\nlogin\n"))
if err != nil {
t.Fatalf("scanLines: %v", err)
}
want := []string{"admin", "login"}
if !reflect.DeepEqual(got, want) {
t.Errorf("scanLines = %v, want %v", got, want)
}
}
func TestScanLinesErrorsOnOverlongLine(t *testing.T) {
// a line past bufio's 64k cap must surface as an error, not silently
// truncate the wordlist (dropping that line and everything after it).
huge := strings.Repeat("a", bufio.MaxScanTokenSize+1)
_, err := scanLines(strings.NewReader("first\n" + huge + "\nlast\n"))
if !errors.Is(err, bufio.ErrTooLong) {
t.Fatalf("scanLines err = %v, want bufio.ErrTooLong", err)
}
}
// reflectingWildcardApp serves a catch-all whose body echoes the request path, so
// its size tracks path length; /admin returns a distinct real page.
func reflectingWildcardApp() *httptest.Server {
mux := http.NewServeMux()
mux.HandleFunc("/admin", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("<html><body>admin control panel dashboard credentials</body></html>"))
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/admin" {
return
}
w.Write([]byte("<html><body>page not found: " + r.URL.Path + "</body></html>"))
})
return httptest.NewServer(mux)
}
// a reflecting catch-all hands each path a different size, so exact-shape calibration
// misses it; the varied probe lengths expose it and -ac suppresses the bogus paths.
func TestDirlist_CalibrationSuppressesReflectingWildcard(t *testing.T) {
srv := reflectingWildcardApp()
defer srv.Close()
dir := t.TempDir()
wordlist := filepath.Join(dir, "words.txt")
if err := os.WriteFile(wordlist, []byte("admin\nnope\nbogus\nmissing\n"), 0o600); err != nil {
t.Fatalf("write wordlist: %v", err)
}
results, err := Dirlist("small", srv.URL, 5*time.Second, 3, "", DirlistOptions{
Wordlist: wordlist,
Calibrate: true,
})
if err != nil {
t.Fatalf("Dirlist (-ac): %v", err)
}
got := pathSet(results)
if !has(got, "/admin") {
t.Errorf("real /admin must still surface, got %v", sortedKeys(got))
}
for _, bogus := range []string{"/nope", "/bogus", "/missing"} {
if has(got, bogus) {
t.Errorf("reflecting soft-404 %s should be suppressed by -ac, got %v", bogus, sortedKeys(got))
}
}
}
func TestBaselinesFromProbes(t *testing.T) {
tests := []struct {
name string
probes []responseMeta
want []responseMeta
}{
{
name: "stable catch-all keeps exact shape",
probes: []responseMeta{{status: 200, size: 63, words: 7}, {status: 200, size: 63, words: 7}},
want: []responseMeta{{status: 200, size: 63, words: 7}},
},
{
name: "reflecting catch-all collapses to words-tolerant",
probes: []responseMeta{{status: 200, size: 40, words: 4}, {status: 200, size: 48, words: 4}, {status: 200, size: 56, words: 4}},
want: []responseMeta{{status: 200, size: anySize, words: 4}},
},
{
name: "distinct shapes kept separately",
probes: []responseMeta{{status: 200, size: 40, words: 4}, {status: 301, size: 0, words: 0}},
want: []responseMeta{{status: 200, size: 40, words: 4}, {status: 301, size: 0, words: 0}},
},
{
name: "no probes yields no baseline",
probes: nil,
want: []responseMeta{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := baselinesFromProbes(tt.probes); !reflect.DeepEqual(got, tt.want) {
t.Errorf("baselinesFromProbes(%v) = %v, want %v", tt.probes, got, tt.want)
}
})
}
}
func TestMatchesBaseline_AnySize(t *testing.T) {
tolerant := responseMeta{status: 200, size: anySize, words: 4}
if !tolerant.matchesBaseline(responseMeta{status: 200, size: 999, words: 4}) {
t.Error("anySize baseline should match any size with the same status/words")
}
if tolerant.matchesBaseline(responseMeta{status: 200, size: 999, words: 5}) {
t.Error("anySize baseline must still discriminate on word count")
}
exact := responseMeta{status: 200, size: 42, words: 5}
if exact.matchesBaseline(responseMeta{status: 200, size: 43, words: 5}) {
t.Error("exact baseline should not match a different size")
}
}
func TestCalibrationSuffix_UniqueAndGrowing(t *testing.T) {
prev := -1
seen := make(map[string]struct{})
for i := 0; i < calibrationProbes; i++ {
s := calibrationSuffix(i)
if _, dup := seen[s]; dup {
t.Errorf("suffix %q repeats across probes", s)
}
seen[s] = struct{}{}
if len(s) <= prev {
t.Errorf("suffix %d %q length %d not greater than previous %d", i, s, len(s), prev)
}
prev = len(s)
}
}
+2 -40
View File
@@ -14,7 +14,6 @@ package scan
import (
"context"
"encoding/base64"
"fmt"
"io"
"net/http"
@@ -22,10 +21,10 @@ import (
"strings"
"time"
"github.com/dropalldatabases/sif/internal/fingerprint"
"github.com/dropalldatabases/sif/internal/httpx"
"github.com/dropalldatabases/sif/internal/logger"
"github.com/dropalldatabases/sif/internal/output"
"github.com/twmb/murmur3"
)
// FaviconResult is the computed shodan-style favicon hash plus the pivot query
@@ -42,11 +41,6 @@ type FaviconResult struct {
// stream forever.
const faviconBodyReadCap = 1 << 20
// 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
// faviconLinkRegex pulls the href off a <link rel="...icon..."> tag so we can
// fall back to a declared icon when /favicon.ico is absent.
var faviconLinkRegex = regexp.MustCompile(`(?i)<link[^>]+rel=["'][^"']*icon[^"']*["'][^>]*>`)
@@ -95,7 +89,7 @@ func Favicon(targetURL string, timeout time.Duration, logdir string) (*FaviconRe
return nil, nil //nolint:nilnil // a missing favicon is not an error
}
hash := FaviconHash(data)
hash := fingerprint.FaviconHash(data)
result := &FaviconResult{
FaviconURL: iconURL,
Hash: hash,
@@ -216,38 +210,6 @@ func resolveFaviconURL(base, href string) string {
return base + "/" + href
}
// 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. the chunking and the sign
// are both load-bearing - shodan stores the value python's mmh3.hash() returns,
// which is signed, over the wrapped base64, not the raw bytes. the golden test
// pins this exactly.
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())
}
// ResultType identifies favicon findings for the result registry.
func (r *FaviconResult) ResultType() string { return "favicon" }
+68
View File
@@ -0,0 +1,68 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package scan
import (
"path/filepath"
"strings"
"testing"
"github.com/dropalldatabases/sif/internal/modules"
)
// favicon demo modules must reference a hash from faviconHashes that names the
// service in their filename, so a demo cannot drift from the scanner's map.
func TestFaviconDemoModulesMatchCanonicalMap(t *testing.T) {
matches, err := filepath.Glob("../../modules/info/favicon-*.yaml")
if err != nil {
t.Fatal(err)
}
if len(matches) == 0 {
t.Skip("no favicon demo modules present")
}
for _, path := range matches {
t.Run(filepath.Base(path), func(t *testing.T) {
def, err := modules.ParseYAMLModule(path)
if err != nil {
t.Fatalf("parse: %v", err)
}
if def.HTTP == nil {
t.Fatal("favicon demo is not an http module")
}
var hashes []int64
for _, m := range def.HTTP.Matchers {
if m.Type == "favicon" {
hashes = append(hashes, m.Hash...)
}
}
if len(hashes) == 0 {
t.Fatal("no favicon hash in module")
}
service := strings.TrimSuffix(strings.TrimPrefix(filepath.Base(path), "favicon-"), ".yaml")
for _, h := range hashes {
// hashes are range-checked at parse, so int32(h) is the canonical fold.
tech, ok := faviconHashes[int32(h)]
if !ok {
t.Errorf("hash %d is absent from faviconHashes; demo references a hash the scanner does not know", h)
continue
}
if !strings.Contains(strings.ToLower(tech), service) {
t.Errorf("hash %d maps to %q, but the file names service %q", h, tech, service)
}
}
})
}
}
-42
View File
@@ -31,48 +31,6 @@ var goldenFaviconBytes = []byte(strings.Repeat("sif-favicon-golden-test-bytes-",
// signedness regress, this number changes and the 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 TestFaviconHash_Golden(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) {
got := FaviconHash(tt.in)
if got != tt.want {
t.Errorf("FaviconHash = %d, want %d", got, tt.want)
}
})
}
}
// TestFaviconBase64Chunking pins the encode step against python's
// base64.encodebytes: a 50-byte input encodes to >76 base64 chars, so it must
// wrap into two newline-terminated lines.
func TestFaviconBase64Chunking(t *testing.T) {
in := []byte(strings.Repeat("A", 60)) // 60 bytes -> 80 base64 chars -> two lines
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)
}
}
// fixtureFaviconServer serves the golden bytes at /favicon.ico.
func fixtureFaviconServer() *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+193
View File
@@ -0,0 +1,193 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
/*
BSD 3-Clause License
(c) 2022-2026 vmfunc, xyzeva & contributors
*/
package frameworks
import (
"fmt"
"math"
"net/http"
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
charmlog "github.com/charmbracelet/log"
"github.com/dropalldatabases/sif/internal/output"
"gopkg.in/yaml.v3"
)
// customDetector is a Detector defined in a user yaml file rather than compiled
// in. it scores with the same weighted signature match as the built-ins and
// optionally pulls a version out of the body.
type customDetector struct {
BaseDetector
versionRe *regexp.Regexp
versionGroup int
}
// Detect returns the weighted signature confidence and, when a version regex is
// set and matches, the captured version. confidence is the matched-weight
// fraction directly (not the built-ins' sigmoid), so it clears 0.5 only past half.
func (d *customDetector) Detect(body string, headers http.Header) (float32, string) {
confidence := d.MatchSignatures(body, headers)
if confidence == 0 || d.versionRe == nil {
return confidence, ""
}
matches := d.versionRe.FindStringSubmatch(body)
if len(matches) > d.versionGroup {
return confidence, matches[d.versionGroup]
}
return confidence, ""
}
// signatureSpec / versionSpec / customDetectorSpec mirror the yaml on disk.
type signatureSpec struct {
Pattern string `yaml:"pattern"`
Weight float32 `yaml:"weight"`
Header bool `yaml:"header"`
}
type versionSpec struct {
Regex string `yaml:"regex"`
Group int `yaml:"group"`
}
type customDetectorSpec struct {
Name string `yaml:"name"`
Signatures []signatureSpec `yaml:"signatures"`
Version *versionSpec `yaml:"version"`
}
// build validates the parsed spec and turns it into a Detector, so a broken
// file fails loudly instead of registering a detector that can never match.
func (spec customDetectorSpec) build() (Detector, error) {
name := strings.TrimSpace(spec.Name)
if name == "" {
return nil, fmt.Errorf("missing name")
}
if len(spec.Signatures) == 0 {
return nil, fmt.Errorf("%q has no signatures", name)
}
sigs := make([]Signature, 0, len(spec.Signatures))
for i, s := range spec.Signatures {
if s.Pattern == "" {
return nil, fmt.Errorf("%q: signature %d has an empty pattern", name, i+1)
}
if s.Weight <= 0 || math.IsInf(float64(s.Weight), 0) || math.IsNaN(float64(s.Weight)) {
return nil, fmt.Errorf("%q: signature %q needs a positive, finite weight", name, s.Pattern)
}
sigs = append(sigs, Signature{Pattern: s.Pattern, Weight: s.Weight, HeaderOnly: s.Header})
}
d := &customDetector{BaseDetector: NewBaseDetector(name, sigs)}
if spec.Version != nil {
if spec.Version.Group < 0 {
return nil, fmt.Errorf("%q: version group must be >= 0", name)
}
re, err := regexp.Compile(spec.Version.Regex)
if err != nil {
return nil, fmt.Errorf("%q: version regex: %w", name, err)
}
d.versionRe = re
d.versionGroup = spec.Version.Group
}
return d, nil
}
// parseCustomDetector reads and validates one signature file.
func parseCustomDetector(path string) (Detector, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read: %w", err)
}
var spec customDetectorSpec
if err := yaml.Unmarshal(data, &spec); err != nil {
return nil, fmt.Errorf("parse: %w", err)
}
return spec.build()
}
// customSignaturesDir is the per-user directory that holds yaml-defined
// detectors, alongside the user modules directory.
func customSignaturesDir() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
if runtime.GOOS == "windows" {
return filepath.Join(home, "AppData", "Local", "sif", "signatures"), nil
}
return filepath.Join(home, ".config", "sif", "signatures"), nil
}
// loadCustomDetectors registers every signature file under the user directory.
// it is driven once, lazily, from DetectFramework.
func loadCustomDetectors() {
dir, err := customSignaturesDir()
if err != nil {
return
}
loadCustomDetectorsFromDir(dir)
}
// loadCustomDetectorsFromDir registers every signature file in dir and returns
// how many loaded. a custom detector whose name matches a built-in overrides
// it, matching the user-module convention.
func loadCustomDetectorsFromDir(dir string) int {
detectors := collectCustomDetectors(dir)
for _, d := range detectors {
Register(d)
}
if len(detectors) > 0 {
output.Module("FRAMEWORK").Info("Loaded %d custom signatures", len(detectors))
}
return len(detectors)
}
// collectCustomDetectors parses (without registering) the .yaml/.yml detectors
// in dir, so discovery and validation stay pure and testable. a missing dir is
// fine; an unparseable file warns and is skipped rather than failing the scan.
func collectCustomDetectors(dir string) []Detector {
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var detectors []Detector
for _, e := range entries {
if e.IsDir() {
continue
}
switch filepath.Ext(e.Name()) {
case ".yaml", ".yml":
default:
continue
}
d, err := parseCustomDetector(filepath.Join(dir, e.Name()))
if err != nil {
charmlog.Warnf("custom signature %s: %v", e.Name(), err)
continue
}
detectors = append(detectors, d)
}
return detectors
}
@@ -0,0 +1,173 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package frameworks
import (
"math"
"net/http"
"os"
"path/filepath"
"testing"
)
func TestCustomDetectorSpecBuild(t *testing.T) {
valid := customDetectorSpec{
Name: "Ghost",
Signatures: []signatureSpec{{Pattern: `content="Ghost`, Weight: 1.0}},
}
cases := []struct {
name string
spec customDetectorSpec
wantErr bool
}{
{"valid", valid, false},
{"empty name", customDetectorSpec{Signatures: valid.Signatures}, true},
{"whitespace name", customDetectorSpec{Name: " ", Signatures: valid.Signatures}, true},
{"no signatures", customDetectorSpec{Name: "X"}, true},
{"empty pattern", customDetectorSpec{Name: "X", Signatures: []signatureSpec{{Pattern: "", Weight: 1}}}, true},
{"zero weight", customDetectorSpec{Name: "X", Signatures: []signatureSpec{{Pattern: "p", Weight: 0}}}, true},
{"negative weight", customDetectorSpec{Name: "X", Signatures: []signatureSpec{{Pattern: "p", Weight: -1}}}, true},
{"bad version regex", customDetectorSpec{Name: "X", Signatures: []signatureSpec{{Pattern: "p", Weight: 1}}, Version: &versionSpec{Regex: "("}}, true},
{"negative version group", customDetectorSpec{Name: "X", Signatures: valid.Signatures, Version: &versionSpec{Regex: `v([0-9]+)`, Group: -1}}, true},
{"nan weight", customDetectorSpec{Name: "X", Signatures: []signatureSpec{{Pattern: "p", Weight: float32(math.NaN())}}}, true},
{"inf weight", customDetectorSpec{Name: "X", Signatures: []signatureSpec{{Pattern: "p", Weight: float32(math.Inf(1))}}}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if _, err := tc.spec.build(); (err != nil) != tc.wantErr {
t.Fatalf("build() err = %v, wantErr = %v", err, tc.wantErr)
}
})
}
}
func TestCustomDetectorDetect(t *testing.T) {
// the version regex is independent of the signature patterns so a body that
// matches it but no signature still must not surface a version.
spec := customDetectorSpec{
Name: "Acme",
Signatures: []signatureSpec{
{Pattern: "AcmeCMS", Weight: 0.6},
{Pattern: "X-Acme", Weight: 0.4, Header: true},
},
Version: &versionSpec{Regex: `ver=([0-9.]+)`, Group: 1},
}
d, err := spec.build()
if err != nil {
t.Fatal(err)
}
withHeader := func() http.Header {
h := http.Header{}
h.Set("X-Acme", "1")
return h
}
t.Run("all signatures match: confidence 1, version extracted", func(t *testing.T) {
conf, ver := d.Detect("powered by AcmeCMS ver=4.2.0", withHeader())
if conf != 1.0 {
t.Errorf("confidence = %v, want 1.0", conf)
}
if ver != "4.2.0" {
t.Errorf("version = %q, want 4.2.0", ver)
}
})
t.Run("only body signature matches: linear 0.6", func(t *testing.T) {
conf, ver := d.Detect("powered by AcmeCMS", http.Header{})
if conf != 0.6 {
t.Errorf("confidence = %v, want 0.6 (0.6/1.0 matched fraction)", conf)
}
if ver != "" {
t.Errorf("version = %q, want empty", ver)
}
})
t.Run("no signature matches: 0 confidence, no version even when present", func(t *testing.T) {
conf, ver := d.Detect("ver=9.9.9 but no marker here", http.Header{})
if conf != 0 {
t.Errorf("confidence = %v, want 0", conf)
}
if ver != "" {
t.Errorf("version = %q, want empty (not detected, so not extracted)", ver)
}
})
}
func TestParseCustomDetectorFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "fw.yaml")
content := `name: Parsed
signatures:
- pattern: "Marker"
weight: 0.5
- pattern: "X-Hdr"
weight: 0.5
header: true
version:
regex: 'Parsed/([0-9.]+)'
group: 1
`
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
d, err := parseCustomDetector(path)
if err != nil {
t.Fatal(err)
}
if d.Name() != "Parsed" {
t.Errorf("name = %q, want Parsed", d.Name())
}
if len(d.Signatures()) != 2 {
t.Errorf("signatures = %d, want 2", len(d.Signatures()))
}
h := http.Header{}
h.Set("X-Hdr", "1")
conf, ver := d.Detect("Marker Parsed/3.1", h)
if conf != 1.0 {
t.Errorf("confidence = %v, want 1.0", conf)
}
if ver != "3.1" {
t.Errorf("version = %q, want 3.1", ver)
}
}
func TestCollectCustomDetectors(t *testing.T) {
dir := t.TempDir()
write := func(name, content string) {
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}
write("good.yaml", "name: ZZCustomTest\nsignatures:\n - pattern: \"ZZCustomMarker\"\n weight: 1.0\n")
write("bad.yaml", "name: \"\"\nsignatures: []\n") // invalid: skipped with a warning
write("ignore.txt", "not a signature file") // wrong extension: ignored
got := collectCustomDetectors(dir)
if len(got) != 1 {
t.Fatalf("collected %d detectors, want 1 (good.yaml only)", len(got))
}
if got[0].Name() != "ZZCustomTest" {
t.Errorf("detector name = %q, want ZZCustomTest", got[0].Name())
}
if conf, _ := got[0].Detect("page with ZZCustomMarker", http.Header{}); conf != 1.0 {
t.Errorf("confidence = %v, want 1.0", conf)
}
}
func TestCollectCustomDetectorsMissingDir(t *testing.T) {
if got := collectCustomDetectors(filepath.Join(t.TempDir(), "nope")); got != nil {
t.Errorf("missing dir should yield nil, got %v", got)
}
}
+6
View File
@@ -40,8 +40,14 @@ type detectionResult struct {
version string
}
// loadCustomOnce loads the user signature directory the first time a scan runs,
// so config-defined detectors join the registry without a per-target re-read.
var loadCustomOnce sync.Once
// DetectFramework runs all registered detectors against the target URL.
func DetectFramework(url string, timeout time.Duration, logdir string) (*FrameworkResult, error) {
loadCustomOnce.Do(loadCustomDetectors)
log := output.Module("FRAMEWORK")
log.Start()
+618
View File
@@ -186,6 +186,27 @@ func TestDetectFramework_ASPNET(t *testing.T) {
}
}
func TestDetectFramework_ASPNETPoweredByHeader(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-AspNetMvc-Version", "5.2")
w.Header().Set("X-Powered-By", "ASP.NET")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`<!DOCTYPE html><html><body><a href="/home/index.aspx">home</a></body></html>`))
}))
defer server.Close()
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil {
t.Fatal("expected result, got nil")
}
if result.Name != "ASP.NET" {
t.Errorf("expected framework 'ASP.NET', got '%s'", result.Name)
}
}
func TestDetectFramework_NoMatch(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
@@ -424,6 +445,108 @@ func TestDetectFramework_Joomla(t *testing.T) {
}
}
func TestDetectFramework_AdonisJS(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Set-Cookie", "adonis-session=s%3Aabc.def; Path=/; HttpOnly")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`<!DOCTYPE html><html><body>Welcome</body></html>`))
}))
defer server.Close()
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil {
t.Fatal("expected result, got nil")
}
if result.Name != "AdonisJS" {
t.Errorf("expected framework 'AdonisJS', got '%s'", result.Name)
}
}
func TestDetectFramework_AdonisFalsePositive(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`
<!DOCTYPE html>
<html>
<head>
<title>Adonis Cosmetics</title>
<link rel="stylesheet" href="/assets/adonis-theme.css">
</head>
<body class="adonis-store">
<h1>Adonis Cosmetics</h1>
<a href="/adonis/collections">Shop the adonis collection</a>
</body>
</html>
`))
}))
defer server.Close()
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != nil && result.Name == "AdonisJS" {
t.Errorf("false positive: plain page mentioning 'Adonis' detected as AdonisJS (%.2f)", result.Confidence)
}
}
func TestDetectFramework_Phoenix(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`
<!DOCTYPE html>
<html>
<head><title>Phoenix App</title></head>
<body>
<div data-phx-main data-phx-session="abc" data-phx-static="def" id="phx-F1a2B3">
<span>Content</span>
</div>
</body>
</html>
`))
}))
defer server.Close()
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil {
t.Fatal("expected result, got nil")
}
if result.Name != "Phoenix" {
t.Errorf("expected framework 'Phoenix', got '%s'", result.Name)
}
}
func TestDetectFramework_PhoenixFalsePositive(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`
<!DOCTYPE html>
<html>
<head><title>Phoenix AZ Roofing</title></head>
<body class="phx-page">
<nav class="phx-nav"><a href="/">Phoenix Home</a></nav>
<section class="phx-hero">Serving Phoenix, Arizona since 1998.</section>
</body>
</html>
`))
}))
defer server.Close()
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != nil && result.Name == "Phoenix" {
t.Errorf("false positive: phx- CSS class page detected as Phoenix (%.2f)", result.Confidence)
}
}
func TestDetectFramework_Astro(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
@@ -461,6 +584,82 @@ func TestDetectFramework_Astro(t *testing.T) {
}
}
func TestDetectFramework_Ghost(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`
<!DOCTYPE html>
<html>
<head>
<meta name="generator" content="Ghost 6.46">
</head>
<body>Content</body>
</html>
`))
}))
defer server.Close()
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil {
t.Fatal("expected result, got nil")
}
if result.Name != "Ghost" {
t.Errorf("expected framework 'Ghost', got '%s'", result.Name)
}
}
func TestDetectFramework_GhostButtonNoMatch(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`
<!DOCTYPE html>
<html>
<body>
<a class="ghost-button" href="/signup">Sign up</a>
</body>
</html>
`))
}))
defer server.Close()
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != nil && result.Name == "Ghost" {
t.Errorf("expected no Ghost detection for a ghost-button page, got confidence %.2f", result.Confidence)
}
}
func TestDetectFramework_GhostAPIPath(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`
<!DOCTYPE html>
<html>
<body>
<script src="/ghost/api/content/posts/?key=abc"></script>
</body>
</html>
`))
}))
defer server.Close()
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil {
t.Fatal("expected result, got nil")
}
if result.Name != "Ghost" {
t.Errorf("expected framework 'Ghost', got '%s'", result.Name)
}
}
func TestExtractVersion_Astro(t *testing.T) {
tests := []struct {
body string
@@ -518,3 +717,422 @@ func TestDetectorRegistry(t *testing.T) {
}
}
}
func TestExtractVersion_Htmx(t *testing.T) {
tests := []struct {
body string
expected string
}{
{`<script src="https://unpkg.com/htmx.org@1.9.10"></script>`, "1.9.10"},
{`https://cdn.jsdelivr.net/npm/htmx@2.0.3/dist/htmx.min.js`, "2.0.3"},
{`"htmx.org": "^1.9.12"`, "1.9.12"},
{"no version", "unknown"},
}
for _, tt := range tests {
result := frameworks.ExtractVersionOptimized(tt.body, "htmx").Version
if result != tt.expected {
t.Errorf("ExtractVersionOptimized(%q, 'htmx') = %q, want %q", tt.body, result, tt.expected)
}
}
}
func TestDetectFramework_Htmx(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`
<!DOCTYPE html>
<html>
<head><script src="https://unpkg.com/htmx.org@1.9.10"></script></head>
<body>
<button hx-get="/clicked" hx-target="#out" hx-swap="outerHTML">Click</button>
<form hx-post="/submit" hx-boost="true"></form>
<div id="out"></div>
</body>
</html>
`))
}))
defer server.Close()
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil {
t.Fatal("expected result, got nil")
}
if result.Name != "htmx" {
t.Errorf("expected framework 'htmx', got '%s'", result.Name)
}
if result.Version != "1.9.10" {
t.Errorf("expected version '1.9.10', got '%s'", result.Version)
}
}
func TestDetectFramework_GinFalsePositive(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`<!DOCTYPE html><html><body>Welcome</body></html>`))
}))
defer server.Close()
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != nil && result.Name == "Gin" {
t.Errorf("false positive: detected Gin (confidence %.2f) on a CORS header", result.Confidence)
}
}
func TestDetectFramework_Gin(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNotFound)
w.Write([]byte(`404 page not found - powered by gin-gonic`))
}))
defer server.Close()
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil || result.Name != "Gin" {
t.Errorf("expected framework 'Gin', got '%v'", result)
}
}
func TestDetectFramework_MeteorFalsePositive(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`<!DOCTYPE html><html><body><p>a meteor shower lit the sky while
meteorology students tracked the meteorite.</p></body></html>`))
}))
defer server.Close()
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != nil && result.Name == "Meteor" {
t.Errorf("false positive: detected Meteor (confidence %.2f) on prose about meteors", result.Confidence)
}
}
func TestDetectFramework_Meteor(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`<!DOCTYPE html><html><head>
<script>__meteor_runtime_config__ = JSON.parse(decodeURIComponent("%7B%7D"));</script>
</head><body><div id="app"></div></body></html>`))
}))
defer server.Close()
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil || result.Name != "Meteor" {
t.Errorf("expected framework 'Meteor', got '%v'", result)
}
}
func TestDetectFramework_BackboneFalsePositive(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`<!DOCTYPE html><html><body><p>our team is the backbone of the
company, the backbone network that keeps everything running.</p></body></html>`))
}))
defer server.Close()
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != nil && result.Name == "Backbone.js" {
t.Errorf("false positive: detected Backbone.js (confidence %.2f) on prose about backbones", result.Confidence)
}
}
func TestDetectFramework_Backbone(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`<!DOCTYPE html><html><head><script src="/js/backbone.js"></script></head>
<body><script>var AppView = Backbone.View.extend({});</script></body></html>`))
}))
defer server.Close()
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil || result.Name != "Backbone.js" {
t.Errorf("expected framework 'Backbone.js', got '%v'", result)
}
}
func TestDetectFramework_CakePHPFalsePositive(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`<!DOCTYPE html><html><body><p>our cupcake and cheesecake recipes,
plus the best pancake stack in town.</p></body></html>`))
}))
defer server.Close()
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != nil && result.Name == "CakePHP" {
t.Errorf("false positive: detected CakePHP (confidence %.2f) on prose about cakes", result.Confidence)
}
}
func TestDetectFramework_CakePHP(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Set-Cookie", "CAKEPHP=abc123; path=/")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`<!DOCTYPE html><html><body>Home</body></html>`))
}))
defer server.Close()
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil || result.Name != "CakePHP" {
t.Errorf("expected framework 'CakePHP', got '%v'", result)
}
}
func TestDetectFramework_SvelteFalsePositive(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`<!DOCTYPE html><html><body><p>the model cut a svelte figure on
the runway.</p></body></html>`))
}))
defer server.Close()
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != nil && result.Name == "Svelte" {
t.Errorf("false positive: detected Svelte (confidence %.2f) on prose with 'svelte'", result.Confidence)
}
}
func TestDetectFramework_StrapiFalsePositive(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`<!DOCTYPE html><html><body><script>fetch("/api/v1/users")</script></body></html>`))
}))
defer server.Close()
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != nil && result.Name == "Strapi" {
t.Errorf("false positive: detected Strapi (confidence %.2f) on a plain /api/ path", result.Confidence)
}
}
func TestDetectFramework_Strapi(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`<!DOCTYPE html><html><body><div>powered by strapi</div></body></html>`))
}))
defer server.Close()
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil || result.Name != "Strapi" {
t.Errorf("expected framework 'Strapi', got '%v'", result)
}
}
func TestDetectFramework_Ember(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`<!DOCTYPE html><html><head><title>Ember App</title></head>
<body class="ember-application"><div id="ember123" class="ember-view">Content</div>
<script src="/assets/vendor.js"></script></body></html>`))
}))
defer server.Close()
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil || result.Name != "Ember.js" {
t.Errorf("expected framework 'Ember.js', got '%v'", result)
}
}
func TestDetectFramework_EmberFalsePositive(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`<!DOCTYPE html><html><head><title>Day of the Dead</title></head>
<body><p>a celebratory holiday to remember the dead; families remember departed
members every November and September.</p></body></html>`))
}))
defer server.Close()
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != nil && result.Name == "Ember.js" {
t.Errorf("false positive: detected Ember.js (confidence %.2f) on prose with 'remember'", result.Confidence)
}
}
func TestDetectFramework_Shopify(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Powered-By", "Shopify")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`
<!DOCTYPE html>
<html>
<head><link rel="stylesheet" href="https://cdn.shopify.com/s/files/1/theme.css"></head>
<body>
<div id="shopify-section-header" class="shopify-section">Store</div>
</body>
</html>
`))
}))
defer server.Close()
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil {
t.Fatal("expected result, got nil")
}
if result.Name != "Shopify" {
t.Errorf("expected framework 'Shopify', got '%s'", result.Name)
}
}
func TestDetectFramework_ShopifyFalsePositive(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`
<!DOCTYPE html>
<html>
<head><title>10 Best Shopify Alternatives in 2026</title></head>
<body>
<h1>Is Shopify Right For You?</h1>
<p>We compare Shopify with other e-commerce platforms.</p>
</body>
</html>
`))
}))
defer server.Close()
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != nil && result.Name == "Shopify" {
t.Errorf("false positive: article mentioning Shopify detected as Shopify (%.2f)", result.Confidence)
}
}
func TestDetectFramework_SpringBoot(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`<html><body><h1>Whitelabel Error Page</h1>` +
`<p>This application has no explicit mapping for /error, so you are seeing this as a fallback.</p>` +
`<div>There was an unexpected error (type=Internal Server Error, status=500).</div>` +
`</body></html>`))
}))
defer server.Close()
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil {
t.Fatal("expected result, got nil")
}
if result.Name != "Spring Boot" {
t.Errorf("expected framework 'Spring Boot', got '%s'", result.Name)
}
}
func TestDetectFramework_SpringBootFalsePositive(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`
<!DOCTYPE html>
<html>
<body>
<h1>Getting started with spring-boot</h1>
<p>Add spring-boot-starter-web to your pom.xml and run the app.</p>
<a href="https://spring.io/projects/spring-boot">spring.io</a>
</body>
</html>
`))
}))
defer server.Close()
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != nil && result.Name == "Spring Boot" {
t.Errorf("expected no Spring Boot match for prose mentioning it, got %.2f confidence", result.Confidence)
}
}
func TestDetectFramework_CodeIgniter(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Set-Cookie", "ci_session=a1b2c3d4e5; path=/; HttpOnly")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`<!DOCTYPE html><html><body><h1>My Shop</h1></body></html>`))
}))
defer server.Close()
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result == nil {
t.Fatal("expected result, got nil")
}
if result.Name != "CodeIgniter" {
t.Errorf("expected framework 'CodeIgniter', got '%s'", result.Name)
}
}
func TestDetectFramework_CodeIgniterFalsePositive(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`
<!DOCTYPE html>
<html>
<body>
<h1>Best PHP frameworks in 2026</h1>
<p>Laravel and codeigniter both ship a router and an ORM.</p>
<a href="https://codeigniter.com">codeigniter.com</a>
<pre>composer create-project codeigniter4/appstarter</pre>
</body>
</html>
`))
}))
defer server.Close()
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result != nil && result.Name == "CodeIgniter" {
t.Errorf("expected no CodeIgniter match for prose mentioning it, got %.2f confidence", result.Confidence)
}
}
+8 -13
View File
@@ -177,7 +177,6 @@ func (d *aspnetDetector) Signatures() []fw.Signature {
{Pattern: ".ashx", Weight: 0.2},
{Pattern: ".asmx", Weight: 0.2},
{Pattern: "asp.net_sessionid", Weight: 0.4, HeaderOnly: true},
{Pattern: "X-Powered-By: ASP.NET", Weight: 0.4, HeaderOnly: true},
}
}
@@ -252,9 +251,9 @@ func (d *springBootDetector) Name() string { return "Spring Boot" }
func (d *springBootDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "spring-boot", Weight: 0.5},
{Pattern: "actuator", Weight: 0.3},
{Pattern: "whitelabel", Weight: 0.2},
{Pattern: "Whitelabel Error Page", Weight: 0.5},
{Pattern: "This application has no explicit mapping for /error", Weight: 0.4},
{Pattern: "There was an unexpected error (type=", Weight: 0.3},
}
}
@@ -352,7 +351,6 @@ func (d *ginDetector) Name() string { return "Gin" }
func (d *ginDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "gin-gonic", Weight: 0.4},
{Pattern: "gin", Weight: 0.2, HeaderOnly: true},
}
}
@@ -375,9 +373,9 @@ func (d *phoenixDetector) Name() string { return "Phoenix" }
func (d *phoenixDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "_csrf_token", Weight: 0.4, HeaderOnly: true},
{Pattern: "phx-", Weight: 0.3},
{Pattern: "phoenix", Weight: 0.2},
{Pattern: "data-phx-main", Weight: 0.4},
{Pattern: "data-phx-session", Weight: 0.3},
{Pattern: "data-phx-static", Weight: 0.3},
}
}
@@ -401,7 +399,6 @@ func (d *strapiDetector) Name() string { return "Strapi" }
func (d *strapiDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "strapi", Weight: 0.4},
{Pattern: "/api/", Weight: 0.2},
}
}
@@ -424,8 +421,7 @@ func (d *adonisDetector) Name() string { return "AdonisJS" }
func (d *adonisDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "adonis", Weight: 0.4},
{Pattern: "_csrf", Weight: 0.2, HeaderOnly: true},
{Pattern: "adonis-session", Weight: 0.4, HeaderOnly: true},
}
}
@@ -449,7 +445,7 @@ func (d *cakephpDetector) Name() string { return "CakePHP" }
func (d *cakephpDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "cakephp", Weight: 0.4},
{Pattern: "cake", Weight: 0.2},
{Pattern: "CAKEPHP", Weight: 0.4, HeaderOnly: true},
}
}
@@ -472,7 +468,6 @@ func (d *codeigniterDetector) Name() string { return "CodeIgniter" }
func (d *codeigniterDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "codeigniter", Weight: 0.4},
{Pattern: "ci_session", Weight: 0.4, HeaderOnly: true},
}
}
+2 -2
View File
@@ -147,7 +147,7 @@ func (d *shopifyDetector) Name() string { return "Shopify" }
func (d *shopifyDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "Shopify", Weight: 0.5},
{Pattern: "Shopify", Weight: 0.5, HeaderOnly: true},
{Pattern: "cdn.shopify.com", Weight: 0.4},
{Pattern: "shopify-section", Weight: 0.4},
{Pattern: "myshopify.com", Weight: 0.3},
@@ -173,7 +173,7 @@ func (d *ghostDetector) Name() string { return "Ghost" }
func (d *ghostDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "ghost-", Weight: 0.4},
{Pattern: `<meta name="generator" content="Ghost`, Weight: 0.4},
{Pattern: "Ghost", Weight: 0.3, HeaderOnly: true},
{Pattern: "/ghost/api/", Weight: 0.4},
}
+45 -9
View File
@@ -34,6 +34,7 @@ func init() {
fw.Register(&emberDetector{})
fw.Register(&backboneDetector{})
fw.Register(&meteorDetector{})
fw.Register(&htmxDetector{})
}
// reactDetector detects React framework.
@@ -128,9 +129,9 @@ func (d *svelteDetector) Name() string { return "Svelte" }
func (d *svelteDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "svelte", Weight: 0.4},
{Pattern: "__svelte", Weight: 0.5},
{Pattern: "svelte-", Weight: 0.3},
{Pattern: "svelte-", Weight: 0.4},
{Pattern: "svelte/internal", Weight: 0.4},
}
}
@@ -153,9 +154,12 @@ func (d *emberDetector) Name() string { return "Ember.js" }
func (d *emberDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "ember", Weight: 0.4},
{Pattern: "ember-cli", Weight: 0.4},
{Pattern: "data-ember", Weight: 0.3},
{Pattern: "ember-application", Weight: 0.5},
{Pattern: "ember-view", Weight: 0.4},
{Pattern: "ember.js", Weight: 0.4},
{Pattern: "ember.min.js", Weight: 0.4},
{Pattern: "ember-cli", Weight: 0.3},
{Pattern: `id="ember`, Weight: 0.4},
}
}
@@ -178,8 +182,11 @@ func (d *backboneDetector) Name() string { return "Backbone.js" }
func (d *backboneDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "backbone", Weight: 0.4},
{Pattern: "Backbone.", Weight: 0.4},
{Pattern: "Backbone.Model", Weight: 0.4},
{Pattern: "Backbone.View", Weight: 0.4},
{Pattern: "Backbone.Router", Weight: 0.4},
{Pattern: "backbone.js", Weight: 0.4},
{Pattern: "backbone-min.js", Weight: 0.4},
}
}
@@ -195,6 +202,34 @@ func (d *backboneDetector) Detect(body string, headers http.Header) (float32, st
return confidence, version
}
// htmxDetector detects the htmx library.
type htmxDetector struct{}
func (d *htmxDetector) Name() string { return "htmx" }
func (d *htmxDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "hx-get", Weight: 0.5},
{Pattern: "hx-post", Weight: 0.5},
{Pattern: "hx-swap", Weight: 0.4},
{Pattern: "hx-target", Weight: 0.4},
{Pattern: "hx-boost", Weight: 0.4},
{Pattern: "htmx.org", Weight: 0.5},
}
}
func (d *htmxDetector) Detect(body string, headers http.Header) (float32, string) {
base := fw.NewBaseDetector(d.Name(), d.Signatures())
score := base.MatchSignatures(body, headers)
confidence := sigmoidConfidence(score)
var version string
if confidence > 0.5 {
version = fw.ExtractVersionOptimized(body, d.Name()).Version
}
return confidence, version
}
// meteorDetector detects Meteor framework.
type meteorDetector struct{}
@@ -202,8 +237,9 @@ func (d *meteorDetector) Name() string { return "Meteor" }
func (d *meteorDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "__meteor_runtime_config__", Weight: 0.5},
{Pattern: "meteor", Weight: 0.3},
{Pattern: "__meteor_runtime_config__", Weight: 0.6},
{Pattern: "Meteor.startup", Weight: 0.3},
{Pattern: "/packages/meteor", Weight: 0.3},
}
}
+4
View File
@@ -107,6 +107,10 @@ func init() {
"SvelteKit": {
{`"@sveltejs/kit":\s*"[~^]?(\d+\.\d+(?:\.\d+)?)"`, 0.85, "package.json"},
},
"htmx": {
{`htmx(?:\.org)?@(\d+\.\d+(?:\.\d+)?)`, 0.85, "CDN reference"},
{`"htmx\.org":\s*"[~^]?(\d+\.\d+(?:\.\d+)?)"`, 0.85, "package.json"},
},
"WordPress": {
{`<meta name="generator" content="WordPress (\d+\.\d+(?:\.\d+)?)"`, 0.95, "generator meta"},
{`WordPress (\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
+1 -1
View File
@@ -181,7 +181,7 @@ func TestIntegrationSQL(t *testing.T) {
srv := newVulnApp()
defer srv.Close()
result, err := SQL(srv.URL, 5*time.Second, 5, "")
result, err := SQL(srv.URL, 5*time.Second, 5, "", false)
if err != nil {
t.Fatalf("SQL: %v", err)
}
+12 -7
View File
@@ -23,9 +23,9 @@
package frameworks
import (
"bufio"
"context"
"fmt"
"io"
"net/http"
"regexp"
"strings"
@@ -37,6 +37,10 @@ import (
// nextPagesRegex matches JavaScript file references in Next.js build manifest.
var nextPagesRegex = regexp.MustCompile(`\[("([^"]+\.js)"(,?))`)
// maxManifestSize caps the build manifest read so a huge or hostile file
// cannot exhaust memory.
const maxManifestSize = 5 * 1024 * 1024
func GetPagesRouterScripts(scriptUrl string) ([]string, error) {
baseUrl, err := urlutil.Parse(scriptUrl)
if err != nil {
@@ -58,13 +62,14 @@ func GetPagesRouterScripts(scriptUrl string) ([]string, error) {
}
defer resp.Body.Close()
var sb strings.Builder
scanner := bufio.NewScanner(resp.Body)
scanner.Split(bufio.ScanLines)
for scanner.Scan() {
sb.WriteString(scanner.Text())
body, err := io.ReadAll(io.LimitReader(resp.Body, maxManifestSize))
if err != nil {
fmt.Println(err)
return nil, err
}
manifestText := sb.String()
// the manifest ships minified on one line; strip line breaks so the regex
// treats a (rare) pretty-printed one the same as the minified form.
manifestText := strings.NewReplacer("\r", "", "\n", "").Replace(string(body))
list := nextPagesRegex.FindAllStringSubmatch(manifestText, -1)

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