Compare commits

..

42 Commits

Author SHA1 Message Date
vmfunc 1feb0648b3 ci(pr-bot): run on pull_request_target so fork PRs get labeled
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:20:58 -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
109 changed files with 5216 additions and 116 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)
+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!
+5 -5
View File
@@ -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>
+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
+25 -1
View File
@@ -115,6 +115,18 @@ 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
#### headers
custom headers to send.
@@ -199,6 +211,18 @@ 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
```
### combining matchers
multiple matchers are combined with AND logic by default.
@@ -238,7 +262,7 @@ extractors:
### kv extractor
extract key-value pairs.
record every response header as a key-value pair, namespaced by `name`.
```yaml
extractors:
+18 -17
View File
@@ -4,18 +4,18 @@ 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/rocketlaunchr/google-search v1.1.6
github.com/twmb/murmur3 v1.1.6
golang.org/x/net v0.53.0
github.com/twmb/murmur3 v1.1.8
golang.org/x/net v0.56.0
golang.org/x/time v0.14.0
gopkg.in/yaml.v3 v3.0.1
)
@@ -55,7 +55,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 +80,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 +97,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
@@ -230,7 +230,7 @@ require (
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-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
@@ -250,6 +250,7 @@ require (
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
@@ -377,17 +378,17 @@ require (
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/crypto v0.53.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/mod v0.35.0 // 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.10 // 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
+42 -32
View File
@@ -149,13 +149,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 +212,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=
@@ -259,14 +260,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=
@@ -464,8 +465,9 @@ 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/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=
@@ -727,8 +729,8 @@ github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stg
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-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=
@@ -791,6 +793,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=
@@ -1061,8 +1065,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=
@@ -1234,8 +1239,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=
@@ -1273,8 +1279,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=
@@ -1331,8 +1337,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=
@@ -1361,8 +1368,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=
@@ -1429,8 +1436,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 +1454,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,8 +1475,8 @@ golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.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=
@@ -1522,8 +1531,8 @@ 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 +1559,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=
@@ -1616,8 +1626,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.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/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=
+1 -1
View File
@@ -194,7 +194,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",
@@ -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))
}
})
}
+144
View File
@@ -0,0 +1,144 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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}
got := reqURLs(generateHTTPRequests(target, cfg))
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,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))
}
}
})
}
@@ -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))
}
}
})
}
+60 -14
View File
@@ -149,25 +149,52 @@ func generateHTTPRequests(target string, cfg *HTTPConfig) []*httpRequest {
return requests
}
// Generate requests with payloads
// pitchfork pairs path[i] with payload[i] and stops at the shorter list;
// clusterbomb (default) crosses every path with every payload.
if strings.EqualFold(cfg.Attack, "pitchfork") {
n := len(cfg.Paths)
if len(cfg.Payloads) < n {
n = len(cfg.Payloads)
}
for i := 0; i < n; i++ {
requests = append(requests, newPayloadRequest(method, target, cfg.Paths[i], cfg.Payloads[i], cfg))
}
return requests
}
for _, path := range cfg.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
}
// 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.
func substituteVariables(template, baseURL, payload string) string {
result := template
@@ -266,6 +293,15 @@ func checkMatcher(m *Matcher, resp *http.Response, body string) bool {
case "regex":
return checkRegex(part, m.Regex, m.Condition)
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 +392,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 +406,16 @@ 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, ", ")
}
}
}
+73
View File
@@ -143,6 +143,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 {
@@ -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,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))
}
}
})
}
+2 -2
View File
@@ -339,9 +339,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,
},
+2 -1
View File
@@ -91,6 +91,7 @@ type Matcher struct {
Regex []string `yaml:"regex,omitempty"`
Words []string `yaml:"words,omitempty"`
Status []int `yaml:"status,omitempty"`
Size []int `yaml:"size,omitempty"`
Condition string `yaml:"condition"` // and, or
Negative bool `yaml:"negative"`
}
@@ -98,7 +99,7 @@ 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"`
@@ -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))
}
}
})
}
@@ -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))
}
}
})
}
+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))
}
}
})
}
+7 -1
View File
@@ -58,7 +58,7 @@ type HTTPConfig struct {
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
Attack string `yaml:"attack,omitempty"` // clusterbomb (default), pitchfork
Threads int `yaml:"threads,omitempty"`
Matchers []Matcher `yaml:"matchers"`
Extractors []Extractor `yaml:"extractors,omitempty"`
@@ -100,6 +100,12 @@ 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)
}
}
return &ym, nil
}
+9 -1
View File
@@ -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))
@@ -141,3 +141,11 @@ 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")
}
+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")
}
}
+186
View File
@@ -424,6 +424,113 @@ 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)
}
}
// a cosmetics brand page that merely contains "adonis" in its markup (CSS
// classes, asset paths, links) must not be fingerprinted as AdonisJS, as the
// old bare "adonis" substring signature did.
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)
}
}
// a Phoenix, Arizona business page using "phx-" CSS class prefixes must not be
// fingerprinted as the Phoenix framework, as the old bare "phx-" signature did.
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 +568,85 @@ 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)
}
}
// ghost-button is a common generic CSS class and must not read as Ghost CMS.
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)
}
}
// the /ghost/api/ path is the only Ghost marker left for pages without the
// generator meta, so guard that it still detects on its own.
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
@@ -375,9 +375,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},
}
}
@@ -424,8 +424,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},
}
}
+1 -1
View File
@@ -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},
}
+4 -1
View File
@@ -19,6 +19,7 @@ import (
"net/http"
"net/url"
"regexp"
"strings"
"sync"
"time"
@@ -234,7 +235,9 @@ func LFI(targetURL string, timeout time.Duration, threads int, logdir string) (*
// check for evidence patterns
for _, evidence := range lfiEvidencePatterns {
if evidence.pattern.MatchString(bodyStr) {
match := evidence.pattern.FindString(bodyStr)
// our own payload echoed back isn't proof of inclusion
if match != "" && !strings.Contains(item.payload.payload, match) {
key := item.param + "|" + item.payload.payload
mu.Lock()
if seen[key] {
+51
View File
@@ -13,9 +13,12 @@
package scan
import (
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestDetectLFIFromResponse_EtcPasswd(t *testing.T) {
@@ -314,3 +317,51 @@ func TestLFI_MockServer(t *testing.T) {
t.Errorf("expected status 200, got %d", resp.StatusCode)
}
}
func TestLFI_ReflectedPayloadIsNotEvidence(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for _, vs := range r.URL.Query() {
for _, v := range vs {
fmt.Fprintf(w, "Warning: include(%s): failed to open stream\n", v)
}
}
}))
defer srv.Close()
result, err := LFI(srv.URL, 5*time.Second, 4, "")
if err != nil {
t.Fatalf("LFI: %v", err)
}
if result != nil && len(result.Vulnerabilities) > 0 {
t.Errorf("reflected payload should not be flagged as LFI, got %+v", result.Vulnerabilities)
}
}
func TestLFI_GenuineBase64PHPStillDetected(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Query().Get("file"), "convert.base64-encode") {
// base64 php source carrying the PD9waHA marker
_, _ = w.Write([]byte("PD9waHAgZWNobyAnc2VjcmV0Jzs="))
return
}
_, _ = w.Write([]byte("<html>nothing to see</html>"))
}))
defer srv.Close()
result, err := LFI(srv.URL, 5*time.Second, 4, "")
if err != nil {
t.Fatalf("LFI: %v", err)
}
if result == nil {
t.Fatal("genuine base64-encoded php disclosure should still be detected")
}
var sawFilterHit bool
for _, v := range result.Vulnerabilities {
if v.Evidence == "base64 encoded PHP" && strings.Contains(v.Payload, "convert.base64-encode") {
sawFilterHit = true
}
}
if !sawFilterHit {
t.Errorf("expected a base64-php disclosure via the convert.base64-encode filter, got %+v", result.Vulnerabilities)
}
}
+11 -4
View File
@@ -141,7 +141,7 @@ func fetchCrtsh(ctx context.Context, client *http.Client, domain string) ([]stri
for i := 0; i < len(entries); i++ {
// name_value can pack several names separated by newlines.
for _, name := range strings.Split(entries[i].NameValue, "\n") {
if host := normalizeHost(name); host != "" {
if host := normalizeHost(name); host != "" && inDomain(host, domain) {
names = append(names, host)
}
}
@@ -164,7 +164,7 @@ func fetchCertspotter(ctx context.Context, client *http.Client, domain string) (
var names []string
for i := 0; i < len(entries); i++ {
for _, name := range entries[i].DNSNames {
if host := normalizeHost(name); host != "" {
if host := normalizeHost(name); host != "" && inDomain(host, domain) {
names = append(names, host)
}
}
@@ -224,14 +224,21 @@ func passiveGET(ctx context.Context, client *http.Client, reqURL string) ([]byte
return body, nil
}
// normalizeHost lowercases a name and strips a leading wildcard label so
// "*.example.com" and "EXAMPLE.com" collapse to one canonical host.
// normalizeHost lowercases a name and strips a leading wildcard label and a
// trailing root dot so "*.example.com" and "example.com." collapse to one host.
func normalizeHost(name string) string {
host := strings.ToLower(strings.TrimSpace(name))
host = strings.TrimPrefix(host, "*.")
host = strings.TrimSuffix(host, ".")
return host
}
// inDomain rejects off-scope names a shared/multi-SAN cert happens to list.
func inDomain(host, domain string) bool {
domain = strings.ToLower(domain)
return host == domain || strings.HasSuffix(host, "."+domain)
}
// addAll inserts every value into the dedupe set.
func addAll(set map[string]struct{}, values []string) {
for _, v := range values {
+24
View File
@@ -145,6 +145,29 @@ func TestPassive_ResultType(t *testing.T) {
}
}
func TestPassive_ScopesSubdomainsToTarget(t *testing.T) {
// notexample.com guards the suffix-match trap: not a subdomain of example.com.
const sharedCert = `[
{"name_value": "www.example.com\nshared.othersite.com"},
{"name_value": "notexample.com\n*.example.com"}
]`
fixtureServer(t, sharedCert, "[]", "")
result, err := Passive("https://example.com", 5*time.Second, "")
if err != nil {
t.Fatalf("Passive: %v", err)
}
for _, off := range []string{"shared.othersite.com", "notexample.com"} {
if urlsContain(result.Subdomains, off) {
t.Errorf("off-scope name %q leaked as a subdomain: %v", off, result.Subdomains)
}
}
if !urlsContain(result.Subdomains, "www.example.com") {
t.Errorf("expected the in-scope subdomain to remain: %v", result.Subdomains)
}
}
func TestNormalizeHost(t *testing.T) {
tests := []struct {
in string
@@ -152,6 +175,7 @@ func TestNormalizeHost(t *testing.T) {
}{
{"www.example.com", "www.example.com"},
{"*.example.com", "example.com"},
{"www.example.com.", "www.example.com"},
{" WWW.Example.COM ", "www.example.com"},
{"", ""},
}
+6
View File
@@ -79,6 +79,9 @@ var metaRefreshRe = regexp.MustCompile(`(?i)<meta[^>]+http-equiv=["']?refresh["'
// client-side redirects baked into a script body
var jsRedirectRe = regexp.MustCompile(`(?i)(?:location\.(?:href|replace|assign)\s*(?:=|\()|window\.location\s*=)\s*["']([^"']+)["']`)
// a leading http(s) scheme and its authority slashes, however few.
var schemeSlashesRe = regexp.MustCompile(`(?i)^(https?):/*`)
// Redirect probes the target's redirect-prone params for open-redirect.
func Redirect(targetURL string, timeout time.Duration, threads int, logdir string) (*RedirectResult, error) {
log := output.Module("REDIRECT")
@@ -274,6 +277,9 @@ func pointsAtSentinel(location string) bool {
// browsers treat backslashes in the authority as forward slashes
normalized := strings.ReplaceAll(location, "\\", "/")
// "https:/host" still navigates off-site; normalise the slashes so it parses.
normalized = schemeSlashesRe.ReplaceAllString(normalized, "$1://")
parsed, err := url.Parse(normalized)
if err != nil {
// unparseable but still naming the sentinel as the leading authority is a hit
+8
View File
@@ -140,8 +140,16 @@ func TestPointsAtSentinel(t *testing.T) {
{"scheme-relative", "//" + redirectSentinel, true},
{"backslash trick", "/\\" + redirectSentinel, true},
{"with port", "https://" + redirectSentinel + ":443/", true},
{"scheme missing slash", "https:/" + redirectSentinel, true},
{"scheme no slashes", "https:" + redirectSentinel, true},
{"scheme backslash authority", "https:\\\\" + redirectSentinel, true},
{"scheme extra slashes", "https:///" + redirectSentinel, true},
{"uppercase scheme", "HTTPS:/" + redirectSentinel, true},
{"userinfo confusion", "https://x.com@" + redirectSentinel, true},
{"empty", "", false},
{"same-site path", "/dashboard", false},
{"same-site path with colon", "/go:" + redirectSentinel, false},
{"opaque scheme not off-site", "mailto:" + redirectSentinel, false},
{"sentinel only in path", "https://safe.example.com/" + redirectSentinel, false},
{"sentinel only in query", "https://safe.example.com/?to=" + redirectSentinel, false},
}
+3 -1
View File
@@ -151,7 +151,9 @@ func gradeSecurityHeaders(header http.Header, https bool) SecurityHeaderResults
func hstsMaxAge(value string) int {
for _, part := range strings.Split(value, ";") {
if age, ok := strings.CutPrefix(strings.ToLower(strings.TrimSpace(part)), "max-age="); ok {
n, err := strconv.Atoi(strings.TrimSpace(age))
// rfc 6797 allows a quoted-string value
age = strings.Trim(strings.TrimSpace(age), `"`)
n, err := strconv.Atoi(age)
if err != nil {
return 0
}
+22
View File
@@ -110,6 +110,28 @@ func TestGradeSecurityHeaders_WeakHSTS(t *testing.T) {
}
}
func TestGradeSecurityHeaders_QuotedHSTS(t *testing.T) {
// rfc 6797 allows a quoted value; strip the quotes before grading
tests := []struct {
name string
value string
flagged bool
}{
{"quoted strong", `max-age="63072000"; includeSubDomains`, false},
{"quoted weak still flagged", `max-age="0"`, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := buildHeader(map[string]string{"Strict-Transport-Security": tt.value})
_, flagged := findFinding(gradeSecurityHeaders(h, true), "Strict-Transport-Security")
if flagged != tt.flagged {
t.Errorf("value %q flagged=%v, want %v", tt.value, flagged, tt.flagged)
}
})
}
}
func TestGradeSecurityHeaders_Disclosure(t *testing.T) {
h := buildHeader(map[string]string{
"Server": "Apache/2.4.1 (Ubuntu)",
+2 -2
View File
@@ -280,10 +280,10 @@ func isAdminPanel(body string, panelType string) bool {
case "phpRedisAdmin":
return strings.Contains(bodyLower, "phpredisadmin")
default:
// for generic database interfaces, check for common keywords
// generic db paths have no product marker, so match db keywords. "query"
// is dropped: it is a substring of jQuery/querySelector (on every js page).
return strings.Contains(bodyLower, "database") ||
strings.Contains(bodyLower, "sql") ||
strings.Contains(bodyLower, "query") ||
strings.Contains(bodyLower, "mysql") ||
strings.Contains(bodyLower, "postgresql") ||
strings.Contains(bodyLower, "mongodb")
+173
View File
@@ -0,0 +1,173 @@
/*
··
: :
: · 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"
)
// genericDefaultPanelTypes are the sqlAdminPaths entries with no product-specific
// case in isAdminPanel, so they fall through to the default keyword branch.
var genericDefaultPanelTypes = []string{
"SQL Interface", "Database Interface", "Database Admin", "MySQL Admin",
"SQL Manager", "WebSQL", "SQLWeb", "MongoDB Interface", "Redis Interface",
}
// an ordinary javascript page is not a database admin panel. "query" used to
// match the default branch via jQuery/querySelector, flagging every js site.
func TestIsAdminPanel_GenericJSPageNotFlagged(t *testing.T) {
pages := []struct{ name, body string }{
{"jquery script tag", `<script src="/assets/jquery-3.6.0.min.js"></script>`},
{"querySelector call", `<script>document.querySelector(".nav").focus()</script>`},
{"jquery invocation", `<script>jQuery(function(){ jQuery("#a").hide(); });</script>`},
{"search query word", "<form><input name='q' placeholder='search query'></form>"},
{"graphql query const", `<script>const QUERY = "{ user { id } }";</script>`},
}
for _, p := range pages {
for _, pt := range genericDefaultPanelTypes {
if isAdminPanel(p.body, pt) {
t.Errorf("%s wrongly flagged as %q admin panel", p.name, pt)
}
}
}
}
// dropping "query" must not reduce recall: real db interfaces still match via
// the sibling keywords (database/sql/mysql/postgresql/mongodb).
func TestIsAdminPanel_RealGenericPanelsStillDetected(t *testing.T) {
cases := []struct{ name, body string }{
{"database manager", "<title>Database Manager</title>"},
{"sql console", "<h1>SQL Console</h1>"},
{"mysql admin", "<title>MySQL Administration</title>"},
{"postgresql browser", "<div>PostgreSQL database browser</div>"},
{"mongodb express", "<title>mongodb express</title>"},
{"sql query interface", "<div>SQL Query Interface</div>"},
}
for _, c := range cases {
if !isAdminPanel(c.body, "Database Interface") {
t.Errorf("%s should still be detected as a database interface", c.name)
}
}
}
// the precise change: a lone "query" no longer triggers, but "query" alongside
// a db keyword still does, carried by the sibling.
func TestIsAdminPanel_QueryRemovalPrecise(t *testing.T) {
if isAdminPanel("<title>Query Console</title>", "Database Interface") {
t.Error(`lone "query" should no longer trigger the default branch`)
}
if !isAdminPanel("<title>SQL Query Tool</title>", "Database Interface") {
t.Error(`"query" with "sql" should still detect via "sql"`)
}
}
// the default-branch change must not disturb the product-specific cases.
func TestIsAdminPanel_ExplicitCasesUnaffected(t *testing.T) {
cases := []struct {
panelType string
body string
want bool
}{
{"phpMyAdmin", "<title>phpMyAdmin</title>", true},
{"phpMyAdmin", "<script>var pma_token='1';</script>", true},
{"phpMyAdmin", "<title>Home</title>", false},
{"Adminer", "<title>Adminer</title>", true},
{"Adminer", "nothing relevant", false},
{"pgAdmin", "<title>pgAdmin 4</title>", true},
{"phpPgAdmin", "<h1>phpPgAdmin</h1>", true},
{"RockMongo", "<title>RockMongo</title>", true},
{"Redis Commander", "<title>Redis Commander</title>", true},
{"phpRedisAdmin", "<h1>phpRedisAdmin</h1>", true},
{"phpMyAdmin", `<script src="jquery.js"></script>`, false},
}
for _, c := range cases {
if got := isAdminPanel(c.body, c.panelType); got != c.want {
t.Errorf("isAdminPanel(%q, %q) = %v, want %v", c.body, c.panelType, got, c.want)
}
}
}
// end to end: a catch-all that serves a jquery page at every path (the common
// soft-404-as-200 case) must not yield any admin-panel finding.
func TestSQL_JQueryCatchAllNotReported(t *testing.T) {
jq := `<!doctype html><html><head>
<script src="/static/jquery.min.js"></script></head>
<body><script>document.querySelector("#app")</script><p>Welcome</p></body></html>`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(jq))
}))
defer srv.Close()
result, err := SQL(srv.URL, 5*time.Second, 4, "")
if err != nil {
t.Fatalf("SQL: %v", err)
}
// SQL returns a nil result when nothing is found, which is the pass case here.
if result != nil && len(result.AdminPanels) != 0 {
t.Errorf("jquery catch-all produced %d admin-panel finding(s): %+v",
len(result.AdminPanels), result.AdminPanels)
}
}
// end to end: a real phpMyAdmin install is still reported.
func TestSQL_RealPhpMyAdminReported(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/phpmyadmin/" {
_, _ = w.Write([]byte("<html><title>phpMyAdmin</title></html>"))
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
result, err := SQL(srv.URL, 5*time.Second, 4, "")
if err != nil {
t.Fatalf("SQL: %v", err)
}
if result == nil {
t.Fatal("expected a phpMyAdmin finding, got nil result")
}
found := false
for _, p := range result.AdminPanels {
if p.Type == "phpMyAdmin" {
found = true
}
}
if !found {
t.Errorf("real phpMyAdmin not reported; panels=%+v", result.AdminPanels)
}
}
// end to end: a genuine generic db interface (db-topical body at a db path) is
// still reported, so the change did not over-tighten the default branch.
func TestSQL_RealGenericPanelReported(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/db/" {
_, _ = w.Write([]byte("<html><title>Database Manager</title><body>MySQL server status</body></html>"))
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
result, err := SQL(srv.URL, 5*time.Second, 4, "")
if err != nil {
t.Fatalf("SQL: %v", err)
}
if result == nil || len(result.AdminPanels) == 0 {
t.Error("a real database interface at /db/ should still be reported")
}
}
+35
View File
@@ -0,0 +1,35 @@
# Ghost CMS Detection Module
id: cms-ghost
info:
name: Ghost Detection
author: sif
severity: info
description: Detects Ghost publishing platform installations
tags: [cms, ghost, detection, info]
type: http
http:
method: GET
paths:
- "{{BaseURL}}"
- "{{BaseURL}}/ghost/"
matchers:
- type: word
part: all
words:
- 'generator" content="Ghost'
- "/ghost/api/"
- "data-ghost"
- "ghost-portal"
condition: or
extractors:
- type: regex
name: ghost_version
part: body
regex:
- 'generator" content="Ghost ([0-9]+(?:\.[0-9]+)*)'
group: 1
+36
View File
@@ -0,0 +1,36 @@
# Joomla CMS Detection Module
id: cms-joomla
info:
name: Joomla Detection
author: sif
severity: info
description: Detects Joomla CMS installations
tags: [cms, joomla, detection, info]
type: http
http:
method: GET
paths:
- "{{BaseURL}}"
- "{{BaseURL}}/administrator/"
matchers:
- type: word
part: all
words:
- 'generator" content="Joomla!'
- "/media/system/js/core.js"
- "/media/jui/"
- "joomla-script-options"
condition: or
extractors:
- type: regex
name: joomla_version
part: all
regex:
- 'Joomla! ([0-9]+(?:\.[0-9]+)*) - Open Source'
- 'X-Content-Encoded-By: Joomla! ([0-9]+(?:\.[0-9]+)*)'
group: 1
+27
View File
@@ -0,0 +1,27 @@
# Magento CMS Detection Module
id: cms-magento
info:
name: Magento Detection
author: sif
severity: info
description: Detects Magento / Adobe Commerce e-commerce installations
tags: [cms, magento, ecommerce, detection, info]
type: http
http:
method: GET
paths:
- "{{BaseURL}}"
- "{{BaseURL}}/customer/account/login/"
matchers:
- type: word
part: all
words:
- "data-mage-init"
- "text/x-magento-init"
- "mage/cookies"
- "Mage.Cookies"
condition: or
+27
View File
@@ -0,0 +1,27 @@
# TYPO3 CMS Detection Module
id: cms-typo3
info:
name: TYPO3 Detection
author: sif
severity: info
description: Detects TYPO3 CMS installations
tags: [cms, typo3, detection, info]
type: http
http:
method: GET
paths:
- "{{BaseURL}}"
- "{{BaseURL}}/typo3/"
matchers:
- type: word
part: all
words:
- "/typo3conf/"
- "/typo3temp/"
- "data-namespace-typo3-fluid"
- 'generator" content="TYPO3'
condition: or
+39
View File
@@ -0,0 +1,39 @@
# Apache Airflow API Exposure Detection Module
id: airflow-api-exposure
info:
name: Apache Airflow API Exposure
author: sif
severity: medium
description: Detects an exposed Apache Airflow webserver through its unauthenticated health endpoint
tags: [airflow, apache, pipeline, api, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/health"
matchers:
- type: status
status:
- 200
- type: word
part: body
words:
- "\"metadatabase\""
- type: word
part: body
words:
- "\"scheduler\""
extractors:
- type: regex
name: airflow_scheduler_heartbeat
part: body
regex:
- '"latest_scheduler_heartbeat"\s*:\s*"([^"]+)"'
group: 1
+46
View File
@@ -0,0 +1,46 @@
# Appsettings Exposure Detection Module
id: appsettings-exposure
info:
name: Appsettings Exposure
author: sif
severity: high
description: Detects an exposed ASP.NET Core appsettings.json that leaks connection strings
tags: [aspnet, dotnet, appsettings, connection-string, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/appsettings.json"
- "{{BaseURL}}/appsettings.Production.json"
- "{{BaseURL}}/appsettings.Development.json"
matchers:
- type: status
status:
- 200
- type: word
part: body
words:
- "\"ConnectionStrings\""
- type: word
part: body
condition: or
words:
- "Password="
- "password="
- "Pwd="
- "pwd="
- "AccountKey="
extractors:
- type: regex
name: connection_string
part: body
regex:
- '((?:Server|Data Source|Host)=[^"]+)'
group: 1
+39
View File
@@ -0,0 +1,39 @@
# ArangoDB API Exposure Detection Module
id: arangodb-api-exposure
info:
name: ArangoDB API Exposure
author: sif
severity: medium
description: Detects an exposed ArangoDB instance through its unauthenticated version endpoint
tags: [arangodb, database, graph, api, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/_api/version"
matchers:
- type: status
status:
- 200
- type: word
part: body
words:
- "\"arango\""
- type: word
part: body
words:
- "\"version\""
extractors:
- type: regex
name: arangodb_version
part: body
regex:
- '"version"\s*:\s*"([^"]+)"'
group: 1
+39
View File
@@ -0,0 +1,39 @@
# Argo CD API Exposure Detection Module
id: argocd-api-exposure
info:
name: Argo CD API Exposure
author: sif
severity: medium
description: Detects an exposed Argo CD api server through its unauthenticated version endpoint
tags: [argocd, gitops, kubernetes, api, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/api/version"
matchers:
- type: status
status:
- 200
- type: word
part: body
words:
- "\"KustomizeVersion\""
- type: word
part: body
words:
- "\"HelmVersion\""
extractors:
- type: regex
name: argocd_version
part: body
regex:
- '"Version"\s*:\s*"([^"]+)"'
group: 1
+27
View File
@@ -0,0 +1,27 @@
# Chroma Heartbeat API Exposure Detection Module
id: chroma-api-exposure
info:
name: Chroma Heartbeat API Exposure
author: sif
severity: medium
description: Detects a reachable Chroma vector database by its unauthenticated heartbeat api
tags: [chroma, vector, database, api, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/api/v1/heartbeat"
- "{{BaseURL}}/api/v2/heartbeat"
matchers:
- type: status
status:
- 200
- type: word
part: body
words:
- "\"nanosecond heartbeat\""
+44
View File
@@ -0,0 +1,44 @@
# Consul API Exposure Detection Module
id: consul-api-exposure
info:
name: Consul API Exposure
author: sif
severity: high
description: Detects an exposed Consul http api that leaks the agent config without an acl token
tags: [consul, hashicorp, service-discovery, api, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/v1/agent/self"
- "{{BaseURL}}/v1/catalog/nodes"
matchers:
- type: status
status:
- 200
- type: word
part: body
words:
- "\"Datacenter\""
- type: word
part: body
condition: or
words:
- "\"NodeName\""
- "\"Server\""
- "\"Member\""
- "\"Address\""
extractors:
- type: regex
name: consul_datacenter
part: body
regex:
- '"Datacenter"\s*:\s*"([^"]+)"'
group: 1
+39
View File
@@ -0,0 +1,39 @@
# Couchbase Cluster API Exposure Detection Module
id: couchbase-api-exposure
info:
name: Couchbase Cluster API Exposure
author: sif
severity: medium
description: Detects an exposed Couchbase cluster management api that leaks the build and topology
tags: [couchbase, database, api, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/pools"
matchers:
- type: status
status:
- 200
- type: word
part: body
words:
- "\"implementationVersion\""
- type: word
part: body
words:
- "\"componentsVersion\""
extractors:
- type: regex
name: couchbase_version
part: body
regex:
- '"implementationVersion"\s*:\s*"([^"]+)"'
group: 1
+39
View File
@@ -0,0 +1,39 @@
# Apache Druid API Exposure Detection Module
id: druid-api-exposure
info:
name: Apache Druid API Exposure
author: sif
severity: high
description: Detects an exposed Apache Druid process that runs without authentication
tags: [druid, apache, database, api, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/status"
matchers:
- type: status
status:
- 200
- type: word
part: body
words:
- "org.apache.druid"
- type: word
part: body
words:
- "\"memory\""
extractors:
- type: regex
name: druid_version
part: body
regex:
- '"version"\s*:\s*"([^"]+)"'
group: 1
+39
View File
@@ -0,0 +1,39 @@
# etcd API Exposure Detection Module
id: etcd-api-exposure
info:
name: etcd API Exposure
author: sif
severity: high
description: Detects an exposed etcd api whose keyspace often holds kubernetes secrets
tags: [etcd, kubernetes, key-value, api, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/version"
matchers:
- type: status
status:
- 200
- type: word
part: body
words:
- "\"etcdserver\""
- type: word
part: body
words:
- "\"etcdcluster\""
extractors:
- type: regex
name: etcd_version
part: body
regex:
- '"etcdserver"\s*:\s*"([0-9]+\.[0-9]+\.[0-9]+)"'
group: 1
+39
View File
@@ -0,0 +1,39 @@
# Apache Flink API Exposure Detection Module
id: flink-api-exposure
info:
name: Apache Flink API Exposure
author: sif
severity: high
description: Detects an exposed Apache Flink dashboard reachable without authentication
tags: [flink, apache, cluster, api, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/overview"
matchers:
- type: status
status:
- 200
- type: word
part: body
words:
- "\"flink-version\""
- type: word
part: body
words:
- "\"slots-total\""
extractors:
- type: regex
name: flink_version
part: body
regex:
- '"flink-version"\s*:\s*"([^"]+)"'
group: 1
+55
View File
@@ -0,0 +1,55 @@
# Atom remote-ftp Deploy Config Exposure Detection Module
id: ftpconfig-exposure
info:
name: Atom remote-ftp Deploy Config Exposure
author: sif
severity: high
description: Detects an exposed remote-ftp config that leaks deploy host and credentials
tags: [atom, ftp, sftp, deploy, credentials, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/.ftpconfig"
matchers:
- type: status
status:
- 200
- type: word
part: body
condition: or
words:
- '"pasvTimeout"'
- '"connTimeout"'
- type: word
part: body
condition: or
words:
- '"pass"'
- '"user"'
- type: word
part: body
negative: true
condition: or
words:
- "<!DOCTYPE"
- "<!doctype"
- "<html"
- "<HTML"
- "<head>"
- "<title>"
extractors:
- type: regex
name: remote_host
part: body
regex:
- '"host"\s*:\s*"([^"]+)"'
group: 1
@@ -0,0 +1,47 @@
# Git Credentials Exposure Detection Module
id: git-credentials-exposure
info:
name: Git Credentials Exposure
author: sif
severity: high
description: Detects an exposed git credential store that leaks tokens embedded in remote urls
tags: [git, credentials, token, secret, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/.git-credentials"
- "{{BaseURL}}/.git/credentials"
matchers:
- type: status
status:
- 200
- type: regex
part: body
regex:
- 'https?://[^:/@\s]+:[^@/\s]+@[A-Za-z0-9._-]+'
- type: word
part: body
negative: true
condition: or
words:
- "<!DOCTYPE"
- "<!doctype"
- "<html"
- "<HTML"
- "<head>"
- "<title>"
extractors:
- type: regex
name: git_host
part: body
regex:
- 'https?://[^:/@\s]+:[^@/\s]+@([A-Za-z0-9._-]+)'
group: 1
@@ -0,0 +1,39 @@
# Apache Hadoop YARN API Exposure Detection Module
id: hadoop-yarn-api-exposure
info:
name: Apache Hadoop YARN API Exposure
author: sif
severity: high
description: Detects an exposed Hadoop YARN resource manager api reachable without authentication
tags: [hadoop, yarn, apache, cluster, api, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/ws/v1/cluster/info"
matchers:
- type: status
status:
- 200
- type: word
part: body
words:
- "\"clusterInfo\""
- type: word
part: body
words:
- "\"resourceManagerVersion\""
extractors:
- type: regex
name: hadoop_version
part: body
regex:
- '"hadoopVersion"\s*:\s*"([^"]+)"'
group: 1
+62
View File
@@ -0,0 +1,62 @@
# Htaccess Exposure Detection Module
id: htaccess-exposure
info:
name: Htaccess Exposure
author: sif
severity: medium
description: Detects an exposed htaccess file that leaks rewrite rules and access control config
tags: [apache, htaccess, config, info-disclosure, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/.htaccess"
matchers:
- type: status
status:
- 200
- type: word
part: body
condition: or
words:
- "RewriteEngine"
- "RewriteRule"
- "RewriteCond"
- "AuthUserFile"
- "AuthType"
- "<FilesMatch"
- "<Files "
- "Order allow,deny"
- "Deny from"
- "Options -"
- "Options +"
- "ErrorDocument"
- "DirectoryIndex"
- "php_flag"
- "php_value"
- "Header set"
- type: word
part: body
negative: true
condition: or
words:
- "<!DOCTYPE"
- "<!doctype"
- "<html"
- "<HTML"
- "<head>"
- "<title>"
extractors:
- type: regex
name: auth_user_file
part: body
regex:
- 'AuthUserFile\s+(\S+)'
group: 1
+36
View File
@@ -0,0 +1,36 @@
# Htpasswd Exposure Detection Module
id: htpasswd-exposure
info:
name: Htpasswd Exposure
author: sif
severity: high
description: Detects an exposed htpasswd file that leaks crackable basic auth password hashes
tags: [apache, htpasswd, credentials, hashes, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/.htpasswd"
matchers:
- type: status
status:
- 200
- type: regex
part: body
condition: or
regex:
- '(?m)^[^:\s]+:\$(apr1|2[aby]|1|5|6)\$'
- '(?m)^[^:\s]+:\{SHA\}'
extractors:
- type: regex
name: htpasswd_user
part: body
regex:
- '(?m)^([^:\s]+):'
group: 1
+39
View File
@@ -0,0 +1,39 @@
# InfluxDB API Exposure Detection Module
id: influxdb-api-exposure
info:
name: InfluxDB API Exposure
author: sif
severity: medium
description: Detects an exposed InfluxDB instance through its unauthenticated health endpoint
tags: [influxdb, database, timeseries, api, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/health"
matchers:
- type: status
status:
- 200
- type: word
part: body
words:
- "\"influxdb\""
- type: word
part: body
words:
- "ready for queries and writes"
extractors:
- type: regex
name: influxdb_version
part: body
regex:
- '"version"\s*:\s*"([^"]+)"'
group: 1
+42
View File
@@ -0,0 +1,42 @@
# Jolokia API Exposure Detection Module
id: jolokia-api-exposure
info:
name: Jolokia API Exposure
author: sif
severity: high
description: Detects an exposed Jolokia agent that bridges http to jmx for remote management
tags: [jolokia, jmx, java, api, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/jolokia/version"
- "{{BaseURL}}/actuator/jolokia/version"
- "{{BaseURL}}/api/jolokia/version"
- "{{BaseURL}}/hawtio/jolokia/version"
matchers:
- type: status
status:
- 200
- type: word
part: body
words:
- "\"agent\""
- type: word
part: body
words:
- "\"protocol\""
extractors:
- type: regex
name: jolokia_agent_version
part: body
regex:
- '"agent"\s*:\s*"([^"]+)"'
group: 1
+44
View File
@@ -0,0 +1,44 @@
# Jupyter Server API Exposure Detection Module
id: jupyter-api-exposure
info:
name: Jupyter Server API Exposure
author: sif
severity: high
description: Detects a Jupyter server whose status api answers without authentication
tags: [jupyter, notebook, api, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/api/status"
matchers:
- type: status
status:
- 200
- type: word
part: body
words:
- "\"last_activity\""
- type: word
part: body
words:
- "\"connections\""
- type: word
part: body
words:
- "\"kernels\""
extractors:
- type: regex
name: jupyter_active_kernels
part: body
regex:
- '"kernels"\s*:\s*([0-9]+)'
group: 1
@@ -0,0 +1,39 @@
# Kafka Connect API Exposure Detection Module
id: kafka-connect-api-exposure
info:
name: Kafka Connect API Exposure
author: sif
severity: high
description: Detects an exposed Kafka Connect rest api reachable without authentication
tags: [kafka, connect, streaming, api, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/"
matchers:
- type: status
status:
- 200
- type: word
part: body
words:
- "\"kafka_cluster_id\""
- type: word
part: body
words:
- "\"version\""
extractors:
- type: regex
name: kafka_version
part: body
regex:
- '"version"\s*:\s*"([^"]+)"'
group: 1
+39
View File
@@ -0,0 +1,39 @@
# Kong Admin API Exposure Detection Module
id: kong-api-exposure
info:
name: Kong Admin API Exposure
author: sif
severity: high
description: Detects an exposed Kong admin api that grants full control of the gateway
tags: [kong, gateway, api, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/"
matchers:
- type: status
status:
- 200
- type: word
part: body
words:
- "\"available_on_server\""
- type: word
part: body
words:
- "\"admin_listen\""
extractors:
- type: regex
name: kong_version
part: body
regex:
- '"version"\s*:\s*"([^"]+)"'
group: 1
+39
View File
@@ -0,0 +1,39 @@
# Metabase Setup Token Exposure Detection Module
id: metabase-api-exposure
info:
name: Metabase Setup Token Exposure
author: sif
severity: high
description: Detects a Metabase instance that exposes a live setup token without authentication
tags: [metabase, analytics, api, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/api/session/properties"
matchers:
- type: status
status:
- 200
- type: regex
part: body
regex:
- '"setup-token"\s*:\s*"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"'
- type: word
part: body
words:
- "\"anon-tracking-enabled\""
extractors:
- type: regex
name: metabase_version
part: body
regex:
- '"version"\s*:\s*\{[^}]*?"tag"\s*:\s*"([^"]+)"'
group: 1
+39
View File
@@ -0,0 +1,39 @@
# NATS Monitoring API Exposure Detection Module
id: nats-api-exposure
info:
name: NATS Monitoring API Exposure
author: sif
severity: medium
description: Detects an exposed NATS monitoring endpoint that leaks the server topology
tags: [nats, messaging, api, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/varz"
matchers:
- type: status
status:
- 200
- type: word
part: body
words:
- "\"server_id\""
- type: word
part: body
words:
- "\"max_payload\""
extractors:
- type: regex
name: nats_version
part: body
regex:
- '"version"\s*:\s*"([^"]+)"'
group: 1
+39
View File
@@ -0,0 +1,39 @@
# Neo4j API Exposure Detection Module
id: neo4j-api-exposure
info:
name: Neo4j API Exposure
author: sif
severity: medium
description: Detects an exposed Neo4j instance through its unauthenticated discovery endpoint
tags: [neo4j, database, graph, api, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/"
matchers:
- type: status
status:
- 200
- type: word
part: body
words:
- "\"neo4j_version\""
- type: word
part: body
words:
- "\"neo4j_edition\""
extractors:
- type: regex
name: neo4j_version
part: body
regex:
- '"neo4j_version"\s*:\s*"([^"]+)"'
group: 1
+46
View File
@@ -0,0 +1,46 @@
# Private Key Exposure Detection Module
id: private-key-exposure
info:
name: Private Key Exposure
author: sif
severity: high
description: Detects an exposed PEM private key that grants ssh or tls access
tags: [ssh, tls, private-key, secret, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/.ssh/id_rsa"
- "{{BaseURL}}/id_rsa"
- "{{BaseURL}}/id_ecdsa"
- "{{BaseURL}}/id_ed25519"
- "{{BaseURL}}/server.key"
- "{{BaseURL}}/private.key"
- "{{BaseURL}}/privkey.pem"
matchers:
- type: status
status:
- 200
- type: word
part: body
condition: or
words:
- "-----BEGIN RSA PRIVATE KEY-----"
- "-----BEGIN OPENSSH PRIVATE KEY-----"
- "-----BEGIN EC PRIVATE KEY-----"
- "-----BEGIN DSA PRIVATE KEY-----"
- "-----BEGIN PRIVATE KEY-----"
- "-----BEGIN ENCRYPTED PRIVATE KEY-----"
extractors:
- type: regex
name: key_type
part: body
regex:
- '-----BEGIN ([A-Z0-9 ]+?) PRIVATE KEY-----'
group: 1
+57
View File
@@ -0,0 +1,57 @@
# Pypirc Exposure Detection Module
id: pypirc-exposure
info:
name: Pypirc Exposure
author: sif
severity: high
description: Detects an exposed pypirc that leaks the package index upload credentials
tags: [python, pypi, credentials, token, secret, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/.pypirc"
matchers:
- type: status
status:
- 200
- type: word
part: body
condition: or
words:
- "[pypi]"
- "[testpypi]"
- "[distutils]"
- type: word
part: body
condition: or
words:
- "password"
- "username"
- "pypi-"
- type: word
part: body
negative: true
condition: or
words:
- "<!DOCTYPE"
- "<!doctype"
- "<html"
- "<HTML"
- "<head>"
- "<title>"
extractors:
- type: regex
name: pypi_token
part: body
regex:
- '(pypi-[A-Za-z0-9_-]{8,})'
group: 1
+39
View File
@@ -0,0 +1,39 @@
# Qdrant Collections API Exposure Detection Module
id: qdrant-api-exposure
info:
name: Qdrant Collections API Exposure
author: sif
severity: high
description: Detects a Qdrant vector database serving its collections without an api key
tags: [qdrant, vector, database, api, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/collections"
matchers:
- type: status
status:
- 200
- type: regex
part: body
regex:
- '"result"\s*:\s*\{\s*"collections"'
- type: regex
part: body
regex:
- '"status"\s*:\s*"ok"'
extractors:
- type: regex
name: qdrant_collection
part: body
regex:
- '"collections"\s*:\s*\[\s*\{[^}]*?"name"\s*:\s*"([^"]+)"'
group: 1
@@ -0,0 +1,53 @@
# Rails Database Config Exposure Detection Module
id: rails-database-yml-exposure
info:
name: Rails Database Config Exposure
author: sif
severity: high
description: Detects an exposed Rails config/database.yml that leaks database credentials
tags: [rails, ruby, database, credentials, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/config/database.yml"
matchers:
- type: status
status:
- 200
- type: word
part: body
words:
- "adapter:"
- type: word
part: body
condition: or
words:
- "password:"
- "username:"
- type: word
part: body
negative: true
condition: or
words:
- "<!DOCTYPE"
- "<!doctype"
- "<html"
- "<HTML"
- "<head>"
- "<title>"
extractors:
- type: regex
name: database
part: body
regex:
- 'database:\s*["'']?([A-Za-z0-9_./\-]+)'
group: 1
@@ -0,0 +1,35 @@
# Rails Master Key Exposure Detection Module
id: rails-master-key-exposure
info:
name: Rails Master Key Exposure
author: sif
severity: high
description: Detects an exposed Rails master key that decrypts the encrypted credentials store
tags: [rails, ruby, master-key, credentials, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/config/master.key"
- "{{BaseURL}}/config/credentials/production.key"
matchers:
- type: status
status:
- 200
- type: regex
part: body
regex:
- '^[a-f0-9]{32}\s*$'
extractors:
- type: regex
name: master_key
part: body
regex:
- '^([a-f0-9]{32})'
group: 1
@@ -0,0 +1,46 @@
# Rails Secrets Config Exposure Detection Module
id: rails-secrets-yml-exposure
info:
name: Rails Secrets Config Exposure
author: sif
severity: high
description: Detects an exposed Rails config/secrets.yml that leaks the secret key base
tags: [rails, ruby, secrets, secret-key-base, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/config/secrets.yml"
matchers:
- type: status
status:
- 200
- type: word
part: body
words:
- "secret_key_base:"
- type: word
part: body
negative: true
condition: or
words:
- "<!DOCTYPE"
- "<!doctype"
- "<html"
- "<HTML"
- "<head>"
- "<title>"
extractors:
- type: regex
name: secret_key_base
part: body
regex:
- 'secret_key_base:\s*["'']?([a-f0-9]{32,})'
group: 1
+37
View File
@@ -0,0 +1,37 @@
# Redis RDB Dump Exposure Detection Module
id: redis-dump-exposure
info:
name: Redis RDB Dump Exposure
author: sif
severity: high
description: Detects an exposed Redis RDB snapshot that leaks the full keyspace
tags: [database, redis, rdb, dump, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/dump.rdb"
- "{{BaseURL}}/redis/dump.rdb"
- "{{BaseURL}}/data/dump.rdb"
- "{{BaseURL}}/var/lib/redis/dump.rdb"
matchers:
- type: status
status:
- 200
- type: regex
part: body
regex:
- '^REDIS00\d\d'
extractors:
- type: regex
name: rdb_version
part: body
regex:
- 'REDIS(\d{4})'
group: 1
+39
View File
@@ -0,0 +1,39 @@
# Riak HTTP API Exposure Detection Module
id: riak-api-exposure
info:
name: Riak HTTP API Exposure
author: sif
severity: high
description: Detects an exposed Riak http api reachable without authentication
tags: [riak, database, api, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/stats"
matchers:
- type: status
status:
- 200
- type: word
part: body
words:
- "\"riak_kv_version\""
- type: word
part: body
words:
- "\"riak_core_version\""
extractors:
- type: regex
name: riak_version
part: body
regex:
- '"riak_kv_version"\s*:\s*"([^"]+)"'
group: 1
+39
View File
@@ -0,0 +1,39 @@
# Apache Solr API Exposure Detection Module
id: solr-api-exposure
info:
name: Apache Solr API Exposure
author: sif
severity: high
description: Detects an exposed Apache Solr admin api reachable without authentication
tags: [solr, apache, search, api, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/solr/admin/info/system"
matchers:
- type: status
status:
- 200
- type: word
part: body
words:
- "\"solr-spec-version\""
- type: word
part: body
words:
- "\"solr_home\""
extractors:
- type: regex
name: solr_version
part: body
regex:
- '"solr-spec-version"\s*:\s*"([^"]+)"'
group: 1
+39
View File
@@ -0,0 +1,39 @@
# Apache Spark Master API Exposure Detection Module
id: spark-api-exposure
info:
name: Apache Spark Master API Exposure
author: sif
severity: high
description: Detects an exposed Apache Spark master that leaks its cluster workers and applications
tags: [spark, apache, cluster, api, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/json/"
matchers:
- type: status
status:
- 200
- type: regex
part: body
regex:
- '"url"\s*:\s*"spark://'
- type: word
part: body
words:
- "\"aliveworkers\""
extractors:
- type: regex
name: spark_master_url
part: body
regex:
- '"url"\s*:\s*"(spark://[^"]+)"'
group: 1
@@ -0,0 +1,61 @@
# Spring Application Config Exposure Detection Module
id: spring-application-config-exposure
info:
name: Spring Application Config Exposure
author: sif
severity: high
description: Detects an exposed Spring application config that leaks the datasource credentials
tags: [spring, java, application-properties, datasource, exposure, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/application.properties"
- "{{BaseURL}}/application.yml"
- "{{BaseURL}}/config/application.properties"
- "{{BaseURL}}/config/application.yml"
- "{{BaseURL}}/WEB-INF/classes/application.properties"
matchers:
- type: status
status:
- 200
- type: word
part: body
condition: or
words:
- "spring.datasource"
- "spring.application"
- "datasource:"
- "jdbc:"
- type: word
part: body
condition: or
words:
- "password"
- "secret"
- type: word
part: body
negative: true
condition: or
words:
- "<!DOCTYPE"
- "<!doctype"
- "<html"
- "<HTML"
- "<head>"
- "<title>"
extractors:
- type: regex
name: jdbc_url
part: body
regex:
- '(jdbc:[a-zA-Z0-9]+://[^\s"'',]+)'
group: 1

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