Compare commits

..

321 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

supersedes #146 (same fix by @TBX3D, which conflicted after the checkout
bump in #143).
2026-06-22 17:21:32 -07:00
Tigah f6f9a2bbf7 feat(modules): add spring, appsettings and wp-config exposure modules (#206)
modules/recon/spring-application-config-exposure.yaml flags an exposed Spring
application config, in either properties or yaml form, on a datasource marker
paired with a credential field, then extracts the jdbc url. requiring the
credential keeps a config that holds no secret from being reported.

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

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

internal/modules/app_config_exposure_test.go drives the three modules end to
end through ExecuteHTTPModule and asserts the leak alongside the near misses a
strict review wants pinned: a config with no credential, a password outside a
connection strings section, a passwordless connection string, prose that names
DB_PASSWORD, a config shown in an html page, a plain 200 body and a 404, none
of which may match.

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

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

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

internal/modules/rails_secret_exposure_test.go drives the three modules end to
end through ExecuteHTTPModule and asserts the leak alongside the near misses a
strict review wants pinned: a credential free sqlite config, a longer hex
digest, a hex value away from the body start, an html page naming the markers,
a config without the markers and a 404, none of which may match.

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

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

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

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

internal/modules/deploy_config_exposure_test.go drives the three modules end to
end through ExecuteHTTPModule and asserts the leak alongside the near misses a
strict review wants pinned: an html login page carrying the same keys, a plain
json config without the tool keys, a tool config with a host but no credential
field and a 404, none of which may match. it also pins a key auth config with
no password as a leak the credential matcher must still catch.

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

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

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

internal/modules/webserver_config_exposure_test.go drives the three modules end
to end through ExecuteHTTPModule and asserts the leak alongside the near misses
a strict review wants pinned: a plaintext htpasswd line, a configuration without
a dotnet section, an html page, a plain 200 body and a 404, none of which may
match.

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

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

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

internal/modules/orchestration_api_exposure_test.go drives the three modules
end to end through ExecuteHTTPModule and asserts the leak alongside the near
misses a strict review wants pinned: a sealed flag with no vault field, a
datacenter field alone, a version response from another service, a partial etcd
reply, a plain 200 body and a 404, none of which may match.

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

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

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

internal/modules/secret_file_exposure_test.go drives the three modules end to
end through ExecuteHTTPModule and asserts the leak alongside the near misses a
strict review wants pinned: a public key, prose that names a key, a remote url
with no password, a pypi section with no credential, a credential shown in an
html page, a plain 200 body and a 404, none of which may match.

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

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

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

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

verify: go test ./internal/modules, each matcher and extractor proven to bite
(break -> red, restore -> green).
2026-06-22 17:07:25 -07:00
Tigah 761e570d59 feat(modules): add sql dump, sqlite and redis rdb exposure modules (#204)
modules/recon/sql-dump-exposure.yaml flags an exposed SQL dump on its
mysqldump and pg_dump idioms paired against a guard that drops SQL shown
inside an html page, then extracts the dumped table name.

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

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

internal/modules/database_file_exposure_test.go drives the three modules end
to end through ExecuteHTTPModule and asserts the leak alongside the near
misses a strict review wants pinned: a SQL tutorial page, a bare select, prose
that names the sqlite or redis format, a header embedded mid body, a plain 200
body and a 404, none of which may match.

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

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

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

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

verify: go test ./internal/modules, each matcher and extractor proven to bite
(break -> red, restore -> green).
2026-06-22 17:06:51 -07:00
Tigah 7788550722 feat(modules): add qdrant, weaviate and chroma exposure modules (#222)
modules/recon/qdrant-api-exposure.yaml flags a Qdrant vector database that
serves its collections without an api key, keyed on the result envelope wrapping
a collections array paired with the ok status, then extracts the first
collection name. Qdrant gates /collections behind the api key, so an answer here
means the catalog is readable.

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

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

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

verify: go test ./internal/modules, each matcher and extractor proven to bite
(break -> red, restore -> green).
2026-06-22 16:54:03 -07:00
Tigah 40482a8409 feat(modules): add argo cd exposure module (#219)
modules/recon/argocd-api-exposure.yaml flags an exposed Argo CD api server through
its unauthenticated /api/version endpoint, keyed on the KustomizeVersion and
HelmVersion fields a generic version response does not carry, then extracts the
server version.

internal/modules/argocd_exposure_test.go drives the module through ExecuteHTTPModule
and asserts the leak alongside the near misses: each keying field missing on its own,
a generic version json, a plain 200 and a 404.

verify: go test ./internal/modules, each matcher and extractor proven to bite
(break -> red, restore -> green).
2026-06-22 16:49:52 -07:00
Tigah 3ed9ea4b6f feat(modules): add kong, jolokia and nats api exposure modules (#215)
modules/recon/kong-api-exposure.yaml flags an exposed Kong admin api that grants
full control of the gateway, keyed on the available plugins map paired with the
admin listen address that the node reports, then extracts the kong version.

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

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

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

verify: go test ./internal/modules, each matcher and extractor proven to bite
(break -> red, restore -> green).
2026-06-22 16:49:45 -07:00
Tigah 612bb61d00 feat(modules): add solr, spark and hadoop yarn api exposure modules (#213)
modules/recon/solr-api-exposure.yaml flags an exposed Apache Solr admin api,
keyed on the solr spec version paired with the solr home that a generic json
endpoint does not carry, then extracts the solr version.

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

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

internal/modules/bigdata_api_exposure_test.go drives the three modules end to end
through ExecuteHTTPModule and asserts the leak alongside the near misses a strict
review wants pinned: each service with one keying field missing, a non spark url
behind the worker count, a generic json endpoint, a plain 200 and a 404.

verify: go test ./internal/modules, each matcher and extractor proven to bite
(break -> red, restore -> green).
2026-06-22 16:49:38 -07:00
Tigah ec53d15a9f docs: require go 1.25 to match go.mod (#161) 2026-06-22 16:49:13 -07:00
dependabot[bot] 064484ff4d chore(deps): bump actions/checkout from 6 to 7 (#143)
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-22 16:49:01 -07:00
Tigah e2a26c19c6 docs: pin golangci-lint version to match ci (#135)
the lint steps said `golangci-lint run` with no version. ci pins
v2.11.4 and .golangci.yml is a v2 config tuned for it, so a contributor
on another version gets spurious findings from unrelated linters.
document the pinned invocation in both the dev guide and the readme so
local runs match ci.

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

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

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

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

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

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

internal/modules/http_database_exposure_test.go drives the three modules through
ExecuteHTTPModule and asserts the leak alongside the near misses a strict review
wants pinned: each service with one keying field missing, a non-arango response, an
arango that requires auth, a generic health json, a plain 200 and a 404.

verify: go test ./internal/modules, each matcher and extractor proven to bite
(break -> red, restore -> green).
2026-06-22 16:48:41 -07:00
Tigah 72f59532cf feat(modules): add metabase, zeppelin and jupyter exposure modules (#220)
modules/recon/metabase-api-exposure.yaml flags a Metabase instance that exposes
a live setup token without authentication, keyed on a non-null uuid token paired
with the anonymous tracking setting, then extracts the version tag. A live token
is the pre-auth chain behind CVE-2023-38646; a patched instance reports it as
null and is left alone.

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

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

internal/modules/analytics_ui_exposure_test.go drives the three modules end to
end through ExecuteHTTPModule and asserts the leak alongside the near misses a
strict review wants pinned: each service with one keying field missing, a
patched metabase that nulls its token, a generic version response, a plain 200
and a 404.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-12 12:43:41 +00:00
vmfunc d62919523a docs: rewrite intro around the shared http layer and concurrency 2026-06-10 18:54:25 -07:00
celeste 33961a5c35 Merge pull request #123 from vmfunc/feat/phase3
feat: diff mode, notify (slack/discord/telegram/webhook), work-stealing pool
2026-06-10 16:58:32 -07:00
vmfunc 8078978a44 feat: notify integrations (slack, discord, telegram, webhook)
ship findings to chat/webhook sinks after a scan so continuous recon can
alert on what it turns up. each provider is one POST through httpx.Client,
so the global proxy/rate-limit/header config applies and there's no extra
http stack. config resolves env-first (SLACK_WEBHOOK_URL, DISCORD_WEBHOOK_URL,
TELEGRAM_BOT_TOKEN/TELEGRAM_CHAT_ID, NOTIFY_WEBHOOK_URL), overridable by a
notify-compatible yaml file so existing projectdiscovery/notify configs port
over. -notify enables it, -notify-severity gates on the finding severity
ladder (default medium), -notify-config points at the yaml. wired after the
scan loop on the severity-filtered finding set; no provider configured is a
silent no-op.
2026-06-10 16:40:14 -07:00
vmfunc 6ec0b60e5a feat: diff mode with json snapshot store
re-scans become a monitor: -diff snapshots each target's normalized
findings to a per-target json file and, on the next run, surfaces only
the delta (+ new / - gone) against the last snapshot, then overwrites it
so each run diffs against the previous one. behavior is unchanged when
-diff is off.

new internal/store keys the set-difference off finding.Key (already
stable across runs) and uses only encoding/json + os - no new deps.
snapshot files are sanitized per target (no traversal), written 0600
under 0750 dirs. -store picks the location: explicit dir, else the log
dir, else <user-config>/sif/state. a missing snapshot is a clean
baseline, a corrupt one self-heals on the next save.
2026-06-10 16:39:04 -07:00
vmfunc 22168611e4 perf(scan): work-stealing worker pool replacing static stride partition
static i%threads stride partitions assign each item to a fixed worker, so a
goroutine stuck on a slow or timing-out host stalls while the rest idle behind
it (head-of-line blocking). add internal/pool.Each: workers all pull from one
shared channel, so a slow item only blocks its own worker and the others keep
draining. migrate git, ports, robots (scan), dnslist, dork, dirlist and
subdomaintakeover off the stride loops; per-item work, mutex-guarded appends and
progress increments are unchanged, results were already unordered.
2026-06-10 16:39:04 -07:00
celeste 4813146afc Merge pull request #122 from vmfunc/feat/phase2
feat: pipe mode, drainclose sweep, jwt/openapi/favicon scanners
2026-06-10 16:24:12 -07:00
vmfunc 57b1bd7113 fix(favicon): use twmb/murmur3; spaolacci does checkptr-unsafe pointer math that crashes under -race (which CI runs) 2026-06-10 16:08:47 -07:00
vmfunc ab731d0562 feat(scan): add jwt, openapi and favicon-hash scanners
jwt fetches the target once then analyzes every harvested token offline:
flags alg:none, the rs256->hs256 confusion surface, missing/expired exp
and plaintext sensitive claims, and cracks a small bundled weak-hmac list.
openapi probes the conventional spec paths, parses json/yaml and enumerates
paths plus unauthenticated operations. favicon computes the shodan-style
mmh3 hash (python base64.encodebytes chunking, signed int32) for tech
fingerprinting and the http.favicon.hash pivot, pinned by a golden test.
2026-06-10 15:50:59 -07:00
vmfunc ef0408ee8d feat: pipe mode (stdin targets, naked-host, -silent plain output)
sif can now slot into unix pipelines. stdin is drained for targets when
it's a pipe (keyed off stdin's mode, not stdout), alongside -u/-f. naked
hosts are accepted and default to https://; explicit http(s) is kept,
other schemes rejected. -silent routes all banner/spinner/log chrome to
stderr and prints one normalized finding per line to stdout via
finding.Flatten, so `subfinder | sif -silent | notify` works.
2026-06-10 15:50:58 -07:00
vmfunc 0383a7bcd2 perf(scan): drain response bodies so pooled connections get reused
go only returns a conn to the idle pool when the body is read to EOF
before Close. the header-only and early-return scan paths closed an
unread body, leaking the conn and forcing a fresh dial each request.
route those close sites through httpx.DrainClose so the tuned pool from
phase 1 actually gets reused. body-read paths (scanner/io.ReadAll) are
left untouched.
2026-06-10 15:50:58 -07:00
celeste 136ddbddba Merge pull request #121 from vmfunc/feat/phase1
perf+feat: transport reuse (50x fewer handshakes), finding-normalization, async DNS
2026-06-10 15:32:13 -07:00
vmfunc a5f42ddfa6 feat(dnslist): async dns resolution with wildcard filtering
dnslist previously http-probed every wordlist candidate through the
blocking os resolver, so a big list meant a request per dead name and a
wildcard zone flooded results. resolve each candidate first via a new
internal/dnsx (retryabledns over a bundled 1.1.1.1/8.8.8.8/9.9.9.9 pool,
promoted to a direct dep), fingerprint the apex with random nonexistent
labels to detect catch-all zones, and http-probe only the names that
actually resolve and aren't wildcard. add -resolvers to override the
pool. resolverFn is a package-level seam so the dnsx tests stay
hermetic; the dnslist newDNSResolver seam keeps the integration test
network-free.
2026-06-10 15:30:03 -07:00
vmfunc 1237f3f09e feat(finding): normalized finding layer for notify and diff
scan results live in ~two dozen structs with no shared shape, so every
consumer that wants "what did this run turn up" reimplements the
type-switch. add internal/finding: an ordered Severity (info<low<medium<
high<critical, with parse/compare) and Flatten, the single type-switch
that collapses every scan result struct into flat, severity-ranked
Findings keyed module:identifier for stable dedup/diff.

wire collectFindings off Flatten in the run loop so notify and diff
(later bundles) build on one normalization path instead of re-deriving
it; the report path keeps emitting raw json blobs unchanged. expose
JavascriptScanResult.SupabaseFindings so the js internals stay private.

the guard test iterates a representative instance of every ResultType
and fails if Flatten lacks a case (falls through to :unhandled) - so a
new scanner can't ship without a Flatten case landing too.
2026-06-10 15:29:20 -07:00
vmfunc 546ab091da perf(httpx): tune transport for connection reuse and add DrainClose
the shared transport was a bare DefaultTransport.Clone() with the stock
MaxIdleConnsPerHost=2, and call-sites only close response bodies without
draining them - so go could never return a conn to the idle pool and every
request re-dialed. high thread counts just thrashed the dialer.

- plumb Threads through Options into buildTransport; size MaxIdleConnsPerHost
  to the worker count (floored) so concurrent workers on one host pool instead
  of re-dialing, MaxIdleConns=512, MaxConnsPerHost=0, IdleConnTimeout=90s,
  ForceAttemptHTTP2. the socks5 branch gets its own keepalive net.Dialer so it
  doesn't lose os-level pooling under proxy.Direct.
- add DrainClose to read (capped) and close a body so the conn is reusable.
- benchmark proves it: 50 sequential requests reuse 1 conn tuned vs 50 bare.
2026-06-10 15:29:20 -07:00
celeste 5166b8d8e6 Merge pull request #120 from vmfunc/feat/day2-batch1
feat: dirlist overhaul, scan bug sweep, live-host probe + sarif/markdown export, modules tests
2026-06-10 15:13:42 -07:00
vmfunc c3a755f934 feat: live-host probe and sarif/markdown report export
adds an httpx-style -probe scanner reporting liveness, final status, page
title, server header and the redirect chain, plus -sarif/-markdown export
flags that serialize the collected run after the scan loop. the report
serializers live in a decoupled internal/report package consuming a raw-json
result model so they never import scan types.
2026-06-10 14:47:17 -07:00
vmfunc 5050900f29 feat(dirlist): response filters, wildcard calibration and custom wordlists
the old scanner surfaced every response that wasn't 404/403, so modern SPA
catch-all 200s flooded the output and made -dirlist near-useless. add ffuf-style
matching:

- -mc/-fc/-fr and -fs/-fw filter by status, regex, body size and word count;
  bodies are read through a capped io.LimitReader so size/word counts are
  deterministic and memory stays flat. filters win over matches.
- -ac auto-calibrates the soft-404 baseline from a few deterministic
  non-existent paths and drops responses matching that wildcard shape.
- -w overrides the size switch with a local file or remote list (fetched through
  the shared client so proxy/rate-limit apply); -e appends extensions per word.

size and words are added to DirectoryResult for the json output.
2026-06-10 14:47:17 -07:00
vmfunc 320fc3d4e7 test(modules): cover matchers, extractors, loader and executor
the yaml module engine (the user-facing extensibility surface) had 0%
test coverage. add table-driven tests for the matcher types
(status/word/regex + and/or + negative), checkWords/checkRegex (incl
invalid-pattern fail-closed under AND, skip under OR), runExtractors
(regex capture groups, group-index bounds, part selection),
substituteVariables and generateHTTPRequests (path x payload expansion),
and ParseYAMLModule on valid + malformed yaml. drive ExecuteHTTPModule
end-to-end against an httptest server through the shared httpx client so
matcher hits and extractor captures are exercised for real. coverage
0% -> 93.7%.

also: ExecuteDNSModule/ExecuteTCPModule were stubs returning an empty
result with nil error, so a type:dns/type:tcp module silently reported
"0 findings" - indistinguishable from a real clean scan. make them
return ErrUnsupportedModuleType (sentinel, wrapped with the module id) so
the existing caller logs a clear failure instead. a test pins the new
behavior.

bodyclose is excluded for test files in .golangci.yml: the synthetic
*http.Response fixtures carry no socket, mirroring the existing _test.go
slack for errcheck/noctx/gosec.
2026-06-10 14:47:17 -07:00
vmfunc 839c0a779c fix(scan): dnslist dedup, robots recursion bound, framework version lookup, takeover cname
four recon-flagged bugs, each with a focused test:

- dnslist fired both http and https per candidate and counted a "found"
  on any non-error response (incl 404 and wildcard catch-all redirects),
  so every host double-counted and a wildcard-dns host flooded results.
  probe http then https with per-subdomain dedupe, gate on a meaningful
  (2xx, non-redirect) status, and stop chasing redirects so a catch-all
  301 reads as a redirect instead of a 200.

- fetchRobotsTXT recursed on every 301 Location with no depth limit and
  no visited set, so an A->B->A loop blew the stack. bound it to a named
  hop cap and a visited set, iteratively.

- framework cve lookup used best.version ("unknown" when the detector
  only fingerprints the framework) and threw away the version
  ExtractVersionOptimized dug out of the body, missing CVEs. reconcile
  via resolveVersion, preferring the extracted concrete version.

- subdomain takeover flagged a dangling cname whenever a no-such-host
  coincided with ANY cname (LookupCNAME echoes the host back for plain A
  records). only flag when the cname points off-host at a known
  takeoverable provider.
2026-06-10 14:47:17 -07:00
celeste 306f9a864d Merge pull request #119 from vmfunc/feat/wave2
feat: js secrets/endpoints, cors/redirect/xss probes, crawler + passive recon
2026-06-09 18:28:28 -07:00
vmfunc dbe79c495e feat(scan): add web crawler and passive subdomain/url discovery
-crawl spiders same-host links/scripts/forms through the shared httpx
client so proxy/headers/rate-limit and robots.txt are honored, bounded
by -crawl-depth. -passive pulls subdomains from keyless ct feeds (crt.sh,
certspotter) and historical urls from wayback, each source isolated so
one feed being down doesn't sink the rest and the target sees no traffic.
2026-06-09 18:11:38 -07:00
vmfunc 9401aa669e feat(scan): add cors, open-redirect and reflected-xss probes
three active web-vuln probes wired into the per-target loop:

- cors: crafts attacker origins (evil sentinel, null, prefix/suffix
  bypass, http downgrade) and flags responses that reflect them in
  access-control-allow-origin, ranking reflection+credentials high.
- redirect: injects a controlled sentinel host plus bypass variants
  (//, https:/, backslash, null-byte, userinfo @) into redirect-prone
  params and catches 30x location, meta-refresh and js redirects that
  resolve off-site.
- xss: injects a unique canary wrapped in breaking chars, classifies
  the reflection context (html/attribute/script) and reports only the
  chars that survive unescaped where they matter, so escaped
  reflections don't false-positive.

all route through httpx.Client so proxy/-H/-cookie/-rate-limit apply.
hermetic httptest coverage plus integration testbed entries.
2026-06-09 18:11:38 -07:00
vmfunc b4e78114d7 feat(js): extract secrets and endpoints from scanned javascript
the -js pipeline already pulls every <script> into a buffer but only
mined supabase jwts from it. reuse that buffer to run a credential
regex bank (aws/github/slack/stripe/google keys, pem blocks, plus
entropy-gated generic apikey/secret/token assignments) and a
linkfinder-style endpoint extractor that resolves relatives to
absolute urls. both dedupe across scripts and surface through the
existing js logger and result struct, no new flag.
2026-06-09 18:11:38 -07:00
celeste 65ce36e963 Merge pull request #118 from vmfunc/feat/httpx-client
feat: shared http client (proxy, custom headers, rate limit) + -threads clamp
2026-06-09 17:46:13 -07:00
vmfunc d0bdcf1690 feat: shared http client with proxy, custom headers and rate limiting
every scanner spun up its own &http.Client, so there was no single place
to apply a proxy, custom headers, a cookie or a rate limit. add an
internal/httpx package that builds one configured transport at startup and
hand it to every scanner via httpx.Client(timeout), keeping behavior
identical when nothing is set (plain client when Configure was never
called).

- httpx.Configure wires -proxy (http/https/socks5), -H/--header, -cookie
  and -rate-limit into a package-level RoundTripper that paces via a
  rate.Limiter and only sets headers the caller hasn't already, so a
  scanner's explicit api key still wins.
- route the scan/wordlist downloads that used http.DefaultClient through
  the shared client too; ports tcp dialing is left untouched.
- clamp -threads to a floor of 1: it feeds wg.Add across the scanners, so
  0 was a silent no-op and a negative value panicked the waitgroup.

document the new flags in the readme, usage docs and man page.
2026-06-09 17:28:14 -07:00
celeste dd0276893b Merge pull request #117 from vmfunc/ci/release-ldflags
ci(release): hoist build ldflags into one env var
2026-06-09 16:27:20 -07:00
celeste cb194406a7 Merge pull request #116 from vmfunc/test/scanner-seams
test(scan): seam shodan/securitytrails/cloudstorage/dnslist for hermetic tests
2026-06-09 16:26:40 -07:00
celeste 8823fa76b7 Merge pull request #115 from vmfunc/fix/progress-milestones
fix(output): dedupe non-tty progress milestones
2026-06-09 16:25:25 -07:00
celeste ade9860250 Merge pull request #114 from vmfunc/fix/flake-vendorhash
fix(nix): bump flake nixpkgs and refresh vendorHash
2026-06-09 16:13:04 -07:00
vmfunc 912f6e8e0e test(scan): seam shodan/securitytrails/cloudstorage/dnslist for hermetic integration tests
the remaining hardcoded base urls had no test seam, so their drivers could
only be exercised against the live apis. promote them to package vars (matching
the dirlist/git/ports pattern from #112) and route dnslist's per-host probes
through an injectable transport, then add integration tests that pin each at a
local httptest fixture. defaults equal the old const values so behavior is
unchanged.
2026-06-09 16:07:20 -07:00
vmfunc 1d2bc64dbc ci(release): hoist build ldflags into one env var
the 7 cross-compile steps each repeated the same ldflags string, easy to
drift; write it once in the extract-version step and reference $LDFLAGS
2026-06-09 16:03:56 -07:00
vmfunc 094f1e7806 fix(output): dedupe non-tty progress milestones
concurrent workers (-threads 40) all hit the same milestone bucket on
increment, spamming ~10 duplicate [25%] lines. track the last printed
bucket under p.mu and only print when it advances.
2026-06-09 16:03:52 -07:00
vmfunc 9f8045be22 fix(nix): bump flake nixpkgs and refresh vendorHash
the pinned nixpkgs shipped go 1.25.5 but go.mod now needs >= 1.25.7, so the
flake build failed (GOTOOLCHAIN=local). bump the lock to a nixpkgs with go
1.26.3, and refresh the stale vendorHash for the current deps. `nix build`
and `nix run github:vmfunc/sif` work again.
2026-06-09 15:56:33 -07:00
celeste 83ac92a4b8 Merge pull request #113 from vmfunc/fix/frameworks-confidence
fix(frameworks): require a real signature match + fix cve version matching
2026-06-09 15:02:40 -07:00
celeste 7f0e4cd128 Merge pull request #112 from vmfunc/test/e2e-integration
test: hermetic e2e integration suite
2026-06-09 14:50:54 -07:00
vmfunc 29d94e5352 fix(frameworks): require a real signature match, fix cve version matching
- recenter the detection confidence (sigmoid centered at 0.3) so a single weak
  signature match no longer clears the 0.5 threshold. before, sigmoid(0) was 0.5
  so *any* match counted as a detection - that's the magento-on-a-plain-page
  false positive from the live run. real detections match ~50%+ of signature
  weight, so the existing detector tests are unaffected
- getVulnerabilities matched affected versions with a raw string prefix, so "4.2"
  also matched "4.20"; match only on dotted boundaries now
- break confidence ties on name so the picked framework is deterministic
- add regression tests for the confidence floor and the version boundary
2026-06-09 14:46:10 -07:00
celeste 05fa35d945 Merge pull request #111 from vmfunc/feat/version
feat: stamp and surface the build version
2026-06-09 14:34:25 -07:00
vmfunc ce3075ad91 test: hermetic e2e integration suite
- make the four wordlist base urls (dirlist/dnslist/git/ports) package vars
  instead of consts so tests can repoint them at a local fixture; the default
  values are byte-for-byte unchanged
- add internal/scan/integration_test.go behind a //go:build integration tag: it
  stands up a local "vulnerable app" httptest server with planted artifacts and
  runs git/dirlist/cms/headers/sql/lfi/ports against it, asserting real findings
- go.yml runs them via `go test -tags=integration`; the default test run is
  untouched (the tag keeps them out)
- document the integration run in docs/development.md
2026-06-09 14:32:26 -07:00
vmfunc 661480a56d feat: stamp and surface the build version
- add internal/version: resolve from the release ldflag, else the go build
  info (module tag / vcs revision), else "dev"
- show the version on the boot banner and for `sif version`
- Makefile now stamps `make` builds via git describe (matching the release ci),
  so local/go-install builds report a real version instead of "dev"
- patchnotes.ShowOnce skips pseudo/dev versions so non-release builds dont make
  a doomed github call
- document sif version / sif patchnote / SIF_NO_PATCHNOTES in the readme + usage
2026-06-09 14:18:28 -07:00
celeste 76e8893ee2 Merge pull request #110 from vmfunc/fix/supabase-timeout
fix(js): give supabase requests a real timeout
2026-06-09 14:11:53 -07:00
celeste 1231ca3179 Merge pull request #109 from vmfunc/refactor/url-helper
refactor: dedupe url scheme stripping
2026-06-09 14:11:49 -07:00
vmfunc eb33321102 fix(js): give supabase requests a real timeout
doSupabaseRequest and the signup call used a bare http.Client{} with no
timeout, so a slow supabase project could hang the whole js scan. thread the
scan's --timeout down through ScanSupabase into every supabase request.
2026-06-09 13:56:01 -07:00
vmfunc 133224c348 refactor: dedupe url scheme stripping
`strings.Split(url, "://")[1]` was copy-pasted in 18 spots and panics on a
schemeless target (index out of range). add a small stripScheme helper in the
scan package - and a guarded equivalent in logger, which cant import scan - so
a bare host degrades gracefully instead of crashing the scan.
2026-06-09 13:51:04 -07:00
celeste 4c650e23e3 Merge pull request #108 from vmfunc/feat/manpage
feat: ship a man page
2026-06-09 13:47:08 -07:00
celeste 75e953cda7 Merge pull request #107 from vmfunc/feat/patchnotes
feat: show release notes via patch notes
2026-06-09 13:47:04 -07:00
vmfunc f7ef71e835 feat: ship a man page
adds man/sif.1 covering the targets/scans/options/modules flags, the
patchnote and version commands, env vars (incl SIF_NO_PATCHNOTES), and a
few examples. the install/uninstall make targets now drop it in
$(PREFIX)/share/man/man1.
2026-06-08 19:13:50 -07:00
vmfunc 5e10c1857b feat: show release notes via patch notes
- `sif patchnote` (also `-pn`) fetches the latest github release and renders
  its notes with glamour
- on the first run of a new version those notes are shown once, then recorded
  so they dont show again - best-effort, so dev builds, the SIF_NO_PATCHNOTES
  opt-out, and any network failure stay quiet
- wire up `var version` so the release `-X main.version` ldflag actually lands,
  and add `sif version`
2026-06-08 19:13:03 -07:00
celeste 3c070a621d Merge pull request #106 from vmfunc/feat/security-headers
feat: security-headers scan + scanner fixes and cleanup
2026-06-08 19:12:40 -07:00
vmfunc 94b99ade5a docs: fix broken -all example and document -sh
- the readme headline used -all, which isn't a real flag (goflags fatals
  on unknown flags), so the three -all examples now use actual flags
- document the new -sh security-header scan in the readme table, usage.md
  and scans.md, and fix the -headers section (it dumps headers; -sh grades
  them)
- bump the documented go version 1.23 -> 1.25 to match go.mod
2026-06-08 18:53:06 -07:00
vmfunc 9326465a46 chore: drop decorative emoji and redundant comments
strips the emoji from the subdomaintakeover/cloudstorage/supabase log
prefixes and trims comments that just restate the code (the
parameters/returns block on SubdomainTakeover, a couple of "this provides
type safety" notes) so the scanners read like whois.go/headers.go.
2026-06-08 18:53:06 -07:00
vmfunc 50c9933812 chore: drop the unused worker pool
internal/worker.Pool[T,R] (run/runwithfilter/foreach) was added with tests
but never wired into anything - the scanners all hand-roll their own
waitgroup loops. dead weight, so remove it.
2026-06-08 18:53:06 -07:00
vmfunc af0167859a fix: data races and a progress divide-by-zero
- git.go and dork.go appended to a shared results slice from every worker
  goroutine with no mutex - a real race that -race never caught since
  neither has a test. guard the appends like dirlist/dnslist already do.
- progress.go's non-tty milestone path divided by total with no guard, so
  a zero-total bar panicked when output was piped/redirected. bail early
  on total <= 0 to match the tty branch, and add an output test for it.
2026-06-08 18:53:06 -07:00
vmfunc 7efd62c804 feat: add security header analysis scan
adds a -sh/--security-headers scan that flags missing or weak response
headers (hsts, csp, x-frame-options, x-content-type-options,
referrer-policy, permissions-policy, coop) and headers that leak server
internals (server, x-powered-by, ...). hsts is only graded over https
where it actually applies. wired into App.Run and the module results.
2026-06-08 18:53:06 -07:00
celeste 1a1ff446d8 Merge pull request #105 from vmfunc/chore/copyright-2026
chore: bump copyright headers to 2026
2026-06-08 18:46:55 -07:00
vmfunc 648fa8d2c8 chore: bump copyright headers to 2026
rolls the (c) 2022-2025 banner to 2022-2026 across all go files, the
startup banner in sif.go, and the header-check workflow's expected
format. comment-only, nothing else changes.
2026-06-08 18:30:48 -07:00
celeste 8918be4797 Merge pull request #100 from vmfunc/cleanup/lint-exclusions-post-98
chore: resolve lint exclusions added in #98
2026-06-08 17:53:06 -07:00
vmfunc 4fc0df5a01 fix(templates): guard tar extraction against path traversal
The nuclei-templates tarball is fetched over the network and its entry
names flowed directly into os.Mkdir/os.Create, so a malicious or
compromised archive could write outside the extraction directory
("Zip Slip", CWE-22). Resolve each entry against the working directory
and reject any path that escapes it before touching the filesystem.

CodeQL flagged this as a high-severity alert on the lines this branch
already touched. gosec's G305 fires on filepath.Join with archive data
regardless of the traversal guard, so it's excluded with a note.
2026-06-08 17:35:05 -07:00
Claude ece5b2b0b0 chore: clean up lint exclusions deferred in #98
Address pre-existing code issues that were suppressed in #98 to keep that
PR scoped to the Go 1.25 / golangci-lint v2 toolchain bump.

https://claude.ai/code/session_01S433Zq3Xzm3ZethsqkyaZF
2026-06-08 16:56:49 -07:00
celeste 79f6b95eaf Merge pull request #101 from vmfunc/dependabot/go_modules/go_modules-d7145cbce0
chore(deps): bump github.com/go-git/go-git/v5 from 5.18.0 to 5.19.1 in the go_modules group across 1 directory
2026-06-07 11:05:53 -07:00
celeste fe2ab240a4 Merge pull request #103 from vmfunc/dependabot/github_actions/actions/dependency-review-action-5
chore(deps): bump actions/dependency-review-action from 4 to 5
2026-06-07 11:05:50 -07:00
dependabot[bot] 4c6cebf4de chore(deps): bump github.com/go-git/go-git/v5
Bumps the go_modules group with 1 update in the / directory: [github.com/go-git/go-git/v5](https://github.com/go-git/go-git).


Updates `github.com/go-git/go-git/v5` from 5.18.0 to 5.19.1
- [Release notes](https://github.com/go-git/go-git/releases)
- [Changelog](https://github.com/go-git/go-git/blob/main/HISTORY.md)
- [Commits](https://github.com/go-git/go-git/compare/v5.18.0...v5.19.1)

---
updated-dependencies:
- dependency-name: github.com/go-git/go-git/v5
  dependency-version: 5.19.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-07 17:48:20 +00:00
dependabot[bot] c7a244ed2f chore(deps): bump actions/dependency-review-action from 4 to 5
Bumps [actions/dependency-review-action](https://github.com/actions/dependency-review-action) from 4 to 5.
- [Release notes](https://github.com/actions/dependency-review-action/releases)
- [Commits](https://github.com/actions/dependency-review-action/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/dependency-review-action
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-07 17:47:57 +00:00
celeste 4d3b87a2cb chore: fix Discord link in README
Updated Discord invite link in README.md
2026-06-07 10:42:02 -07:00
celeste bf802a7c0b Merge pull request #99 from vmfunc/dependabot/go_modules/go_modules-c9a791322e
chore(deps): bump go.opentelemetry.io/otel from 1.38.0 to 1.41.0 in the go_modules group across 1 directory
2026-04-24 13:54:05 -07:00
dependabot[bot] c6143f7f39 chore(deps): bump go.opentelemetry.io/otel
Bumps the go_modules group with 1 update in the / directory: [go.opentelemetry.io/otel](https://github.com/open-telemetry/opentelemetry-go).


Updates `go.opentelemetry.io/otel` from 1.38.0 to 1.41.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.38.0...v1.41.0)

---
updated-dependencies:
- dependency-name: go.opentelemetry.io/otel
  dependency-version: 1.41.0
  dependency-type: indirect
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-24 20:33:16 +00:00
celeste fbcf96d557 Merge pull request #96 from vmfunc/dependabot/go_modules/go_modules-c1d9254ec5
chore(deps): bump the go_modules group across 1 directory with 5 updates
2026-04-24 09:24:47 -07:00
celeste cecaa8aa3b Merge pull request #97 from vmfunc/dependabot/go_modules/github.com/projectdiscovery/nuclei/v3-3.8.0
chore(deps): bump github.com/projectdiscovery/nuclei/v3 from 3.7.1 to 3.8.0
2026-04-24 09:24:44 -07:00
dependabot[bot] 571711ff6b chore(deps): bump github.com/projectdiscovery/nuclei/v3
Bumps [github.com/projectdiscovery/nuclei/v3](https://github.com/projectdiscovery/nuclei) from 3.7.1 to 3.8.0.
- [Release notes](https://github.com/projectdiscovery/nuclei/releases)
- [Commits](https://github.com/projectdiscovery/nuclei/compare/v3.7.1...v3.8.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-24 16:21:28 +00:00
celeste e86a917364 Merge pull request #98 from vmfunc/ci/bump-go-1.25
ci: bump go to 1.25 and migrate golangci-lint to v2
2026-04-24 09:19:55 -07:00
vmfunc 09314edf5c ci: suppress v2-only staticcheck rules and 3 stray errcheck cases
second pass after v2 surfaced more old-code noise:
  * staticcheck ST1000/ST1003 were the v1 'stylecheck' linter
    (never enabled here); disabled to preserve prior parity
  * errcheck can't match (io.Closer).Close against concrete
    types (tarball, tcp, logger); added narrow file+text rules

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:04:50 -07:00
vmfunc 9863a252d8 ci: appease yamllint and relax v2 linters to match v1 parity
yamllint wanted a '---' document start and lines <=80 chars.

golangci-lint v2 surfaces 63 latent issues that v1.64.8 missed:
  * staticcheck QF1003/QF1012 (new quickfix checks) -- disabled
  * errcheck on idiomatic Body.Close / fmt.Fprint -- excluded
  * gocritic importShadow (nuclei output pkg alias) -- excluded
  * gocritic rangeValCopy (nuclei value-type iteration) -- excluded
  * gosec G301/G302 (log perms, intentional) -- excluded
  * noctx in 3 scan files -- excluded by path until refactored

real fixes for the noctx/perms cases are a separate follow-up;
this PR is strictly the toolchain + lint-action bump.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 09:01:33 -07:00
vmfunc 84b0b81996 ci: bump go toolchain to 1.25 and migrate golangci-lint to v2
nuclei/v3 v3.8.0 (PR #97) requires go >= 1.25.7 in go.mod.
this breaks the existing CI pinned to go 1.24:

  - build: "go.mod requires go >= 1.25.7 (running go 1.24.13;
    GOTOOLCHAIN=local)"
  - lint:  "the Go language version (go1.24) used to build
    golangci-lint is lower than the targeted Go version (1.25.7)"

bumps setup-go to 1.25 across all workflows and moves the lint
job to golangci-lint-action v8 with golangci-lint v2.11.4 (built
with go 1.25). migrates .golangci.yml to the v2 schema:

  - version: "2"
  - linters-settings -> linters.settings
  - issues.exclude-rules -> linters.exclusions.rules
  - drop gosimple (merged into staticcheck in v2)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 08:53:43 -07:00
dependabot[bot] 7123e392c9 chore(deps): bump the go_modules group across 1 directory with 5 updates
Bumps the go_modules group with 3 updates in the / directory: [github.com/projectdiscovery/nuclei/v3](https://github.com/projectdiscovery/nuclei), [github.com/Azure/go-ntlmssp](https://github.com/Azure/go-ntlmssp) and [github.com/go-git/go-git/v5](https://github.com/go-git/go-git).


Updates `github.com/projectdiscovery/nuclei/v3` from 3.7.1 to 3.8.0
- [Release notes](https://github.com/projectdiscovery/nuclei/releases)
- [Commits](https://github.com/projectdiscovery/nuclei/compare/v3.7.1...v3.8.0)

Updates `github.com/Azure/go-ntlmssp` from 0.1.0 to 0.1.1
- [Release notes](https://github.com/Azure/go-ntlmssp/releases)
- [Commits](https://github.com/Azure/go-ntlmssp/compare/v0.1.0...v0.1.1)

Updates `github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream` from 1.6.11 to 1.7.8
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/rum/v1.6.11...service/m2/v1.7.8)

Updates `github.com/buger/jsonparser` from 1.1.1 to 1.1.2
- [Release notes](https://github.com/buger/jsonparser/releases)
- [Commits](https://github.com/buger/jsonparser/compare/v1.1.1...v1.1.2)

Updates `github.com/go-git/go-git/v5` from 5.17.1 to 5.18.0
- [Release notes](https://github.com/go-git/go-git/releases)
- [Commits](https://github.com/go-git/go-git/compare/v5.17.1...v5.18.0)

---
updated-dependencies:
- dependency-name: github.com/projectdiscovery/nuclei/v3
  dependency-version: 3.8.0
  dependency-type: direct:production
  dependency-group: go_modules
- dependency-name: github.com/Azure/go-ntlmssp
  dependency-version: 0.1.1
  dependency-type: indirect
  dependency-group: go_modules
- dependency-name: github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream
  dependency-version: 1.7.8
  dependency-type: indirect
  dependency-group: go_modules
- dependency-name: github.com/buger/jsonparser
  dependency-version: 1.1.2
  dependency-type: indirect
  dependency-group: go_modules
- dependency-name: github.com/go-git/go-git/v5
  dependency-version: 5.18.0
  dependency-type: indirect
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-24 07:40:32 +00:00
celeste 383e645d85 Merge pull request #91 from vmfunc/dependabot/github_actions/codecov/codecov-action-6
chore(deps): bump codecov/codecov-action from 5 to 6
2026-04-24 00:37:55 -07:00
celeste 30bf148768 Merge pull request #92 from vmfunc/dependabot/go_modules/go_modules-f67f74747b
chore(deps): bump github.com/go-git/go-git/v5 from 5.16.5 to 5.17.1 in the go_modules group across 1 directory
2026-04-24 00:37:42 -07:00
celeste 3d04a61b27 Merge pull request #89 from vmfunc/dependabot/go_modules/github.com/charmbracelet/log-1.0.0
chore(deps): bump github.com/charmbracelet/log from 0.4.2 to 1.0.0
2026-04-24 00:37:22 -07:00
celeste 9bd1d8cd14 Merge pull request #93 from vmfunc/dependabot/github_actions/actions/github-script-9
chore(deps): bump actions/github-script from 8 to 9
2026-04-24 00:37:01 -07:00
celeste 68fed81eee Merge pull request #94 from vmfunc/dependabot/go_modules/github.com/projectdiscovery/utils-0.10.1
chore(deps): bump github.com/projectdiscovery/utils from 0.9.0 to 0.10.1
2026-04-24 00:36:47 -07:00
celeste cf72dfff0e Merge pull request #95 from vmfunc/dependabot/github_actions/softprops/action-gh-release-3
chore(deps): bump softprops/action-gh-release from 2 to 3
2026-04-24 00:36:20 -07:00
dependabot[bot] a469463c19 chore(deps): bump softprops/action-gh-release from 2 to 3
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-17 12:45:52 +00:00
dependabot[bot] c527668c60 chore(deps): bump github.com/projectdiscovery/utils from 0.9.0 to 0.10.1
Bumps [github.com/projectdiscovery/utils](https://github.com/projectdiscovery/utils) from 0.9.0 to 0.10.1.
- [Release notes](https://github.com/projectdiscovery/utils/releases)
- [Changelog](https://github.com/projectdiscovery/utils/blob/main/CHANGELOG.md)
- [Commits](https://github.com/projectdiscovery/utils/compare/v0.9.0...v0.10.1)

---
updated-dependencies:
- dependency-name: github.com/projectdiscovery/utils
  dependency-version: 0.10.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-17 12:43:54 +00:00
dependabot[bot] 4917eaf7e7 chore(deps): bump actions/github-script from 8 to 9
Bumps [actions/github-script](https://github.com/actions/github-script) from 8 to 9.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v8...v9)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-10 12:45:52 +00:00
dependabot[bot] 4152e74ade chore(deps): bump github.com/go-git/go-git/v5
Bumps the go_modules group with 1 update in the / directory: [github.com/go-git/go-git/v5](https://github.com/go-git/go-git).


Updates `github.com/go-git/go-git/v5` from 5.16.5 to 5.17.1
- [Release notes](https://github.com/go-git/go-git/releases)
- [Commits](https://github.com/go-git/go-git/compare/v5.16.5...v5.17.1)

---
updated-dependencies:
- dependency-name: github.com/go-git/go-git/v5
  dependency-version: 5.17.1
  dependency-type: indirect
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 17:15:28 +00:00
dependabot[bot] 3e0cbbc5dd chore(deps): bump codecov/codecov-action from 5 to 6
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5 to 6.
- [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/v5...v6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-27 12:45:10 +00:00
dependabot[bot] abe8bac165 chore(deps): bump github.com/charmbracelet/log from 0.4.2 to 1.0.0
Bumps [github.com/charmbracelet/log](https://github.com/charmbracelet/log) from 0.4.2 to 1.0.0.
- [Release notes](https://github.com/charmbracelet/log/releases)
- [Commits](https://github.com/charmbracelet/log/compare/v0.4.2...v1.0.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-13 12:44:04 +00:00
celeste d6c52d3dd8 Merge pull request #87 from vmfunc/dependabot/go_modules/github.com/projectdiscovery/nuclei/v3-3.7.1
chore(deps): bump github.com/projectdiscovery/nuclei/v3 from 3.7.0 to 3.7.1
2026-03-06 22:01:46 +01:00
celeste 439e829c1b Merge pull request #88 from vmfunc/dependabot/go_modules/github.com/antchfx/htmlquery-1.3.6
chore(deps): bump github.com/antchfx/htmlquery from 1.3.5 to 1.3.6
2026-03-06 22:01:27 +01:00
dependabot[bot] d5067d08b2 chore(deps): bump github.com/antchfx/htmlquery from 1.3.5 to 1.3.6
Bumps [github.com/antchfx/htmlquery](https://github.com/antchfx/htmlquery) from 1.3.5 to 1.3.6.
- [Release notes](https://github.com/antchfx/htmlquery/releases)
- [Commits](https://github.com/antchfx/htmlquery/compare/v1.3.5...v1.3.6)

---
updated-dependencies:
- dependency-name: github.com/antchfx/htmlquery
  dependency-version: 1.3.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-06 12:44:13 +00:00
dependabot[bot] 98c987bfbb chore(deps): bump github.com/projectdiscovery/nuclei/v3
Bumps [github.com/projectdiscovery/nuclei/v3](https://github.com/projectdiscovery/nuclei) from 3.7.0 to 3.7.1.
- [Release notes](https://github.com/projectdiscovery/nuclei/releases)
- [Commits](https://github.com/projectdiscovery/nuclei/compare/v3.7.0...v3.7.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-06 12:44:06 +00:00
celeste 237dfde4d1 Merge pull request #86 from vmfunc/chore/bump-setup-go-v6
chore: bump setup-go to v6, drop go 1.23 from CI matrix
2026-03-01 05:13:54 +01:00
vmfunc c69bbe1232 chore: bump setup-go to v6, drop go 1.23 from CI matrix
go.mod requires >= 1.24.2 so the 1.23 matrix entry was already dead.
setup-go v6 sets GOTOOLCHAIN=local which makes it fail explicitly.
2026-03-01 05:07:23 +01:00
celeste d52bcfc736 Merge pull request #84 from vmfunc/dependabot/go_modules/go_modules-9655125a49
chore(deps): bump the go_modules group across 1 directory with 2 updates
2026-03-01 05:05:02 +01:00
celeste fcffe18ba5 Merge pull request #85 from vmfunc/dependabot/github_actions/github/codeql-action-4
chore(deps): bump github/codeql-action from 3 to 4
2026-03-01 05:04:59 +01:00
dependabot[bot] 7749b50d25 chore(deps): bump github/codeql-action from 3 to 4
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3 to 4.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: github/codeql-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-27 12:45:54 +00:00
dependabot[bot] 93783d8bd3 chore(deps): bump the go_modules group across 1 directory with 2 updates
Bumps the go_modules group with 2 updates in the / directory: [filippo.io/edwards25519](https://github.com/FiloSottile/edwards25519) and [github.com/cloudflare/circl](https://github.com/cloudflare/circl).


Updates `filippo.io/edwards25519` from 1.1.0 to 1.1.1
- [Commits](https://github.com/FiloSottile/edwards25519/compare/v1.1.0...v1.1.1)

Updates `github.com/cloudflare/circl` from 1.6.1 to 1.6.3
- [Release notes](https://github.com/cloudflare/circl/releases)
- [Commits](https://github.com/cloudflare/circl/compare/v1.6.1...v1.6.3)

---
updated-dependencies:
- dependency-name: filippo.io/edwards25519
  dependency-version: 1.1.1
  dependency-type: indirect
  dependency-group: go_modules
- dependency-name: github.com/cloudflare/circl
  dependency-version: 1.6.3
  dependency-type: indirect
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-25 19:34:14 +00:00
dependabot[bot] fef7806ac2 chore(deps): bump github.com/refraction-networking/utls (#78)
Bumps the go_modules group with 1 update in the / directory: [github.com/refraction-networking/utls](https://github.com/refraction-networking/utls).


Updates `github.com/refraction-networking/utls` from 1.8.1 to 1.8.2
- [Release notes](https://github.com/refraction-networking/utls/releases)
- [Commits](https://github.com/refraction-networking/utls/compare/v1.8.1...v1.8.2)

---
updated-dependencies:
- dependency-name: github.com/refraction-networking/utls
  dependency-version: 1.8.2
  dependency-type: indirect
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-23 02:11:40 +01:00
dependabot[bot] 422245fe7f chore(deps): bump actions/labeler from 5 to 6 (#80)
Bumps [actions/labeler](https://github.com/actions/labeler) from 5 to 6.
- [Release notes](https://github.com/actions/labeler/releases)
- [Commits](https://github.com/actions/labeler/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/labeler
  dependency-version: '6'
  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-02-23 02:11:25 +01:00
dependabot[bot] 24a9f4411f chore(deps): bump reviewdog/action-misspell from 1.26.0 to 1.27.0 (#82)
Bumps [reviewdog/action-misspell](https://github.com/reviewdog/action-misspell) from 1.26.0 to 1.27.0.
- [Release notes](https://github.com/reviewdog/action-misspell/releases)
- [Commits](https://github.com/reviewdog/action-misspell/compare/v1.26.0...v1.27.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-23 02:10:52 +01:00
dependabot[bot] 22936a3281 chore(deps): bump reviewdog/action-yamllint from 1.19.0 to 1.21.0 (#83)
Bumps [reviewdog/action-yamllint](https://github.com/reviewdog/action-yamllint) from 1.19.0 to 1.21.0.
- [Release notes](https://github.com/reviewdog/action-yamllint/releases)
- [Commits](https://github.com/reviewdog/action-yamllint/compare/v1.19.0...v1.21.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-23 02:10:40 +01:00
vmfunc 495d2c5496 feat: add securitytrails integration for domain discovery + target expansion 2026-02-17 13:38:07 +01:00
dependabot[bot] 5ddfbc6204 chore(deps): bump github.com/likexian/whois from 1.15.1 to 1.15.7 (#67)
Bumps [github.com/likexian/whois](https://github.com/likexian/whois) from 1.15.1 to 1.15.7.
- [Release notes](https://github.com/likexian/whois/releases)
- [Commits](https://github.com/likexian/whois/compare/v1.15.1...v1.15.7)

---
updated-dependencies:
- dependency-name: github.com/likexian/whois
  dependency-version: 1.15.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-13 15:32:55 +01:00
dependabot[bot] b522aa3206 chore(deps): bump github.com/charmbracelet/log from 0.2.4 to 0.4.2 (#74)
Bumps [github.com/charmbracelet/log](https://github.com/charmbracelet/log) from 0.2.4 to 0.4.2.
- [Release notes](https://github.com/charmbracelet/log/releases)
- [Commits](https://github.com/charmbracelet/log/compare/v0.2.4...v0.4.2)

---
updated-dependencies:
- dependency-name: github.com/charmbracelet/log
  dependency-version: 0.4.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-13 15:11:38 +01:00
dependabot[bot] efd089a9b6 chore(deps): bump ossf/scorecard-action from 2.4.0 to 2.4.3 (#66)
Bumps [ossf/scorecard-action](https://github.com/ossf/scorecard-action) from 2.4.0 to 2.4.3.
- [Release notes](https://github.com/ossf/scorecard-action/releases)
- [Changelog](https://github.com/ossf/scorecard-action/blob/main/RELEASE.md)
- [Commits](https://github.com/ossf/scorecard-action/compare/v2.4.0...v2.4.3)

---
updated-dependencies:
- dependency-name: ossf/scorecard-action
  dependency-version: 2.4.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-13 15:11:30 +01:00
dependabot[bot] dd9db0dfd6 chore(deps): bump reviewdog/action-shellcheck from 1.27.0 to 1.32.0 (#70)
Bumps [reviewdog/action-shellcheck](https://github.com/reviewdog/action-shellcheck) from 1.27.0 to 1.32.0.
- [Release notes](https://github.com/reviewdog/action-shellcheck/releases)
- [Commits](https://github.com/reviewdog/action-shellcheck/compare/v1.27.0...v1.32.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-13 15:11:26 +01:00
dependabot[bot] 1eab6143bb chore(deps): bump reviewdog/action-markdownlint from 0.24.0 to 0.26.2 (#73)
Bumps [reviewdog/action-markdownlint](https://github.com/reviewdog/action-markdownlint) from 0.24.0 to 0.26.2.
- [Release notes](https://github.com/reviewdog/action-markdownlint/releases)
- [Commits](https://github.com/reviewdog/action-markdownlint/compare/v0.24.0...v0.26.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-13 15:11:23 +01:00
dependabot[bot] 418180a124 chore(deps): bump actions/github-script from 7 to 8 (#77)
Bumps [actions/github-script](https://github.com/actions/github-script) from 7 to 8.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v8)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: '8'
  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-02-13 15:05:54 +01:00
dependabot[bot] 6f4144efe1 chore(deps): bump actions/checkout from 4 to 6 (#68)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  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-02-13 14:47:32 +01:00
vmfunc a05d6ada56 ci: add pr bot for auto-labeling + rewrite release workflow for semver tags
pr-bot labels PRs by area (scan, nuclei, modules, ci, deps, etc) and size
(xs/s/m/l/xl), posts a summary comment with file stats breakdown.

release workflow now triggers on v* tags instead of every push to main -
extracts version from tag, injects via ldflags, auto-generates changelog
from commits since last release, includes install instructions in the
release body. prerelease detection for rc/beta tags.

Signed-off-by: vmfunc <celeste@linux.com>
2026-02-13 02:19:19 +01:00
vmfunc 75da3e3131 fix: resolve all golangci-lint issues across codebase
- noctx: use http.NewRequestWithContext instead of http.Get/client.Get
- bodyclose: close response bodies on all code paths
- httpNoBody: use http.NoBody instead of nil for GET request bodies
- ifElseChain: convert if/else chains to switch in sif.go
- sloppyReassign: use := in logger.go where possible
- nilnil: annotate intentional nil,nil returns in lfi.go, sql.go
- errcheck: handle template install error in nuclei.go
- govet copylock: pass mutex by pointer in executor.go
- log.Fatalf: replace with log.Errorf+continue in api mode
2026-02-13 02:11:17 +01:00
vmfunc f5251d0c44 chore: strengthen golangci-lint config - add gosec, errorlint, nilnil, wastedassign, usetesting linters
adds security and correctness linters, suppresses noisy checks
(fieldalignment, shadow, unusedwrite, nestingReduce), excludes
logger.Write from errcheck since log writes are best-effort
2026-02-13 02:11:03 +01:00
vmfunc e2198e932b ci: replace qodana with codeql - no external tokens needed
Signed-off-by: vmfunc <celeste@linux.com>
2026-02-13 02:08:32 +01:00
vmfunc bad5b598c9 test: add fuzz tests for LFI detection, SQL patterns, version parsing
fuzz targets: DetectLFIFromResponse, isAdminPanel, databaseErrorPatterns,
isValidVersionString, ExtractVersionOptimized - should bump the scorecard
fuzzing check.

Signed-off-by: vmfunc <celeste@linux.com>
2026-02-13 01:57:46 +01:00
vmfunc c85201b1ed ci: pin govulncheck to v1.1.4 - fixes scorecard pinned-dependencies
Signed-off-by: vmfunc <celeste@linux.com>
2026-02-13 01:57:36 +01:00
vmfunc 45a384bdc9 add SECURITY.md - fixes scorecard security-policy check
Signed-off-by: vmfunc <celeste@linux.com>
2026-02-13 01:57:31 +01:00
vmfunc fcf9291653 ci: add explicit permissions to all workflows - fixes scorecard token-permissions
Signed-off-by: vmfunc <celeste@linux.com>
2026-02-13 01:40:22 +01:00
vmfunc e94fda0acf deps: bump go-git to v5.16.5 - fixes CVE-2026-25934
Signed-off-by: vmfunc <celeste@linux.com>
2026-02-13 01:39:00 +01:00
vmfunc 03a9488b65 internal/scan: migrate nuclei integration to v3 SDK
replace ~100 lines of manual nuclei v2 plumbing (catalog, loader, core,
protocolstate, protocolinit, hosterrorscache, interactsh, reporting,
ratelimit, testutils) with the v3 lib SDK - NewNucleiEngineCtx +
functional options.

drops direct ratelimit dep, mholt/archiver and nwaples/rardecode
(resolves dependabot CVE alerts for path traversal + DoS).

Signed-off-by: vmfunc <celeste@linux.com>
2026-02-13 01:22:25 +01:00
vmfunc 83702e9a41 ci: overhaul workflows - lint, security scanning, release hardening
- add golangci-lint job to go.yml (parallel with build+test)
- add Go 1.23/1.24 version matrix, coverage only on 1.24
- upgrade setup-go@v4 to v5, codecov@v4 to v5 across all workflows
- fix check-large-files bug (find|while never exits 1), exclude .git/
- add concurrency groups to push+PR workflows (no duplicate runs)
- lowercase all workflow names to match project voice
- add gosec, errorlint, gocognit, nilnil, wastedassign, usetesting linters
- remove deprecated exportloopref (Go 1.22 fixed loop var capture)
- new: govulncheck.yml - Go vuln scanner with call-graph analysis
- new: scorecard.yml - OpenSSF supply chain scorecard
- new: dependabot.yml - auto-update Go deps + Actions versions
- release: SHA256 checksums + SBOM generation for all artifacts
- add CODEOWNERS
2026-02-13 01:09:57 +01:00
vmfunc 426a301182 deps: bump projectdiscovery/utils to v0.9.0
Signed-off-by: vmfunc <celeste@linux.com>
2026-02-13 01:03:47 +01:00
vmfunc 953ef299c9 deps: bump goflags to v0.1.74
Signed-off-by: vmfunc <celeste@linux.com>
2026-02-13 01:03:46 +01:00
vmfunc 548c2110d4 update funding.yml with proper sponsor info 2026-02-13 00:26:04 +01:00
vmfunc 4f42c52964 add nixpkgs install instructions and badge to readme 2026-02-13 00:12:28 +01:00
vmfunc 5a557eb20a Merge pull request #62 from 0x4bs3nt/feat/builtin-shodan
feat(modules): builtin shodan scan as module
2026-02-08 21:31:07 +01:00
vmfunc f50f1b933a Merge branch 'main' into feat/builtin-shodan 2026-02-08 19:22:32 +01:00
vmfunc 6f460425be Merge pull request #63 from 0x4bs3nt/feat/builtin-whois
feat(modules): builtin whois scan as module
2026-02-08 14:12:27 +01:00
vmfunc 261dbea356 Merge pull request #64 from 0x4bs3nt/feat/builtin-frameworks
feat(modules): builtin framework detection as module
2026-02-08 14:11:56 +01:00
vmfunc 16ea9047f0 Merge branch 'main' into feat/builtin-frameworks 2026-01-12 11:22:56 +01:00
vmfunc 39bd115d3c Merge branch 'main' into feat/builtin-shodan 2026-01-12 11:22:36 +01:00
vmfunc ccf093b7e9 fix: rename to snakecase 2026-01-12 11:19:54 +01:00
vmfunc b5398ec687 fix: renamed whois module file
Renamed whois scan module file to differentiate from legacy whois scan
file.
2026-01-12 11:19:54 +01:00
vmfunc b298e2ec2c fix(conflicts): fix PR conflicts on 2026-01-12 11:19:48 +01:00
vmfunc 812d0b3e53 Merge pull request #61 from 0x4bs3nt/feat/builtin-nuclei
feat(modules): builtin nuclei scan as module
2026-01-11 16:39:18 -08:00
vmfunc 95cebab47f fix: rename to snakecase 2026-01-07 22:39:56 +01:00
vmfunc 579f5aff4b fix: rename to snakecase 2026-01-07 22:39:35 +01:00
vmfunc 6df46b635a fix: rename to snakecase 2026-01-07 22:39:19 +01:00
vmfunc 4a6364aba9 fix: shodan module file rename
Renamed shodan module file to differentiate from legacy shodan lookup
files.
2026-01-07 22:35:51 +01:00
vmfunc e7db0777c2 fix: frameworks module file rename
Renamed frameworks module file to differentiate from legacy framework
scans.
2026-01-07 22:34:53 +01:00
vmfunc 9767a6b189 fix: renamed nuclei module file
Renamed the nuclei module file to differentiate from the nuclei legacy
scan file.
2026-01-07 22:33:49 +01:00
vmfunc abb992aefd fix: colorizer exception
Fixed Nuclei giving off exception for missing Colorizer on the executor
options.
2026-01-07 19:06:51 +01:00
vmfunc 3c160de165 fix(nuclei): logdir, headless option and hosterrorscache
Set the HostErrorsCache executor option, cache is created but not passed
as option.
Headless initialization is required even without browser templates.
Nuclei expects project file to be set
2026-01-07 17:01:22 +01:00
vmfunc 66a752d604 fix: nuclei scan nil pointer dereference
Fixed nil pointer dereference issues in the nuclei scan running as a
module
2026-01-07 15:09:49 +01:00
vmfunc 45f341c97f feat(modules): legacy nuclei scan
Converted nuclei scan to be able to run as module.
2026-01-07 13:07:35 +01:00
vmfunc 0383c49bbd feat(modules): legacy shodan scan
Converted legacy Go shodan scan to be able to run as module.
2026-01-07 13:05:08 +01:00
vmfunc e5e831541f feat(modules): legacy framework scan
Converted legacy framework scan to be able to run as module.
2026-01-07 13:02:40 +01:00
vmfunc f309198f69 feat(modules): infra for builtin modules
Infrastructure preparation for builtin complex Go module registration.
2026-01-07 12:56:17 +01:00
vmfunc 689d575282 Merge pull request #56 from 0x4bs3nt/feat/astro-framework-detection
feat(frameworks): add Astro framework detection
2026-01-06 12:10:34 -08:00
vmfunc 75014e244b fix: adjust generator meta weight
Adjusted generator meta weight to remain consistent with other meta-framework detectors.

Co-authored-by: vmfunc <celeste@router.sex>
2026-01-06 14:45:03 +01:00
vmfunc 9c5220ec57 Merge pull request #55 from 0x4bs3nt/docs/contributing-update
docs: update CONTRIBUTING.md
2026-01-05 23:50:02 -08:00
vmfunc 0297bf3975 fix: discord invite
Fixed discord invite to official server invite url.
2026-01-06 06:35:32 +01:00
vmfunc 8eb7e84090 fix: use dynamic versioning for debian packages 2026-01-05 20:55:30 -08:00
vmfunc 4e0c45fa58 docs: update CONTRIBUTING.md
Update CONTRIBUTING.md docs with up to date data:
 - Discord invite to new sif server
 - Update URL-s to new vmfunc/sif repository
 - Update guidelines on contributing framework detection patterns
2026-01-06 05:30:34 +01:00
vmfunc 6467a2ca58 docs: add apt/cloudsmith installation instructions and badge 2026-01-05 20:28:30 -08:00
vmfunc 844affaed4 ci: push debian packages to cloudsmith 2026-01-05 20:28:07 -08:00
vmfunc 56895899ff ci: add debian package builds to releases 2026-01-05 20:13:18 -08:00
vmfunc 2e99e5072f docs: add 0xatrilla to contributors for AUR packaging 2026-01-05 19:51:50 -08:00
vmfunc 37925c6c99 docs: add AUR and Homebrew badges to readme 2026-01-05 19:48:51 -08:00
vmfunc d3216ca4a6 Merge pull request #53 from 0xatrilla/add-aur-install-instructions
docs: add AUR installation instructions
2026-01-05 19:44:43 -08:00
vmfunc 277f516ce9 chore: revise arch linux installation section in README
Updated Arch Linux installation instructions in README.md.
2026-01-05 19:44:15 -08:00
vmfunc ee1f9d7f31 feat(frameworks): add Astro framework detection
Add detection support for the Astro meta framework.

Includes signature detection, version extraction and tests with full
signature coverage.
2026-01-06 04:40:15 +01:00
acxtrilla 9705d95067 docs: add AUR installation instructions
Added Arch Linux (AUR) installation section to README with instructions
for installing via AUR helpers (yay/paru) or manually with makepkg.

Package available at: https://aur.archlinux.org/packages/sif
2026-01-06 01:56:40 +00:00
vmfunc 8c60e255dc docs: add homebrew installation instructions 2026-01-05 16:53:26 -08:00
vmfunc 7438dfb2ca chore: readme inconsistency 2026-01-03 06:14:40 -08:00
vmfunc 60c38e29cf ci: upgrade to go 1.24 in all workflows 2026-01-03 06:04:33 -08:00
vmfunc 7268374333 chore: add license headers to missing files 2026-01-03 06:01:00 -08:00
vmfunc 00a66adf27 feat(output): add styled console output with module loggers
- Add output package with colored prefixes and module loggers
- Each module gets unique background color based on name hash
- Add spinner for indeterminate operations
- Add progress bar for known-count operations
- Update all scan files to use ModuleLogger pattern
- Add clean PrintSummary for scan completion
2026-01-03 05:57:10 -08:00
vmfunc ab17191c31 docs: add comprehensive documentation and fix github actions
- add docs/ with installation, usage, modules, scans, and api docs
- add docs link to main readme
- fix release.yml to bundle modules directory with releases
- add module system tests to runtest.yml
- standardize go version to 1.23 across workflows
2026-01-03 05:57:10 -08:00
vmfunc cd1a56bd14 docs: update readme and add module documentation 2026-01-03 05:57:10 -08:00
vmfunc 36a0e473e3 feat: show module loading and execution logs by default 2026-01-03 05:57:10 -08:00
vmfunc 29b1b804af feat: add debug logging for module execution 2026-01-03 05:57:10 -08:00
vmfunc d2537dae1b refactor: move pkg/scan to internal/scan 2026-01-03 05:57:10 -08:00
vmfunc 6d8319dfa8 fix: add io.LimitReader and proper error handling to shodan.go
Add io.LimitReader with 5MB limit to prevent memory exhaustion and
fix ignored error in queryShodanHost. The error from io.ReadAll was
previously being discarded with _, which could mask read failures.
2026-01-03 05:57:10 -08:00
vmfunc 7ec8c6fb70 fix: add io.LimitReader to prevent memory exhaustion
Add io.LimitReader with 5MB limit to all HTTP response body reads
to prevent potential memory exhaustion from maliciously large responses.

Affected files:
- pkg/scan/cms.go
- pkg/scan/subdomaintakeover.go
- pkg/scan/js/scan.go
- pkg/scan/js/supabase.go
2026-01-03 05:57:10 -08:00
vmfunc 3e4fd67588 fix: regex compilation performance
Move regex compilation from inside functions to package level to avoid
recompiling on every function call. This improves performance by
compiling the regex patterns once at package initialization.

- Move jwtRegex to package level in supabase.go
- Move nextPagesRegex to package level in next.go
- Use strings.Builder instead of string concatenation in next.go
2026-01-03 05:57:10 -08:00
vmfunc 2d306fcf1d feat: implement loadYAML in module loader 2026-01-03 05:57:10 -08:00
vmfunc 82c8667e63 feat: integrate module system into sif.go
Add module system integration allowing users to run YAML-defined security
modules via CLI flags. Implements --list-modules to display available modules,
and supports running modules by ID, tags, or all at once.
2026-01-03 05:57:10 -08:00
vmfunc dc537a02f2 feat: add module cli flags 2026-01-03 05:57:10 -08:00
vmfunc a5ea29b88d feat: add built-in yaml modules for security scanning 2026-01-03 05:57:10 -08:00
vmfunc 01a10c6a2f feat: add yaml module parser and http executor 2026-01-03 05:57:10 -08:00
vmfunc 9154f8e77a feat: add module system infrastructure 2026-01-03 05:57:10 -08:00
vmfunc 539122ac4e refactor: move config to internal 2026-01-03 05:57:10 -08:00
vmfunc 28588fe37c refactor: move logger to internal 2026-01-03 05:57:10 -08:00
vmfunc a6abadd0d4 refactor: rewrite framework detection with modular detector architecture
- create detector interface and registry for extensibility
- extract detectors to separate files: backend.go, frontend.go, cms.go, meta.go
- reduce detect.go from 785 lines to 178 lines (pure orchestrator)
- export VersionMatch and ExtractVersionOptimized for detector use
- create result.go with NewFrameworkResult and WithVulnerabilities helpers
- add url validation to New() for early error detection
- add sif_test.go with main package tests
- update detect_test.go to use external test package pattern
2026-01-03 05:57:09 -08:00
vmfunc 1b27250b05 feat: add generic types and type-safe result handling
introduce ScanResult interface and generic NewModuleResult constructor
for compile-time type safety when creating module results.

- add pkg/scan/result.go with ScanResult interface and named slice types
- add typed shodanMetadata struct to replace map[string]interface{}
- refactor supabase.go with typed response structs and json.RawMessage
- add ResultType() methods to all scan result types
- update sif.go to use NewModuleResult generic constructor

this provides type safety without breaking JSON serialization.
2026-01-03 05:57:09 -08:00
vmfunc 2002509ab5 refactor: extract cve database to separate file
move CVEEntry struct and knownCVEs map to cve.go for better
organization. this reduces detect.go by another 170 lines and makes
the CVE database easier to maintain and extend.
2026-01-03 05:57:09 -08:00
vmfunc 7223a2eb66 perf: precompile framework version regex patterns
move version extraction patterns to version.go and compile them at init
time instead of recompiling on every check. this significantly improves
framework detection performance.

- add version.go with pre-compiled regex patterns for all frameworks
- update detect.go to use extractVersionOptimized
- remove duplicate extractVersionWithConfidence and isValidVersion functions
- add io.LimitReader to prevent memory exhaustion on large responses
- update tests to use the optimized version extraction
2026-01-03 05:57:09 -08:00
vmfunc 314783dba3 fix: response body leaks in cms.go and sql.go
close response bodies immediately after reading instead of deferring
inside loops, which delays closure until function exit
2026-01-03 05:57:09 -08:00
vmfunc bad1af5fc6 fix: response body leak in scan.go robots processing
move resp.body.close() inside the loop after use instead of deferring,
which would only run when the outer function exits
2026-01-03 05:57:09 -08:00
vmfunc 7ab5cfc18c feat: add generic worker pool for concurrent task processing
implement channel-based work distribution with generics for type-safe
concurrent processing, includes run, runwithfilter, and foreach methods
with comprehensive test coverage
2026-01-03 05:57:09 -08:00
vmfunc aba8c410a6 perf: optimize deduplication with map-based o(1) lookups in lfi and sql
replace o(n) slice iteration with map lookups for checking duplicates,
preallocate result slices, reduce lock hold time by separating map check
from result append
2026-01-03 05:57:09 -08:00
vmfunc 582baf2d33 fix: data races and slice preallocation in dirlist and dnslist
add mutex protection for concurrent slice appends, preallocate result
slices with reasonable capacity, use logger instead of direct file i/o
2026-01-03 05:57:09 -08:00
vmfunc ecb0124688 fix: error patterns and string building in sif.go and js/scan.go
replace errors.new(fmt.sprintf()) with fmt.errorf, use strings.builder
instead of string concatenation in loop, fix defer in loop issue,
preallocate slices where size is estimable
2026-01-03 05:57:09 -08:00
vmfunc 088a5bebeb test: add logger tests for buffered write functionality
covers initialization, write, flush, close, concurrent writes, and
file creation with proper cleanup verification
2026-01-03 05:57:09 -08:00
vmfunc 17d8e664d6 refactor: logger to use buffered file handles
replace per-write file open/close with cached file handles and buffered
writers for significantly reduced i/o overhead. adds flush and close
methods for proper cleanup at program exit.
2026-01-03 05:57:09 -08:00
vmfunc 86539cd06e chore: remove unused utils package
the returnApiOutput function was never used and contained only
hardcoded test data
2026-01-03 05:57:09 -08:00
vmfunc 046a5bc7d7 ci: add test coverage reporting to workflow
run tests with race detector and coverage profiling, upload results
to codecov for visibility into test coverage metrics
2026-01-03 05:57:09 -08:00
vmfunc 295d684054 ci: enhance golangci-lint with additional linters
add gocritic, revive, unconvert, prealloc, bodyclose, noctx, and
exportloopref for better code quality detection
2026-01-03 05:57:09 -08:00
vmfunc 8cb4a85a99 Merge pull request #51 from andrewgazelka/chore/modernize-nix-flake
chore(nix): modernize flake to use buildGoModule
2026-01-03 00:38:59 -08:00
Andrew Gazelka f2c8cc71b2 chore(nix): modernize flake to use buildGoModule
- Remove flake-utils dependency (use local forAllSystems helper)
- Remove gomod2nix dependency (use native buildGoModule)
- Add overlay export for easy consumption
- Update nixpkgs to latest unstable
- Disable tests in nix build (require network access)
2026-01-03 00:25:37 -08:00
vmfunc 01fa28555b docs: update contributor name and add vxfemboy 2026-01-02 19:56:44 -08:00
vmfunc 29478f087a chore: fix contributorrc 2026-01-02 19:55:31 -08:00
vmfunc 7b3f4a4f2f chore: fix contributorrc 2026-01-02 19:51:03 -08:00
vmfunc 563f3817ce Merge pull request #40 from vmfunc/feat/framework-detection
feat: framework detection module
2026-01-02 19:15:07 -08:00
vmfunc 20ea60cd70 fix: adjust sif logo alignment 2026-01-02 19:12:28 -08:00
vmfunc 5eac60f696 fix: improve version detection and add documentation
- fix version detection to validate reasonable version numbers (major < 100)
- remove overly permissive patterns that caused false positives
- add comprehensive framework contribution documentation to CONTRIBUTING.md
- document signature patterns, version detection, and CVE data format
- add configuration documentation for flags and env vars
- outline future enhancements for community contributions
2026-01-02 19:04:37 -08:00
vmfunc e02e1554ea docs: add framework detection to readme 2026-01-02 18:54:24 -08:00
vmfunc 816b89328c feat: expand framework detection with cvs, version confidence, concurrency
- add 20+ new framework signatures (vue, angular, react, svelte, sveltekit,
  remix, gatsby, joomla, magento, shopify, ghost, ember, backbone, meteor,
  strapi, adonisjs, cakephp, codeigniter, asp.net core, spring boot)
- add version confidence scoring with multiple detection sources
- add concurrent framework scanning for better performance
- expand cve database with 15+ known vulnerabilities (spring4shell, etc.)
- add risk level assessment based on cve severity
- add comprehensive security recommendations
- add new tests for all features
2026-01-02 18:52:15 -08:00
vmfunc af9d05f6b2 chore: add license header to detect.go 2026-01-02 18:52:15 -08:00
vmfunc 138bdf35aa feat: improve framework detection with more signatures and tests
- use math.Exp instead of custom exp implementation
- add more framework signatures: next.js, nuxt.js, wordpress, drupal,
  symfony, fastapi, gin, phoenix
- fix header detection to check both header names and values
- simplify version detection (remove unnecessary padding)
- add comprehensive test suite for framework detection
- fix formatting in dork.go
2026-01-02 18:52:15 -08:00
vmfunc 494a84e338 chore(actions): add framework to CI 2026-01-02 18:52:15 -08:00
vmfunc 3bc7a2463d feat(framework-detection): weighted bayesian detection algorithm
- weighted signature matching for more accurate framework detection
- sigmoid normalization for confidence scores
- version detection with semantic versioning support
- header-only pattern
2026-01-02 18:52:15 -08:00
vmfunc a08239bb1c feat: framework detection module 2026-01-02 18:52:15 -08:00
vmfunc ecf71be01f fix: use static discord badge instead of server id 2026-01-02 18:45:07 -08:00
vmfunc 29709103be docs: update readme with new modules and discord link 2026-01-02 18:42:45 -08:00
vmfunc db24c59498 feat: add lfi reconnaissance module (#49)
adds a new --lfi flag for local file inclusion vulnerability scanning:
- tests common lfi parameters with directory traversal payloads
- detects /etc/passwd, /etc/shadow, windows system files
- identifies php wrappers and encoded content
- supports various bypass techniques (null bytes, encoding)

closes #4
2026-01-02 18:41:30 -08:00
vmfunc 4392e33179 feat: add sql reconnaissance module (#48)
adds a new --sql flag that performs sql reconnaissance on target urls:
- detects common database admin panels (phpmyadmin, adminer, pgadmin, etc.)
- identifies database error disclosure (mysql, postgresql, mssql, oracle, sqlite)
- scans common paths for sql injection indicators

closes #3
2026-01-02 18:40:06 -08:00
vmfunc 080ab10f56 fix: remove duplicate subdomain takeover call and add config tests (#46)
- remove duplicate SubdomainTakeover call that ran twice when both
  dns scan and --st flag were enabled
- add comprehensive tests for config settings defaults and behavior
- fix formatting in dork.go

closes #1
2026-01-02 18:38:47 -08:00
vmfunc 4a77307acf Merge pull request #47 from vmfunc/feat/shodan-integration
feat: add shodan integration for host reconnaissance
2026-01-02 18:35:56 -08:00
vmfunc d44dbb7f48 feat: add shodan integration for host reconnaissance
adds a new --shodan flag that queries the shodan api for information
about the target host. requires SHODAN_API_KEY environment variable.

features:
- resolves hostnames to ip addresses
- queries shodan host api for reconnaissance data
- displays organization, isp, location, ports, services, and vulns
- logs results to file when logdir is specified

closes #2
2026-01-02 18:24:37 -08:00
vmfunc 1bf927b895 fix: update dependencies to address security vulnerabilities
- golang.org/x/crypto v0.26.0 -> v0.46.0 (critical: ssh auth bypass)
- golang.org/x/net v0.28.0 -> v0.48.0 (medium: xss vulnerability)
- golang.org/x/oauth2 v0.11.0 -> v0.34.0 (high: input validation)
- quic-go v0.48.2 -> v0.58.0 (high: panic on undecryptable packets)
- golang-jwt/jwt v4.5.1 -> v4.5.2 (high: memory allocation)
- cloudflare/circl v1.3.7 -> v1.6.2 (low: validation issues)
- refraction-networking/utls v1.5.4 -> v1.8.1 (medium: tls downgrade)
- ulikunitz/xz v0.5.11 -> v0.5.15 (medium: memory leak)
- klauspost/compress v1.16.7 -> v1.17.4

also fixes go vet warnings for non-constant format strings
2026-01-02 18:03:27 -08:00
vmfunc 0e3e43a1f3 fix: update readme badges and use banner image
- update badges to point to vmfunc/sif
- replace ascii art with banner image
- fix header check action to check first 5 lines
- remove obsolete LICENSE.md
2026-01-02 17:54:17 -08:00
vmfunc 8230edf30b chore: delete old license 2026-01-02 17:45:14 -08:00
vmfunc d30c7f56a3 license: switch to bsd 3-clause, update headers and readme
- replace proprietary license with bsd 3-clause
- update all go file headers with new retro terminal style
- add header-check github action to enforce license headers
- completely rewrite readme to be modern, sleek, and lowercase
- fix broken badges
2026-01-02 17:41:18 -08:00
318 changed files with 31620 additions and 1858 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"projectName": "sif",
"projectOwner": "lunchcat",
"projectOwner": "vmfunc",
"files": [
"README.md"
],
+1
View File
@@ -0,0 +1 @@
* @vmfunc
+15
View File
@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: vmfunc
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+17
View File
@@ -0,0 +1,17 @@
version: 2
updates:
- package-ecosystem: gomod
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
labels:
- deps
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 5
labels:
- deps
+44
View File
@@ -0,0 +1,44 @@
ci:
- changed-files:
- any-glob-to-any-file: ".github/**"
deps:
- changed-files:
- any-glob-to-any-file:
- "go.mod"
- "go.sum"
- "flake.nix"
- "flake.lock"
scan:
- changed-files:
- any-glob-to-any-file: "internal/scan/**"
nuclei:
- changed-files:
- any-glob-to-any-file: "internal/nuclei/**"
modules:
- changed-files:
- any-glob-to-any-file:
- "internal/modules/**"
- "internal/scan/builtin/**"
- "internal/scan/js/**"
- "modules/**"
docs:
- changed-files:
- any-glob-to-any-file:
- "**/*.md"
- "docs/**"
tests:
- changed-files:
- any-glob-to-any-file: "**/*_test.go"
config:
- changed-files:
- any-glob-to-any-file:
- "internal/config/**"
- ".golangci.yml"
- ".editorconfig"
+8 -3
View File
@@ -1,7 +1,12 @@
name: Automatic Rebase
name: automatic rebase
on:
issue_comment:
types: [created]
permissions:
contents: write
pull-requests: write
jobs:
rebase:
name: Rebase
@@ -9,10 +14,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the latest code
uses: actions/checkout@v4
uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Automatic Rebase
- name: automatic rebase
uses: cirrus-actions/rebase@1.8
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+18 -7
View File
@@ -1,18 +1,29 @@
name: Check Large Files
name: check large files
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
check-large-files:
name: Check for large files
name: check for large files
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Check for large files
- uses: actions/checkout@v7
- name: check for large files
run: |
find . -type f -size +5M | while read file; do
echo "::error file=${file}::File ${file} is larger than 5MB"
done
large_files=$(find . -path ./.git -prune -o -type f -size +5M -print)
if [ -n "$large_files" ]; then
echo "$large_files" | while read -r file; do
echo "::error file=${file}::File ${file} is larger than 5MB"
done
exit 1
fi
+44
View File
@@ -0,0 +1,44 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
+50
View File
@@ -0,0 +1,50 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr *)'
+27 -12
View File
@@ -1,24 +1,39 @@
name: Qodana
name: code quality
on:
workflow_dispatch:
pull_request:
push:
branches:
- main
schedule:
- cron: "0 6 * * 1" # monday 06:00 UTC
permissions: {}
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
qodana:
codeql:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
checks: write
security-events: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: set up go
uses: actions/setup-go@v5
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- name: 'Qodana Scan'
uses: JetBrains/qodana-action@v2024.3
env:
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
go-version: "1.25"
- name: initialize codeql
uses: github/codeql-action/init@v4
with:
languages: go
- name: build
run: go build ./...
- name: perform codeql analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:go"
+10 -6
View File
@@ -1,4 +1,4 @@
name: "Dependency Review"
name: dependency review
on:
pull_request:
push:
@@ -7,16 +7,20 @@ on:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
dependency-review:
runs-on: ubuntu-latest
steps:
- name: "Checkout Repository"
uses: actions/checkout@v4
- name: "Dependency Review"
uses: actions/dependency-review-action@v4
- name: checkout repository
uses: actions/checkout@v7
- name: dependency review
uses: actions/dependency-review-action@v5
continue-on-error: ${{ github.event_name == 'push' }}
- name: "Check Dependency Review Outcome"
- name: check dependency review outcome
if: github.event_name == 'push' && failure()
run: |
echo "::warning::Dependency review failed. Please check the dependencies for potential issues."
+37 -10
View File
@@ -1,24 +1,51 @@
name: Go
name: go
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
- uses: actions/checkout@v7
- name: set up go
uses: actions/setup-go@v6
with:
go-version: "1.24"
- name: Build
go-version: "1.25"
- name: golangci-lint
uses: golangci/golangci-lint-action@v8
with:
version: v2.11.4
build:
runs-on: ubuntu-latest
strategy:
matrix:
go-version: ["1.25"]
steps:
- uses: actions/checkout@v7
- name: set up go
uses: actions/setup-go@v6
with:
go-version: ${{ matrix.go-version }}
- name: build
run: make
- name: Run tests with coverage
- name: run tests with coverage
run: go test -race -coverprofile=coverage.out -covermode=atomic ./...
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
- name: upload coverage to codecov
uses: codecov/codecov-action@v7
with:
files: ./coverage.out
fail_ci_if_error: false
- name: run integration tests
run: go test -tags=integration -race ./internal/scan/...
+27
View File
@@ -0,0 +1,27 @@
name: govulncheck
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: "0 6 * * 1" # monday 06:00 UTC
permissions:
contents: read
jobs:
govulncheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: set up go
uses: actions/setup-go@v5
with:
go-version: "1.25"
- name: install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@v1.1.4
- name: run govulncheck
run: govulncheck ./...
continue-on-error: true
+5 -2
View File
@@ -8,11 +8,14 @@ on:
paths:
- '**.go'
permissions:
contents: read
jobs:
check-headers:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: check license headers
run: |
@@ -41,7 +44,7 @@ jobs:
echo ': █▀ █ █▀▀ · Blazing-fast pentesting suite :'
echo ': ▄█ █ █▀ · BSD 3-Clause License :'
echo ': :'
echo ': (c) 2022-2025 vmfunc (vmfunc), xyzeva, :'
echo ': (c) 2022-2026 vmfunc, xyzeva, :'
echo ': lunchcat alumni & contributors :'
echo ': :'
echo '·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·'
+8 -2
View File
@@ -1,4 +1,4 @@
name: Mind your language
name: mind your language
on:
issues:
types:
@@ -12,13 +12,19 @@ on:
types:
- created
- edited
permissions:
contents: read
issues: write
pull-requests: write
jobs:
echo_issue_comment:
runs-on: ubuntu-latest
name: profanity check
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v7
- name: Profanity check step
uses: tailaiw/mind-your-language-action@v1.0.3
env:
+7 -3
View File
@@ -1,18 +1,22 @@
name: Markdown Lint
name: markdown lint
on:
pull_request:
paths:
- "**/*.md"
permissions:
contents: read
pull-requests: write
jobs:
markdownlint:
name: runner / markdownlint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: markdownlint
uses: reviewdog/action-markdownlint@v0.24.0
uses: reviewdog/action-markdownlint@v0.26.2
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
reporter: github-pr-review
+11 -3
View File
@@ -1,18 +1,26 @@
name: Misspell Check
name: misspell check
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
pull-requests: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
misspell:
name: runner / misspell
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: misspell
uses: reviewdog/action-misspell@v1.26.0
uses: reviewdog/action-misspell@v1.27.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
reporter: github-pr-review
+138
View File
@@ -0,0 +1,138 @@
name: pr bot
on:
pull_request_target:
types: [opened, synchronize, reopened, edited]
permissions:
contents: read
pull-requests: write
concurrency:
group: ${{ github.workflow }}-pr-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
label:
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v6
with:
configuration-path: .github/labeler.yml
size:
runs-on: ubuntu-latest
steps:
- name: label pr size
uses: actions/github-script@v9
with:
script: |
const { data: files } = await github.rest.pulls.listFiles({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
per_page: 100,
});
const changes = files.reduce((sum, f) => sum + f.additions + f.deletions, 0);
let size;
if (changes < 10) size = "size/xs";
else if (changes < 50) size = "size/s";
else if (changes < 200) size = "size/m";
else if (changes < 500) size = "size/l";
else size = "size/xl";
const sizeLabels = ["size/xs", "size/s", "size/m", "size/l", "size/xl"];
const currentLabels = context.payload.pull_request.labels.map(l => l.name);
const toRemove = currentLabels.filter(l => sizeLabels.includes(l) && l !== size);
for (const label of toRemove) {
await github.rest.issues.removeLabel({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
name: label,
}).catch(() => {});
}
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
labels: [size],
});
ci-summary:
runs-on: ubuntu-latest
needs: [label, size]
if: always()
steps:
- uses: actions/github-script@v9
with:
script: |
const pr = context.payload.pull_request;
const { data: checks } = await github.rest.checks.listForRef({
owner: context.repo.owner,
repo: context.repo.repo,
ref: pr.head.sha,
per_page: 100,
});
const { data: files } = await github.rest.pulls.listFiles({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pr.number,
per_page: 100,
});
const additions = files.reduce((sum, f) => sum + f.additions, 0);
const deletions = files.reduce((sum, f) => sum + f.deletions, 0);
const fileCount = files.length;
let body = `### pr summary\n\n`;
body += `**${fileCount}** files changed (+${additions} -${deletions})\n\n`;
const goFiles = files.filter(f => f.filename.endsWith('.go')).length;
const testFiles = files.filter(f => f.filename.endsWith('_test.go')).length;
const ciFiles = files.filter(f => f.filename.startsWith('.github/')).length;
const modFiles = files.filter(f => f.filename === 'go.mod' || f.filename === 'go.sum').length;
if (goFiles > 0 || testFiles > 0 || ciFiles > 0 || modFiles > 0) {
body += `| category | files |\n|----------|-------|\n`;
if (goFiles > 0) body += `| go source | ${goFiles} |\n`;
if (testFiles > 0) body += `| tests | ${testFiles} |\n`;
if (ciFiles > 0) body += `| ci/workflows | ${ciFiles} |\n`;
if (modFiles > 0) body += `| deps | ${modFiles} |\n`;
body += `\n`;
}
// find existing bot comment
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
});
const marker = '<!-- sif-pr-bot -->';
body = marker + '\n' + body;
const existing = comments.find(c =>
c.user.type === 'Bot' && c.body.includes(marker)
);
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pr.number,
body,
});
}
+150 -41
View File
@@ -1,8 +1,9 @@
name: Release
name: release
on:
push:
branches: [main]
tags:
- "v*"
permissions:
contents: write
@@ -18,29 +19,35 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
- uses: actions/checkout@v7
- name: set up go
uses: actions/setup-go@v5
with:
go-version: "1.24"
go-version: "1.25"
- name: Build for Windows
- name: extract version
run: |
GOOS=windows GOARCH=amd64 go build -o sif-windows-amd64.exe ./cmd/sif
GOOS=windows GOARCH=386 go build -o sif-windows-386.exe ./cmd/sif
echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV
# single source of truth so the cross-compile steps can't drift
echo "LDFLAGS=-s -w -X main.version=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV
- name: Build for macOS
- name: build for windows
run: |
GOOS=darwin GOARCH=amd64 go build -o sif-macos-amd64 ./cmd/sif
GOOS=darwin GOARCH=arm64 go build -o sif-macos-arm64 ./cmd/sif
GOOS=windows GOARCH=amd64 go build -ldflags="${{ env.LDFLAGS }}" -o sif-windows-amd64.exe ./cmd/sif
GOOS=windows GOARCH=386 go build -ldflags="${{ env.LDFLAGS }}" -o sif-windows-386.exe ./cmd/sif
- name: Build for Linux
- name: build for macOS
run: |
GOOS=linux GOARCH=amd64 go build -o sif-linux-amd64 ./cmd/sif
GOOS=linux GOARCH=386 go build -o sif-linux-386 ./cmd/sif
GOOS=linux GOARCH=arm64 go build -o sif-linux-arm64 ./cmd/sif
GOOS=darwin GOARCH=amd64 go build -ldflags="${{ env.LDFLAGS }}" -o sif-macos-amd64 ./cmd/sif
GOOS=darwin GOARCH=arm64 go build -ldflags="${{ env.LDFLAGS }}" -o sif-macos-arm64 ./cmd/sif
- name: Package releases with modules
- name: build for linux
run: |
GOOS=linux GOARCH=amd64 go build -ldflags="${{ env.LDFLAGS }}" -o sif-linux-amd64 ./cmd/sif
GOOS=linux GOARCH=386 go build -ldflags="${{ env.LDFLAGS }}" -o sif-linux-386 ./cmd/sif
GOOS=linux GOARCH=arm64 go build -ldflags="${{ env.LDFLAGS }}" -o sif-linux-arm64 ./cmd/sif
- name: package releases with modules
run: |
for binary in sif-linux-amd64 sif-linux-386 sif-linux-arm64 sif-macos-amd64 sif-macos-arm64; do
mkdir -p "dist/${binary}"
@@ -55,40 +62,127 @@ jobs:
cd dist && zip -r "../${binary}.zip" "${binary}" && cd ..
done
- name: Set release version
run: echo "RELEASE_VERSION=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
- name: build debian packages
run: |
declare -A arch_map=(
["sif-linux-amd64"]="amd64"
["sif-linux-386"]="i386"
["sif-linux-arm64"]="arm64"
)
- name: Create Release and Upload Assets
uses: softprops/action-gh-release@v2
for binary in sif-linux-amd64 sif-linux-386 sif-linux-arm64; do
arch="${arch_map[$binary]}"
pkg_dir="sif_${{ env.VERSION }}_${arch}"
mkdir -p "${pkg_dir}/DEBIAN"
mkdir -p "${pkg_dir}/usr/bin"
mkdir -p "${pkg_dir}/usr/share/sif/modules"
cp "${binary}" "${pkg_dir}/usr/bin/sif"
chmod 755 "${pkg_dir}/usr/bin/sif"
cp -r modules/* "${pkg_dir}/usr/share/sif/modules/"
cat > "${pkg_dir}/DEBIAN/control" << EOF
Package: sif
Version: ${{ env.VERSION }}
Section: security
Priority: optional
Architecture: ${arch}
Maintainer: vmfunc <celeste@linux.com>
Homepage: https://github.com/vmfunc/sif
Description: Modular pentesting toolkit
sif is a fast, concurrent, and extensible pentesting toolkit written in Go.
It supports multiple scan types including directory fuzzing, subdomain
enumeration, port scanning, and vulnerability detection.
EOF
dpkg-deb --build "${pkg_dir}"
done
- name: generate checksums
run: |
sha256sum \
sif-windows-amd64.zip \
sif-windows-386.zip \
sif-macos-amd64.tar.gz \
sif-macos-arm64.tar.gz \
sif-linux-amd64.tar.gz \
sif-linux-386.tar.gz \
sif-linux-arm64.tar.gz \
sif_*.deb \
> checksums-sha256.txt
- name: generate SBOM
uses: anchore/sbom-action@v0
with:
tag_name: automated-release-${{ env.RELEASE_VERSION }}
name: Release ${{ env.RELEASE_VERSION }}
artifact-name: sbom.spdx.json
output-file: sbom.spdx.json
- name: generate changelog
id: changelog
uses: actions/github-script@v9
with:
result-encoding: string
script: |
const { data: releases } = await github.rest.repos.listReleases({
owner: context.repo.owner,
repo: context.repo.repo,
per_page: 1,
});
const prev = releases.length > 0 ? releases[0].tag_name : '';
const range = prev ? `${prev}...${context.ref}` : '';
const { data: commits } = await github.rest.repos.compareCommitsWithBasehead({
owner: context.repo.owner,
repo: context.repo.repo,
basehead: prev ? `${prev}...${{ github.ref_name }}` : `${{ github.sha }}~10...${{ github.sha }}`,
}).catch(() => ({ data: { commits: [] } }));
let log = '';
for (const c of commits.commits || []) {
const msg = c.commit.message.split('\n')[0];
const sha = c.sha.substring(0, 7);
log += `- ${msg} (${sha})\n`;
}
return log || 'initial release';
- name: create release
uses: softprops/action-gh-release@v3
with:
name: sif v${{ env.VERSION }}
body: |
Automated release v${{ env.RELEASE_VERSION }}
## what's changed
## Assets
${{ steps.changelog.outputs.result }}
Each archive contains the sif binary and built-in modules.
- Windows (64-bit): `sif-windows-amd64.zip`
- Windows (32-bit): `sif-windows-386.zip`
- macOS (64-bit Intel): `sif-macos-amd64.tar.gz`
- macOS (64-bit ARM): `sif-macos-arm64.tar.gz`
- Linux (64-bit): `sif-linux-amd64.tar.gz`
- Linux (32-bit): `sif-linux-386.tar.gz`
- Linux (64-bit ARM): `sif-linux-arm64.tar.gz`
## Installation
## install
**homebrew / linuxbrew**
```bash
tar -xzf sif-linux-amd64.tar.gz
cd sif-linux-amd64
./sif -h
# coming soon
```
For more details, check the [commit history](https://github.com/${{ github.repository }}/commits/main).
**debian / ubuntu**
```bash
sudo dpkg -i sif_${{ env.VERSION }}_amd64.deb
```
**go install**
```bash
go install github.com/dropalldatabases/sif/cmd/sif@v${{ env.VERSION }}
```
**binary download** - grab the right archive from below.
## verification
```bash
sha256sum -c checksums-sha256.txt
```
draft: false
prerelease: false
prerelease: ${{ contains(github.ref_name, '-') }}
files: |
sif-windows-amd64.zip
sif-windows-386.zip
@@ -97,5 +191,20 @@ jobs:
sif-linux-amd64.tar.gz
sif-linux-386.tar.gz
sif-linux-arm64.tar.gz
sif_*_amd64.deb
sif_*_i386.deb
sif_*_arm64.deb
checksums-sha256.txt
sbom.spdx.json
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: push to cloudsmith
if: ${{ !contains(github.ref_name, '-') }}
env:
CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_API_KEY }}
run: |
pip install cloudsmith-cli
for deb in sif_*.deb; do
cloudsmith push deb sif/deb/any-distro/any-version "$deb" -k "$CLOUDSMITH_API_KEY"
done
+10 -3
View File
@@ -1,4 +1,4 @@
name: Update Report Card
name: update report card
on:
push:
@@ -7,10 +7,17 @@ on:
branches: [main]
workflow_call:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
update-report-card:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Update Go Report Card
- uses: actions/checkout@v7
- name: update go report card
uses: creekorful/goreportcard-action@v1.0
+11 -8
View File
@@ -1,4 +1,4 @@
name: Functional Test
name: functional test
on:
push:
@@ -7,18 +7,21 @@ on:
branches: [main]
workflow_call:
permissions:
contents: read
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
- uses: actions/checkout@v7
- name: set up go
uses: actions/setup-go@v5
with:
go-version: "1.24"
- name: Build Sif
go-version: "1.25"
- name: build sif
run: make
- name: Run Sif with features
- name: run sif with features
run: |
./sif -u https://example.com -dnslist small -dirlist small -dork -git -whois -cms -framework
if [ $? -eq 0 ]; then
@@ -28,7 +31,7 @@ jobs:
exit 1
fi
- name: Test module system
- name: test module system
run: |
echo "Listing modules..."
./sif -lm
+30
View File
@@ -0,0 +1,30 @@
name: scorecard
on:
push:
branches: [main]
schedule:
- cron: "0 6 * * 1" # monday 06:00 UTC
permissions: read-all
jobs:
analysis:
runs-on: ubuntu-latest
permissions:
security-events: write
id-token: write
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: run scorecard
uses: ossf/scorecard-action@v2.4.3
with:
results_file: results.sarif
results_format: sarif
publish_results: true
- name: upload sarif results
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: results.sarif
+7 -3
View File
@@ -1,18 +1,22 @@
name: Shell Check
name: shell check
on:
pull_request:
paths:
- "**/*.sh"
permissions:
contents: read
pull-requests: write
jobs:
shellcheck:
name: runner / shellcheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: shellcheck
uses: reviewdog/action-shellcheck@v1.27.0
uses: reviewdog/action-shellcheck@v1.32.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
reporter: github-pr-review
+7 -3
View File
@@ -1,4 +1,4 @@
name: YAML Lint
name: yaml lint
on:
pull_request:
@@ -6,14 +6,18 @@ on:
- "**/*.yml"
- "**/*.yaml"
permissions:
contents: read
pull-requests: write
jobs:
yamllint:
name: runner / yamllint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: yamllint
uses: reviewdog/action-yamllint@v1.19.0
uses: reviewdog/action-yamllint@v1.21.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
reporter: github-pr-review
+89 -32
View File
@@ -1,39 +1,96 @@
linters:
enable:
- errcheck # check error returns
- govet # suspicious constructs
- staticcheck # advanced static analysis
- unused # unused code
- gosimple # simplifications
- ineffassign # useless assignments
- misspell # spelling mistakes
- gocritic # opinionated lints
- revive # replacement for golint
- unconvert # unnecessary type conversions
- prealloc # slice preallocation hints
- bodyclose # http response body not closed
- noctx # http requests without context
- exportloopref # loop variable capture
linters-settings:
govet:
enable-all: true
errcheck:
check-blank: false
revive:
rules:
- name: exported
arguments: [checkPrivateReceivers]
gocritic:
enabled-tags:
- diagnostic
- style
- performance
---
version: "2"
run:
timeout: 5m
issues-exit-code: 1
linters:
enable:
- errcheck # check error returns
- govet # suspicious constructs
- staticcheck # advanced static analysis (absorbs gosimple in v2)
- unused # unused code
- ineffassign # useless assignments
- misspell # spelling mistakes
- gocritic # opinionated lints
- revive # replacement for golint
- unconvert # unnecessary type conversions
- bodyclose # http response body not closed
- noctx # http requests without context
- gosec # security issues
- errorlint # error wrapping and comparison
- nilnil # return nil, nil
- wastedassign # assignments to variables never read
- usetesting # os.Setenv in tests instead of t.Setenv, etc.
settings:
govet:
enable-all: true
disable:
# too many structs to reorder, risks breaking serialization
- fieldalignment
- shadow # common Go pattern, too noisy
- unusedwrite # false positives on test data structs
errcheck:
check-blank: false
exclude-functions:
# log writes are best-effort
- github.com/dropalldatabases/sif/internal/logger.Write
# Close on io.Closer is idiomatic best-effort
- (io.Closer).Close
- (*os.File).Close
- (*net/http.Response).Body.Close
# fmt.Fprint* returns are rarely actionable
- fmt.Fprint
- fmt.Fprintf
- fmt.Fprintln
staticcheck:
# QF1003/QF1012 are v2 quickfix suggestions, not bugs.
# ST1000/ST1003 were the stylecheck linter in v1
# (not previously enabled); skipping to match prior parity.
checks:
- all
- -QF1003
- -QF1012
- -ST1000
- -ST1003
revive:
rules:
# stuttering names (scan.ScanResult) need breaking API changes
- name: exported
disabled: true
gocritic:
enabled-tags:
- diagnostic
- style
- performance
disabled-checks:
- commentedOutCode # too opinionated for a project with TODOs
- paramTypeCombine # style-only, not worth churn
- unnamedResult # style-only
- unnecessaryDefer # common pattern in tests
# inverting conditions in scan logic hurts readability
- nestingReduce
gosec:
excludes:
- G104 # errcheck covers this
- G107 # pentesting tool -- variable URLs are the whole point
- G110 # nuclei template decompression, acceptable context
- G304 # sif reads user-supplied wordlist paths -- intentional
- G305 # tar extraction is traversal-guarded (HasPrefix on the
# cleaned target); gosec flags filepath.Join regardless
exclusions:
rules:
# test files get some slack
- path: _test\.go
linters:
- errcheck
- noctx
- gosec # fake credentials in secret-scanner fixtures are not real keys
- bodyclose # synthetic *http.Response fixtures carry no socket to close
issues:
max-issues-per-linter: 50
max-same-issues: 3
max-same-issues: 50
+53 -25
View File
@@ -4,17 +4,19 @@ Thank you for taking the time to contribute to sif! All contributions are valued
If you want to contribute but don't know where to start, worry not; there is no shortage of things to do.
Even if you don't know any Go, don't let that stop you from trying to contribute! We're here to help.
*By contributing to this repository, you agree to adhere to the sif [Code of Conduct](https://github.com/dropalldatabases/sif/blob/main/CODE_OF_CONDUCT.md). Not doing so may result in a ban.*
_By contributing to this repository, you agree to adhere to the sif [Code of Conduct](https://github.com/vmfunc/sif/blob/main/CODE_OF_CONDUCT.md). Not doing so may result in a ban._
## How can I help?
Here are some ways to get started:
- Have a look at our [issue tracker](https://github.com/dropalldatabases/sif/issues).
- Have a look at our [issue tracker](https://github.com/vmfunc/sif/issues).
- If you've encountered a bug, discuss it with us, [report it](#reporting-issues).
- Once you've found a bug you believe you can fix, open a [pull request](#contributing-code) for it.
- Alternatively, consider [packaging sif for your distribution](#packaging).
If you like the project, but don't have time to contribute, that's okay too! Here are other ways to show your appreciation for the project:
- Use sif (seriously, that's enough)
- Star the repository
- Share sif with your friends
@@ -22,7 +24,7 @@ If you like the project, but don't have time to contribute, that's okay too! Her
## Reporting issues
If you believe you've found a bug, or you have a new feature to request, please hop on the [Discord server](https://discord.gg/dropalldatabases) first to discuss it.
If you believe you've found a bug, or you have a new feature to request, please hop on the [Discord server](https://discord.com/invite/sifcli) first to discuss it.
This way, if it's an easy fix, we could help you solve it more quickly, and if it's a feature request we could workshop it together into something more mature.
When opening an issue, please use the search tool and make sure that the issue has not been discussed before. In the case of a bug report, run sif with the `-d/-debug` flag for full debug logs.
@@ -31,9 +33,9 @@ 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.
_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.
### Submitting a pull request
@@ -55,21 +57,40 @@ If you have any questions, feel free to ask around on the IRC channel.
## Contributing Framework Detection Patterns
The framework detection module (`pkg/scan/frameworks/detect.go`) identifies web frameworks by analyzing HTTP headers and response bodies. To add support for a new framework:
The framework detection module (`internal/scan/frameworks/`) identifies web frameworks by analyzing HTTP headers and response bodies. Detectors are organized by category in the `detectors/` subdirectory:
### Adding a New Framework Signature
### Adding a New Framework Detector
1. Add your framework to the `frameworkSignatures` map:
1. Create a detector struct in the appropriate file in `detectors/`:
```go
"MyFramework": {
{Pattern: `unique-identifier`, Weight: 0.5},
{Pattern: `header-signature`, Weight: 0.4, HeaderOnly: true},
{Pattern: `body-signature`, Weight: 0.3},
},
// myframeworkDetector detects MyFramework.
type myframeworkDetector struct{}
func (d *myframeworkDetector) Name() string { return "MyFramework" }
func (d *myframeworkDetector) Signatures() []fw.Signature {
return []fw.Signature{
{Pattern: "unique-identifier", Weight: 0.5},
{Pattern: "header-signature", Weight: 0.4, HeaderOnly: true},
{Pattern: "body-signature", Weight: 0.3},
}
}
...
```
2. Register the detector in the `init()` function of the same file:
```go
func init() {
fw.Register(&myframeworkDetector{})
}
```
**Pattern Guidelines:**
- `Weight`: How much this signature contributes to detection (0.0-1.0)
- `HeaderOnly`: Set to `true` for HTTP header patterns
- Use unique identifiers that won't false-positive on other frameworks
@@ -77,10 +98,11 @@ The framework detection module (`pkg/scan/frameworks/detect.go`) identifies web
### Adding Version Detection
Add version patterns to `extractVersionWithConfidence()`:
Add version patterns to `version.go` in the `rawPatterns` map inside `init()`:
```go
"MyFramework": {
{`<meta name="generator" content="MyFramework v?(\d+\.\d+(?:\.\d+)?)"`, 0.95, "generator meta"},
{`MyFramework[/\s]+[Vv]?(\d+\.\d+(?:\.\d+)?)`, 0.9, "explicit version"},
{`"myframework":\s*"[~^]?(\d+\.\d+(?:\.\d+)?)"`, 0.85, "package.json"},
},
@@ -88,7 +110,7 @@ Add version patterns to `extractVersionWithConfidence()`:
### Adding CVE Data
Add known vulnerabilities to the `knownCVEs` map:
Add known vulnerabilities to `cve.go` in the `knownCVEs` map:
```go
"MyFramework": {
@@ -115,11 +137,17 @@ func TestDetectFramework_MyFramework(t *testing.T) {
}))
defer server.Close()
result, err := DetectFramework(server.URL, 5*time.Second, "")
result, err := frameworks.DetectFramework(server.URL, 5*time.Second, "")
// assertions...
}
```
Also add your framework to the registry test in `TestDetectorRegistry`:
```go
expectedDetectors := []string{"Laravel", "Django", ..., "MyFramework"}
```
### Future Enhancements (Help Wanted)
- **Custom Signature Support**: Allow users to define signatures via config file
@@ -131,18 +159,18 @@ func TestDetectFramework_MyFramework(t *testing.T) {
### Framework Detection Flags
| Flag | Description |
|------|-------------|
| `-framework` | Enable framework detection |
| `-timeout` | HTTP request timeout (affects all modules) |
| `-threads` | Number of concurrent workers |
| `-log` | Directory to save scan results |
| `-debug` | Enable debug logging for verbose output |
| Flag | Description |
| ------------ | ------------------------------------------ |
| `-framework` | Enable framework detection |
| `-timeout` | HTTP request timeout (affects all modules) |
| `-threads` | Number of concurrent workers |
| `-log` | Directory to save scan results |
| `-debug` | Enable debug logging for verbose output |
### Environment Variables
| Variable | Description |
|----------|-------------|
| Variable | Description |
| ---------------- | ------------------------------------ |
| `SHODAN_API_KEY` | API key for Shodan host intelligence |
## Packaging
+1 -1
View File
@@ -1,6 +1,6 @@
BSD 3-Clause License
Copyright (c) 2022-2025 vmfunc (vmfunc), xyzeva, lunchcat alumni,
Copyright (c) 2022-2025 vmfunc, xyzeva, lunchcat alumni,
and other sif contributors.
Redistribution and use in source and binary forms, with or without
+12 -3
View File
@@ -9,6 +9,12 @@ RM ?= rm
GOFLAGS ?=
PREFIX ?= /usr/local
BINDIR ?= bin
MANDIR ?= share/man/man1
# stamp local builds with the nearest v* tag (or short sha), matching the
# release ci. --match keeps the automated-release-* tags out of the version.
VERSION ?= $(shell git describe --tags --match 'v*' --always --dirty 2>/dev/null | sed 's/^v//')
GO_LDFLAGS = -X main.version=$(VERSION)
define COPYRIGHT_ASCII
@@ -32,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!
@@ -56,7 +61,7 @@ sif: check_go_version
@echo "📁 Current directory: $$(pwd)"
@echo "🔧 Go flags: $(GOFLAGS)"
@echo "📦 Building package: ./cmd/sif"
$(GO) build -v $(GOFLAGS) ./cmd/sif
$(GO) build -v $(GOFLAGS) -ldflags "$(GO_LDFLAGS)" ./cmd/sif
@echo "📊 Build info:"
@$(GO) version -m sif
@echo "✅ sif built successfully! 🚀"
@@ -76,6 +81,9 @@ install: check_go_version
fi
@mkdir -p $(DESTDIR)$(PREFIX)/$(BINDIR) || (echo "🔒 Permission denied. Trying with sudo..." && sudo mkdir -p $(DESTDIR)$(PREFIX)/$(BINDIR))
@cp -f sif $(DESTDIR)$(PREFIX)/$(BINDIR) || (echo "🔒 Permission denied. Trying with sudo..." && sudo cp -f sif $(DESTDIR)$(PREFIX)/$(BINDIR))
@echo "📖 Installing man page..."
@mkdir -p $(DESTDIR)$(PREFIX)/$(MANDIR) || (echo "🔒 Permission denied. Trying with sudo..." && sudo mkdir -p $(DESTDIR)$(PREFIX)/$(MANDIR))
@cp -f man/sif.1 $(DESTDIR)$(PREFIX)/$(MANDIR) || (echo "🔒 Permission denied. Trying with sudo..." && sudo cp -f man/sif.1 $(DESTDIR)$(PREFIX)/$(MANDIR))
@echo "✅ sif installed successfully! 🎊"
uninstall:
@@ -86,6 +94,7 @@ uninstall:
exit 1; \
fi
@$(RM) $(DESTDIR)$(PREFIX)/$(BINDIR)/sif || (echo "🔒 Permission denied. Trying with sudo..." && sudo $(RM) $(DESTDIR)$(PREFIX)/$(BINDIR)/sif)
@$(RM) $(DESTDIR)$(PREFIX)/$(MANDIR)/sif.1 || (echo "🔒 Permission denied. Trying with sudo..." && sudo $(RM) $(DESTDIR)$(PREFIX)/$(MANDIR)/sif.1)
@echo "✅ sif uninstalled successfully!"
.PHONY: all check_go_version sif clean install uninstall
+187 -12
View File
@@ -8,23 +8,39 @@
[![build](https://img.shields.io/github/actions/workflow/status/vmfunc/sif/go.yml?style=flat-square)](https://github.com/vmfunc/sif/actions)
[![license](https://img.shields.io/badge/license-BSD--3--Clause-blue?style=flat-square)](LICENSE)
[![aur](https://img.shields.io/aur/version/sif?style=flat-square&logo=archlinux&logoColor=white&color=1793D1)](https://aur.archlinux.org/packages/sif)
[![nixpkgs](https://img.shields.io/badge/nixpkgs-sif-5277C3?style=flat-square&logo=nixos&logoColor=white)](https://search.nixos.org/packages?query=sif)
[![homebrew](https://img.shields.io/badge/homebrew-tap-FBB040?style=flat-square&logo=homebrew&logoColor=white)](https://github.com/vmfunc/homebrew-sif)
[![discord](https://img.shields.io/badge/discord-join-5865F2?style=flat-square&logo=discord&logoColor=white)](https://discord.gg/sifcli)
[![apt](https://img.shields.io/badge/apt-cloudsmith-2A5ADF?style=flat-square&logo=debian&logoColor=white)](https://cloudsmith.io/~sif/repos/deb/packages/)
[![discord](https://img.shields.io/badge/discord-join-5865F2?style=flat-square&logo=discord&logoColor=white)](https://discord.gg/Yksy9J2BvE)
**[install](#install) · [usage](#usage) · [modules](#modules) · [docs](docs/) · [contribute](#contribute)**
*fast, concurrent recon to exploitation in one binary. every scanner shares one connection-pooled http client.*
</div>
---
## what is sif?
sif is a modular pentesting toolkit written in go. it's designed to be fast, concurrent, and extensible. run multiple scan types against targets with a single command.
sif is a recon and exploitation scanner that runs the whole chain in one binary: subdomain enum, port scan, crawler, nuclei, framework/cve detection, js secret extraction, web-vuln probes (cors/xss/redirect), cloud and takeover checks. 25+ scan types, one command.
```bash
./sif -u https://example.com -all
sif -u https://example.com -dnslist -ports -crawl -js -framework -nuclei
```
nuclei and colly are compiled in as libraries rather than shelled out to (there's no `exec.Command` in the tree), so it's a single static binary with no runtime dependencies and nothing to wire together.
every scanner runs through one shared http client and a work-stealing worker pool. `-proxy`, `-H`, `-cookie` and `-rate-limit` apply to the whole run at once, connections get pooled and reused across the scan (a single-host run reuses one connection for ~50 requests instead of dialing 50 times), and a slow host doesn't hold the rest up. that shared client is the practical reason to use it over piping a stack of separate tools together. port scanning is `connect()`-based, so rustscan and nmap are still faster at raw port scans.
it reads targets from stdin and prints findings one per line under `-silent`, so it composes:
```bash
subfinder -d example.com | sif -silent -crawl -js -nuclei | notify
```
`-diff` turns a re-scan into a monitor that only reports what changed, `-notify` posts to slack/discord/telegram/webhook, and runs export to sarif and markdown.
## install
### homebrew (macos)
@@ -44,6 +60,32 @@ yay -S sif
paru -S sif
```
### nix
```bash
# nixpkgs (declarative: add to configuration.nix or home-manager)
environment.systemPackages = [ pkgs.sif ];
# or imperatively
nix profile install nixpkgs#sif
# or just run it without installing
nix run nixpkgs#sif -- -u https://example.com -headers -sh -framework
```
the repo also ships a flake if you want to build from source:
```bash
nix run github:vmfunc/sif
```
### debian/ubuntu (apt)
```bash
curl -1sLf 'https://dl.cloudsmith.io/public/sif/deb/setup.deb.sh' | sudo -E bash
sudo apt-get install sif
```
### from releases
grab the latest binary from [releases](https://github.com/vmfunc/sif/releases).
@@ -56,7 +98,7 @@ cd sif
make
```
requires go 1.23+
requires go 1.25+
### aur (manual install)
@@ -87,18 +129,39 @@ makepkg -si
# shodan host intelligence (requires SHODAN_API_KEY env var)
./sif -u https://example.com -shodan
# securitytrails domain discovery (requires SECURITYTRAILS_API_KEY env var)
# discovers subdomains + associated domains, then scans all of them
./sif -u https://example.com -securitytrails -headers
# sql recon + lfi scanning
./sif -u https://example.com -sql -lfi
# web vuln probes (cors, open redirect, reflected xss)
./sif -u https://example.com -cors -redirect -xss
# framework detection (with cve lookup)
./sif -u https://example.com -framework
# everything
./sif -u https://example.com -all
# a broad sweep
./sif -u https://example.com -dirlist small -dnslist small -ports common -headers -sh -cms -framework -git -whois
```
run `./sif -h` for all options.
## commands
a couple of subcommands run without scanning:
```bash
# print the version (release builds are stamped; local builds use git describe)
./sif version
# show the latest release notes (also -pn)
./sif patchnote
```
the first time you run a new release, sif prints that release's notes once. set `SIF_NO_PATCHNOTES=1` to turn that off.
## modules
sif has a modular architecture. modules are defined in yaml and can be extended by users.
@@ -108,21 +171,133 @@ sif has a modular architecture. modules are defined in yaml and can be extended
| flag | description |
|------|-------------|
| `-dirlist` | directory and file fuzzing (small/medium/large) |
| `-mc` | dirlist: match these status codes (comma list, e.g. 200,301) |
| `-fc` | dirlist: filter out these status codes (comma list) |
| `-fs` | dirlist: filter out responses of these body sizes (comma list) |
| `-fw` | dirlist: filter out responses with these word counts (comma list) |
| `-fr` | dirlist: filter out responses whose body matches this regex |
| `-ac` | auto-calibrate the soft-404 wildcard baseline (dirlist, sql) |
| `-w` | dirlist: custom wordlist (local file or url; overrides `-dirlist` size) |
| `-e` | dirlist: extensions appended to each word (comma list, e.g. php,bak,env) |
| `-dnslist` | subdomain enumeration (small/medium/large) |
| `-ports` | port scanning (common/full) |
| `-nuclei` | vulnerability scanning with nuclei templates |
| `-dork` | automated google dorking |
| `-js` | javascript analysis |
| `-js` | javascript analysis + secret and endpoint extraction |
| `-c3` | cloud storage misconfiguration |
| `-headers` | http header analysis |
| `-sh` | security header analysis (missing/weak headers) |
| `-st` | subdomain takeover detection |
| `-cms` | cms detection |
| `-whois` | whois lookups |
| `-git` | exposed git repository detection |
| `-shodan` | shodan lookup (requires SHODAN_API_KEY) |
| `-securitytrails` | domain discovery + target expansion (requires SECURITYTRAILS_API_KEY) |
| `-sql` | sql recon |
| `-lfi` | local file inclusion |
| `-jwt` | jwt discovery + offline weakness analysis (alg:none, weak hmac, exp, sensitive claims) |
| `-openapi` | openapi/swagger spec exposure probe (enumerates paths + unauth endpoints) |
| `-favicon` | favicon hash fingerprinting (shodan-style mmh3, tech match + pivot query) |
| `-cors` | cors misconfiguration probe |
| `-redirect` | open redirect probe |
| `-xss` | reflected xss probe |
| `-framework` | framework detection with cve lookup |
| `-crawl` | web crawler (spider same-host links/scripts/forms) |
| `-crawl-depth` | max crawl recursion depth (default 2) |
| `-passive` | passive subdomain/url discovery (zero traffic to target) |
| `-probe` | live-host probe (status, title, server, redirect chain) |
### http options
these apply to every outbound request across all scanners:
| flag | description |
|------|-------------|
| `-proxy` | route all traffic through a proxy (http/https/socks5 url) |
| `-H`, `--header` | custom header to send (repeatable or comma-separated, `"Key: Value"`) |
| `-cookie` | cookie header to send with every request |
| `-rate-limit` | max requests per second (0 = unlimited, default 0) |
```bash
# scan through a socks5 proxy with a custom header, cookie and 20 req/s cap
./sif -u https://example.com -headers -proxy socks5://127.0.0.1:1080 -H "Authorization: Bearer tok" -cookie "session=abc" -rate-limit 20
```
a scanner that sets a header explicitly (e.g. an api key) always wins over the global default.
### report export
write the run's findings out to a file for ci/cd or triage:
| flag | description |
|------|-------------|
| `-sarif` | write a sarif 2.1.0 report to this file |
| `-markdown`, `-md` | write a markdown report to this file |
| `-silent` | plain output: chrome to stderr, one finding per line to stdout (for pipelines) |
| `-diff` | surface only findings added/removed since the last snapshot of each target |
| `-store` | snapshot directory for `-diff` (default: log dir, else `<user-config>/sif/state`) |
```bash
# scan and emit both a sarif and markdown report
./sif -u https://example.com -headers -cors -sarif out.sarif -md out.md
```
sarif output is ingestable by github code scanning; markdown is a readable per-target summary.
### diff mode
`-diff` turns a re-scan into a monitor: sif snapshots each target's normalized findings to a json file, and on the next run reports only the delta (`+ new` / `- gone`) against that snapshot, then overwrites it. the first run for a target has no baseline, so everything is `+ new`. snapshots land in `-store` (one sanitized file per target); when unset they reuse the log dir, falling back to `<user-config>/sif/state`.
```bash
# baseline run, then re-scan later and see only what moved
./sif -u https://example.com -sh -cors -diff
./sif -u https://example.com -sh -cors -diff
```
the snapshot is always rewritten, so each run diffs against the previous one. the delta is chrome (it rides the normal output sink / stderr under `-silent`), not the findings stream.
### notify
ship findings to a chat/webhook sink so a continuous-recon run alerts on what it turns up. every provider is a single POST through the shared http client, so the global proxy/rate-limit/header config applies.
| flag | description |
|------|-------------|
| `-notify` | ship findings to every configured provider after the scan |
| `-notify-severity` | minimum severity to send (`info`/`low`/`medium`/`high`/`critical`, default `medium`) |
| `-notify-config` | path to a notify-compatible yaml config (overrides env vars) |
providers are configured env-first; a yaml file (`-notify-config`) overrides per-field. the yaml keys match [projectdiscovery/notify](https://github.com/projectdiscovery/notify) so an existing config ports over:
| env var | yaml key | provider |
|---------|----------|----------|
| `SLACK_WEBHOOK_URL` | `slack_webhook_url` | slack incoming webhook |
| `DISCORD_WEBHOOK_URL` | `discord_webhook_url` | discord webhook |
| `TELEGRAM_BOT_TOKEN` | `telegram_api_key` | telegram bot api (needs chat id too) |
| `TELEGRAM_CHAT_ID` | `telegram_chat_id` | telegram destination chat |
| `NOTIFY_WEBHOOK_URL` | `webhook_url` | generic json webhook (structured findings) |
```bash
# alert slack on medium+ findings discovered during a scan
export SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...
./sif -u https://example.com -cors -xss -notify -notify-severity medium
```
a provider with no destination is skipped; with nothing configured, `-notify` is a silent no-op. slack/discord/telegram receive a fixed-width finding block; the generic webhook receives structured json (`{count, findings[]}`).
### pipe mode
sif reads targets from stdin and accepts naked hosts, so it drops into a unix pipeline. `-silent` routes all banner/spinner/log chrome to stderr and prints one normalized finding per line (`[severity] target module title`) to stdout:
```bash
# subfinder feeds hosts, sif probes them, notify ships the findings
subfinder -d example.com | sif -silent -probe | notify
```
| flag | description |
|------|-------------|
| stdin | a piped target stream (one host/url per line) is read alongside `-u`/`-f` |
scheme-less hosts default to `https://`; an explicit `http://`/`https://` is kept; any other scheme (`ftp://`, ...) is rejected.
### yaml modules
@@ -190,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 ./...
@@ -210,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>
+15
View File
@@ -0,0 +1,15 @@
# security policy
## reporting a vulnerability
if you find a security issue in sif, email celeste@linux.com directly.
don't open a public issue.
expect a response within 48 hours. if it's confirmed, i'll push a fix
and credit you in the release notes (unless you'd rather stay anonymous).
## scope
sif is a pentesting tool — "it can scan things" is not a vulnerability.
actual bugs: command injection in user input handling, path traversal in
template extraction, credential leaks, that kind of thing.
+30 -1
View File
@@ -4,7 +4,7 @@
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2025 vmfunc (vmfunc), xyzeva, :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
@@ -13,15 +13,38 @@
package main
import (
"fmt"
"os"
"github.com/charmbracelet/log"
"github.com/dropalldatabases/sif"
"github.com/dropalldatabases/sif/internal/config"
"github.com/dropalldatabases/sif/internal/patchnotes"
ver "github.com/dropalldatabases/sif/internal/version"
// Register framework detectors
_ "github.com/dropalldatabases/sif/internal/scan/frameworks/detectors"
)
// version is stamped at release time via -ldflags "-X main.version=...";
// ver.Resolve falls back to the build info or "dev" for other builds.
var version = "dev"
func main() {
version = ver.Resolve(version)
sif.Version = version
if len(os.Args) > 1 {
switch os.Args[1] {
case "patchnote", "patchnotes", "-pn", "--patchnotes":
patchnotes.Print("")
return
case "version", "-version", "--version":
fmt.Printf("sif %s\n", version)
return
}
}
settings := config.Parse()
app, err := sif.New(settings)
@@ -29,6 +52,12 @@ func main() {
log.Fatal(err)
}
// patchnotes print to stdout; skip them in api/silent mode so the only thing
// on stdout is the machine-readable result stream.
if !settings.ApiMode && !settings.Silent {
patchnotes.ShowOnce(version)
}
err = app.Run()
if err != nil {
log.Fatal(err)
+38
View File
@@ -57,6 +57,12 @@ enable verbose logging:
./sif -u https://example.com -d
```
### templates
`-template` loads a batch of scan settings from a built-in preset or a local yaml file, so a run does not have to pass every flag. see the [usage guide](usage.md) for the presets and file format. command-line flags still take precedence over the template.
sif also reads an ambient config at `~/.config/sif/config.yaml` (created on first run) keyed by the same flag names. passing `-template` uses that template as the config for the run instead of the ambient file.
## user modules
place custom modules in:
@@ -92,6 +98,38 @@ info:
# ...
```
## custom signatures
framework detection (`-framework`) also loads user-defined detectors from yaml
files, so a framework sif does not ship can be detected without rebuilding:
- linux/macos: `~/.config/sif/signatures/`
- windows: `%LOCALAPPDATA%\sif\signatures\`
each file defines one detector; place them directly in the directory, as
subdirectories are not scanned. `header: true` matches a response header name or
value (case-insensitive) instead of the body; the optional `version` block pulls
a version out of the body.
```yaml
# ~/.config/sif/signatures/ghost.yaml
name: Ghost
signatures:
- pattern: 'content="Ghost'
weight: 0.6
- pattern: 'X-Ghost-Cache'
weight: 0.4
header: true
version:
regex: 'content="Ghost ([0-9.]+)'
group: 1
```
a detector reports a match once its matched signature weights sum past half, so
weight your signatures to total about `1.0`. a name matching a built-in detector
overrides it and inherits that built-in's version patterns and known cves, the
same as user modules.
## performance tuning
### fast scans
+16 -5
View File
@@ -4,7 +4,7 @@ setting up a development environment for sif.
## prerequisites
- go 1.23 or later
- go 1.25 or later
- git
- make
@@ -28,8 +28,7 @@ sif/
│ ├── logger/ # logging utilities
│ ├── modules/ # module system
│ ├── scan/ # built-in scans
── styles/ # terminal styling
│ └── worker/ # worker pool
── styles/ # terminal styling
├── modules/ # built-in yaml modules
│ ├── http/ # http-based modules
│ ├── info/ # information gathering
@@ -61,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
@@ -138,6 +140,15 @@ the module system is in `internal/modules/`:
go test ./internal/...
```
### integration tests
run the scanners against a local testbed that plants the artifacts each one
should find (network-free, behind a build tag):
```bash
go test -tags=integration ./internal/scan/...
```
### functional test
```bash
@@ -156,7 +167,7 @@ go test ./internal/...
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
+1 -1
View File
@@ -36,7 +36,7 @@ download `sif-windows-amd64.exe` from releases and add to your PATH.
## from source
requires go 1.23+
requires go 1.25+
```bash
git clone https://github.com/dropalldatabases/sif.git
+94 -1
View File
@@ -115,6 +115,29 @@ http:
each payload creates a separate request for each path.
#### attack
how paths and payloads combine into requests.
```yaml
http:
attack: pitchfork
```
- `clusterbomb` (default) - every path is tried with every payload
- `pitchfork` - path and payload are paired by index, stopping at the shorter list
#### wordlist
a local file whose non-empty lines fuzz the `{{word}}` placeholder, one request
per word. paths without `{{word}}` are still requested as-is.
```yaml
http:
wordlist: /usr/share/wordlists/dirs.txt
paths:
- "{{BaseURL}}/{{word}}"
```
#### headers
custom headers to send.
@@ -199,6 +222,42 @@ matchers:
condition: or
```
### size matcher
match the response body length in bytes (measured after the 5 MB response cap, so larger sizes never match).
```yaml
matchers:
- type: size
size:
- 0
- 1337
```
### favicon matcher
match the shodan-style mmh3 hash of the response body. point the module at a
favicon and list the hashes of the tech you want to fingerprint.
```yaml
http:
paths:
- "{{BaseURL}}/favicon.ico"
matchers:
- type: status
status:
- 200
- type: favicon
hash:
- -235701012 # jenkins
- 1278322581 # grafana
```
the hash is shodan's `http.favicon.hash` value. paste it signed or unsigned;
both 32-bit forms are accepted, so values from shodan or any favicon-hash tool
drop in without conversion. pair it with a `status: 200` matcher so an error
page served for `/favicon.ico` is not hashed. a finding fires when the body
hashes to any listed value.
### combining matchers
multiple matchers are combined with AND logic by default.
@@ -218,6 +277,25 @@ matchers:
this matches responses with status 200 AND containing "ref: refs/".
to require any matcher instead of all, set `matchers-condition: or` on the http
block; the module then reports a finding when any one matcher matches.
```yaml
http:
matchers-condition: or
matchers:
- type: status
status:
- 401
- type: status
status:
- 403
```
this matches a 401 OR a 403 response. `matchers-condition` accepts `and` (the
default) or `or`; any other value fails at load.
## extractors
extractors pull data from responses.
@@ -238,7 +316,7 @@ extractors:
### kv extractor
extract key-value pairs.
record every response header as a key-value pair, namespaced by `name`.
```yaml
extractors:
@@ -247,6 +325,21 @@ extractors:
part: header
```
### json extractor
extract values from a json body by gjson path (github.com/tidwall/gjson); the
first path that exists is stored under name.
```yaml
extractors:
- type: json
name: version
part: body
json:
- "version"
- "data.version"
```
## examples
### exposed git repository
+15 -4
View File
@@ -98,16 +98,27 @@ analyzes javascript files for security issues.
## http headers (-headers)
analyzes security headers.
dumps the target's response headers.
## security headers (-sh)
flags missing or weak security headers and headers that leak server internals.
### checks
- strict-transport-security (https only)
- content-security-policy
- x-frame-options
- x-content-type-options
- strict-transport-security
- x-xss-protection
- x-content-type-options (expects nosniff)
- referrer-policy
- permissions-policy
- cross-origin-opener-policy
### flagged as disclosure
- server
- x-powered-by
- x-aspnet-version / x-aspnetmvc-version
## cms detection (-cms)
+330 -3
View File
@@ -21,6 +21,23 @@ read targets from a file (one url per line):
./sif -f targets.txt
```
### stdin (pipe mode)
when stdin is a pipe, sif reads one target per line from it, alongside any `-u`/`-f` targets. this lets sif slot into a unix pipeline:
```bash
subfinder -d example.com | sif -silent -probe | notify
```
### naked hosts
targets without a scheme default to `https://`; an explicit `http://`/`https://` is kept as given. any other scheme (`ftp://`, `file://`, ...) is rejected:
```bash
./sif -u example.com # scanned as https://example.com
echo example.com | sif -probe # same, over stdin
```
## scan options
### directory fuzzing
@@ -33,6 +50,42 @@ sizes: `small`, `medium`, `large`
./sif -u https://example.com -dirlist medium
```
#### response filters
modern apps serve a catch-all 200 for unknown routes, so a naive scan reports
every path. these ffuf-style filters cut the noise (a filter always wins over a
match):
- `-mc <codes>` - match only these status codes (comma list, e.g. `200,301`)
- `-fc <codes>` - filter out these status codes
- `-fs <sizes>` - filter out responses of these body sizes
- `-fw <counts>` - filter out responses with these word counts
- `-fr <regex>` - filter out responses whose body matches this regex
```bash
./sif -u https://example.com -dirlist medium -mc 200,301 -fs 1234
```
#### wildcard calibration
`-ac` probes a few paths that cannot exist, learns the soft-404 baseline
(status + size + words), and auto-drops any response matching it - so SPA
catch-all 200s stop flooding the output:
```bash
./sif -u https://example.com -dirlist medium -ac
```
#### custom wordlists and extensions
`-w <path|url>` overrides the size switch with your own list (local file or
remote url); `-e <exts>` appends each extension to every word, keeping the bare
word too:
```bash
./sif -u https://example.com -w /path/to/words.txt -e php,bak,env
```
### subdomain enumeration
`-dnslist <size>` - enumerate subdomains
@@ -79,7 +132,7 @@ scopes: `common` (top ports), `full` (all ports)
### javascript analysis
`-js` - analyze javascript files
`-js` - analyze javascript files + secret and endpoint extraction
```bash
./sif -u https://example.com -js
@@ -95,12 +148,20 @@ scopes: `common` (top ports), `full` (all ports)
### http headers
`-headers` - analyze security headers
`-headers` - dump the target's response headers
```bash
./sif -u https://example.com -headers
```
### security headers
`-sh` - flag missing/weak security headers (hsts, csp, x-frame-options, ...) and headers that leak server internals
```bash
./sif -u https://example.com -sh
```
### cloud storage
`-c3` - check for cloud storage misconfigurations
@@ -146,6 +207,56 @@ export SHODAN_API_KEY=your-api-key
./sif -u https://example.com -lfi
```
### cors probe
`-cors` - probe for cors misconfigurations (reflected/permissive origins)
```bash
./sif -u https://example.com -cors
```
### open redirect probe
`-redirect` - probe redirect-prone params for open redirects
```bash
./sif -u https://example.com/login?next=home -redirect
```
### reflected xss probe
`-xss` - inject a canary into params and report unescaped reflections
```bash
./sif -u https://example.com/search?q=test -xss
```
### jwt analysis
`-jwt` - fetch the target once, harvest jwts from response headers, cookies and body, then analyze each one entirely offline
flags alg:none, the rs256->hs256 confusion surface, missing/expired exp, plaintext sensitive claims, and cracks a small bundled weak-hmac wordlist. no token is ever sent off-box.
```bash
./sif -u https://example.com -jwt
```
### openapi/swagger exposure
`-openapi` - probe the conventional spec paths (`/swagger.json`, `/openapi.json`, `/v3/api-docs`, ...), parse the first hit (json or yaml) and enumerate every path+method, flagging operations with no security requirement
```bash
./sif -u https://example.com -openapi
```
### favicon fingerprint
`-favicon` - fetch `/favicon.ico` (or the declared `<link rel=icon>`), compute the shodan-style mmh3 hash, match it against a bundled tech map and print the `http.favicon.hash:<n>` pivot query
```bash
./sif -u https://example.com -favicon
```
### framework detection
`-framework` - detect web frameworks with version and cve lookup
@@ -154,6 +265,34 @@ export SHODAN_API_KEY=your-api-key
./sif -u https://example.com -framework
```
### web crawler
`-crawl` - spider the target, following same-host links, scripts and forms
`-crawl-depth` - max recursion depth (default 2). respects robots.txt and stays on the target host.
```bash
./sif -u https://example.com -crawl -crawl-depth 3
```
### passive discovery
`-passive` - gather subdomains from certificate transparency (crt.sh, certspotter) and historical urls from the wayback machine
keyless and zero traffic to the target itself - all lookups hit third-party feeds.
```bash
./sif -u https://example.com -passive
```
### live-host probe
`-probe` - check whether the target is alive and report its final status, page title, server header, content-length and the redirect chain it walked
```bash
./sif -u https://example.com -probe
```
### whois lookup
`-whois` - perform whois lookups
@@ -217,7 +356,7 @@ http request timeout (default: 10s):
### --threads
number of concurrent threads (default: 10):
number of concurrent threads (default: 10). values below 1 are clamped to 1:
```bash
./sif -u https://example.com --threads 20
@@ -239,6 +378,169 @@ enable debug logging:
./sif -u https://example.com -d
```
### --template
load a batch of scan settings from a template instead of passing each flag. the value is either a built-in preset or a local yaml file keyed by flag long-names:
```bash
./sif -u https://example.com --template recon
./sif -u https://example.com --template ./my-scans.yaml
```
built-in presets:
- `minimal`: liveness and fingerprint only (probe, headers, favicon)
- `recon`: broad non-intrusive discovery, no attack payloads
- `full`: every scan except the api-key ones (shodan, securitytrails), including the intrusive probes (xss, sql, lfi, redirect)
`full` sends attack payloads, so only run it against targets you are authorized to test.
a local template lists flag long-names, for example:
```yaml
cms: true
dirlist: medium
threads: 20
```
flags passed on the command line take precedence over the template, so `--template recon -xss` runs the recon preset with an added xss probe.
## http options
these apply to every outbound request across all scanners (proxy, custom headers, cookie and rate limiting share one client). a scanner that sets a header explicitly still wins over the global default.
### -proxy
route all traffic through a proxy. supports http, https and socks5 urls:
```bash
./sif -u https://example.com -proxy socks5://127.0.0.1:1080
```
### -H, --header
add a custom header to every request. repeatable or comma-separated, `"Key: Value"`:
```bash
./sif -u https://example.com -H "Authorization: Bearer tok" -H "X-Env: staging"
```
### -cookie
cookie header to send with every request:
```bash
./sif -u https://example.com -cookie "session=abc; theme=dark"
```
### -rate-limit
cap outbound requests per second (0 = unlimited, default 0):
```bash
./sif -u https://example.com -rate-limit 20
```
## output options
write the collected findings out to a file after the scan. both formats can be requested in the same run.
### -sarif
write a sarif 2.1.0 report (one run, tool `sif`, one result per finding). ingestable by github code scanning and other sarif consumers:
```bash
./sif -u https://example.com -headers -cors -sarif out.sarif
```
### -md, --markdown
write a readable markdown report grouped by target, then by module:
```bash
./sif -u https://example.com -headers -cors -md report.md
```
### -silent
plain output for pipelines: all banner/spinner/log chrome goes to stderr and stdout carries one normalized finding per line, formatted `[severity] target module title`. implies non-interactive (no spinners), so a downstream consumer sees nothing but findings:
```bash
subfinder -d example.com | sif -silent -probe -sh | notify
```
### -diff
turn a re-scan into a monitor. sif snapshots each target's normalized findings to a json file under the store dir; on the next run it loads that snapshot, diffs the current findings against it by finding key, and prints only the delta (`+ new` for findings that appeared, `- gone` for findings that vanished). it always rewrites the snapshot afterwards, so each run compares against the previous one.
the first run for a target has no snapshot, so every finding shows as `+ new`. when nothing changed, sif notes that and writes a fresh snapshot anyway.
```bash
# baseline, then re-scan and see only what moved
./sif -u https://example.com -sh -cors -diff
./sif -u https://example.com -sh -cors -diff
```
the delta is chrome, not the findings stream: under `-silent` it rides stderr with the rest of the chrome, leaving stdout for the full findings.
### -store
snapshot directory for `-diff`. precedence when unset: the `-log` dir if one is given, else `<user-config>/sif/state` (`$XDG_CONFIG_HOME/sif/state` on linux, `~/Library/Application Support/sif/state` on macos). one sanitized file per target, created at `0750`, written `0600`.
```bash
./sif -u https://example.com -sh -diff -store ./snapshots
```
## notify options
ship findings to a chat/webhook sink after the scan. every provider is a single POST through the shared http client, so the global proxy/rate-limit/header config applies. with nothing configured, `-notify` is a silent no-op.
### -notify
enable delivery to every configured provider:
```bash
export SLACK_WEBHOOK_URL=https://hooks.slack.com/services/...
./sif -u https://example.com -cors -xss -notify
```
### -notify-severity
minimum severity to send: `info`, `low`, `medium`, `high` or `critical` (default `medium`). findings below the floor are dropped, so info-level recon noise doesn't flood a channel. an unrecognized value falls back to `medium`:
```bash
./sif -u https://example.com -cors -notify -notify-severity high
```
### -notify-config
path to a yaml config that overrides the env vars per-field. the keys match [projectdiscovery/notify](https://github.com/projectdiscovery/notify) so an existing config ports over:
```yaml
slack_webhook_url: https://hooks.slack.com/services/...
discord_webhook_url: https://discord.com/api/webhooks/...
telegram_api_key: 123456:abcdef
telegram_chat_id: "987654"
webhook_url: https://example.internal/sif-findings
```
```bash
./sif -u https://example.com -cors -notify -notify-config notify.yaml
```
providers are resolved env-first, then overlaid by the yaml file:
| env var | yaml key | provider |
|---------|----------|----------|
| `SLACK_WEBHOOK_URL` | `slack_webhook_url` | slack incoming webhook |
| `DISCORD_WEBHOOK_URL` | `discord_webhook_url` | discord webhook |
| `TELEGRAM_BOT_TOKEN` | `telegram_api_key` | telegram bot api (needs chat id too) |
| `TELEGRAM_CHAT_ID` | `telegram_chat_id` | telegram destination chat |
| `NOTIFY_WEBHOOK_URL` | `webhook_url` | generic json webhook (structured findings) |
slack/discord/telegram receive a fixed-width finding block; the generic webhook receives structured json (`{count, findings[]}`) for downstream automation.
## api options
### -api
@@ -251,6 +553,28 @@ enable api mode for json output:
output is a json object with scan results.
## commands
these run without scanning a target.
### version
print the sif version. release builds are stamped via ldflags, local `make` builds derive it from `git describe`, and `go install`ed builds read it from the module build info:
```bash
./sif version
```
### patchnote
show the latest release's notes, fetched from github (also `-pn`):
```bash
./sif patchnote
```
the first time you run a new release sif also prints that release's notes once. set `SIF_NO_PATCHNOTES=1` to disable that.
## examples
### quick recon
@@ -273,6 +597,9 @@ output is a json object with scan results.
-git \
-sql \
-lfi \
-cors \
-redirect \
-xss \
-am
```
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1767364772,
"narHash": "sha256-fFUnEYMla8b7UKjijLnMe+oVFOz6HjijGGNS1l7dYaQ=",
"lastModified": 1780930886,
"narHash": "sha256-rppURzHviaQN131F+nLiLdGfcb0uCd9gGP0E5+iw9MI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "16c7794d0a28b5a37904d55bcca36003b9109aaa",
"rev": "8c3cede7ddc26bd659d2d383b5610efbd2c7a16e",
"type": "github"
},
"original": {
+1 -1
View File
@@ -21,7 +21,7 @@
version = "unstable-${self.shortRev or self.dirtyShortRev or "dev"}";
src = ./.;
vendorHash = "sha256-ztKXnOjZS/jMxsRjtF0rIZ3lKv4YjMdZd6oQFRuAtR4=";
vendorHash = "sha256-fR63/dStMsZon22vancuLWIAvZiEYMLjMwY1kmRDNgM=";
# Tests require network access (httptest)
doCheck = false;
+328 -143
View File
@@ -1,223 +1,408 @@
module github.com/dropalldatabases/sif
go 1.24.0
go 1.25.7
require (
github.com/antchfx/htmlquery v1.3.0
github.com/charmbracelet/lipgloss v0.8.0
github.com/charmbracelet/log v0.2.4
github.com/likexian/whois v1.15.1
github.com/projectdiscovery/goflags v0.1.54
github.com/projectdiscovery/nuclei/v2 v2.9.14
github.com/projectdiscovery/ratelimit v0.0.9
github.com/projectdiscovery/utils v0.1.1
github.com/antchfx/htmlquery v1.3.6
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.3.0
github.com/likexian/whois v1.15.7
github.com/projectdiscovery/goflags v0.1.74
github.com/projectdiscovery/nuclei/v3 v3.9.0
github.com/projectdiscovery/retryabledns v1.0.115
github.com/projectdiscovery/utils v0.11.1
github.com/rocketlaunchr/google-search v1.1.6
github.com/tidwall/gjson v1.18.0
github.com/twmb/murmur3 v1.1.8
golang.org/x/net v0.56.0
golang.org/x/time v0.15.0
gopkg.in/yaml.v3 v3.0.1
)
require (
aead.dev/minisign v0.2.0 // indirect
aead.dev/minisign v0.3.0 // indirect
carvel.dev/ytt v0.52.0 // indirect
code.gitea.io/sdk/gitea v0.17.0 // indirect
dario.cat/mergo v1.0.2 // indirect
filippo.io/edwards25519 v1.1.1 // indirect
git.mills.io/prologic/smtpd v0.0.0-20210710122116-a525b76c287a // indirect
github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible // indirect
github.com/Masterminds/semver/v3 v3.2.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.18.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.10.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.1.0 // indirect
github.com/Azure/go-ntlmssp v0.1.1 // indirect
github.com/AzureAD/microsoft-authentication-library-for-go v1.4.2 // indirect
github.com/FalconOpsLLC/goexec v0.3.0 // indirect
github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/Mzack9999/gcache v0.0.0-20230410081825-519e28eab057 // indirect
github.com/Mzack9999/go-http-digest-auth-client v0.6.1-0.20220414142836-eb8883508809 // indirect
github.com/PuerkitoBio/goquery v1.8.1 // indirect
github.com/Mzack9999/go-rsync v0.0.0-20250821180103-81ffa574ef4d // indirect
github.com/Mzack9999/goimpacket v0.0.0-20260422121140-7085336a0415 // indirect
github.com/Mzack9999/goja v0.0.0-20250507184235-e46100e9c697 // indirect
github.com/Mzack9999/goja_nodejs v0.0.0-20250507184139-66bcbf65c883 // indirect
github.com/ProtonMail/go-crypto v1.1.6 // indirect
github.com/PuerkitoBio/goquery v1.11.0 // indirect
github.com/RedTeamPentesting/adauth v0.5.4-0.20260511073005-3d18e8a5a687 // indirect
github.com/STARRY-S/zip v0.2.3 // indirect
github.com/VividCortex/ewma v1.2.0 // indirect
github.com/akrylysov/pogreb v0.10.1 // indirect
github.com/akrylysov/pogreb v0.10.2 // indirect
github.com/alecthomas/chroma v0.10.0 // indirect
github.com/alecthomas/jsonschema v0.0.0-20211022214203-8b29eab41725 // indirect
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect
github.com/andybalholm/brotli v1.0.6 // indirect
github.com/andybalholm/cascadia v1.3.2 // indirect
github.com/andygrunwald/go-jira v1.16.0 // indirect
github.com/antchfx/xmlquery v1.3.15 // indirect
github.com/antchfx/xpath v1.2.4 // indirect
github.com/alexsnet/go-vnc v0.1.0 // indirect
github.com/alitto/pond v1.9.2 // indirect
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.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.19.0 // indirect
github.com/aws/aws-sdk-go-v2/config v1.18.28 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.13.27 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.13.5 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.35 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.29 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.3.36 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.29 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.12.13 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.14.13 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.19.3 // indirect
github.com/aws/smithy-go v1.13.5 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.5 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/config v1.29.17 // indirect
github.com/aws/aws-sdk-go-v2/credentials v1.17.70 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.32 // indirect
github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.82 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.25.5 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.30.3 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.34.0 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
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.24.4 // indirect
github.com/bits-and-blooms/bloom/v3 v3.5.0 // indirect
github.com/bluele/gcache v0.0.2 // indirect
github.com/caddyserver/certmagic v0.19.2 // indirect
github.com/charmbracelet/glamour v0.6.0 // indirect
github.com/cheggaaa/pb/v3 v3.1.4 // indirect
github.com/bodgit/plumbing v1.3.0 // indirect
github.com/bodgit/sevenzip v1.6.1 // indirect
github.com/bodgit/windows v1.0.1 // indirect
github.com/brianvoe/gofakeit/v7 v7.2.1 // indirect
github.com/buger/jsonparser v1.1.2 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/caddyserver/certmagic v0.25.0 // indirect
github.com/caddyserver/zerossl v0.1.3 // indirect
github.com/censys/censys-sdk-go v0.19.1 // indirect
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.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
github.com/cheggaaa/pb/v3 v3.1.7 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/cloudflare/cfssl v1.6.4 // indirect
github.com/cnf/structhash v0.0.0-20201127153200-e1b16c1ebc08 // indirect
github.com/corpix/uarand v0.2.0 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/cnf/structhash v0.0.0-20250313080605-df4c6cc74a9a // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/cyphar/filepath-securejoin v0.6.1 // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dimchansky/utfbom v1.1.1 // indirect
github.com/dlclark/regexp2 v1.8.1 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/ditashi/jsbeautifier-go v0.0.0-20141206144643-2520a8026a9c // indirect
github.com/djherbis/times v1.6.0 // indirect
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/docker/go-connections v0.7.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect
github.com/fatih/color v1.16.0 // indirect
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/ericlagergren/decimal v0.0.0-20240411145413-00de7ca16731 // indirect
github.com/fatih/color v1.18.0 // indirect
github.com/fatih/structs v1.1.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/felixge/fgprof v0.9.5 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/free5gc/util v1.0.5-0.20230511064842-2e120956883b // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gaissmai/bart v0.28.0 // indirect
github.com/geoffgarside/ber v1.2.0 // indirect
github.com/getkin/kin-openapi v0.132.0 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/gin-gonic/gin v1.9.1 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-fed/httpsig v1.1.0 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.9.0 // indirect
github.com/go-git/go-git/v5 v5.19.1 // indirect
github.com/go-ldap/ldap/v3 v3.4.12 // indirect
github.com/go-logfmt/logfmt v0.6.1 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
github.com/go-pdf/fpdf v0.9.0 // indirect
github.com/go-pg/pg/v10 v10.15.0 // indirect
github.com/go-pg/zerochecker v0.2.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.1 // indirect
github.com/go-rod/rod v0.114.0 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/go-rod/rod v0.116.2 // indirect
github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect
github.com/go-sql-driver/mysql v1.9.3 // indirect
github.com/goburrow/cache v0.1.4 // indirect
github.com/gobwas/glob v0.2.3 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.2.1 // indirect
github.com/gocolly/colly/v2 v2.1.0 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golang/snappy v0.0.4 // indirect
github.com/google/certificate-transparency-go v1.1.4 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
github.com/golang-sql/sqlexp v0.1.0 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/golang/snappy v1.0.0 // indirect
github.com/google/certificate-transparency-go v1.3.2 // indirect
github.com/google/go-github v17.0.0+incompatible // indirect
github.com/google/go-github/v30 v30.1.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/pprof v0.0.0-20240727154555-813a5fbdbec8 // indirect
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
github.com/google/uuid v1.3.1 // indirect
github.com/gorilla/css v1.0.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/gosimple/slug v1.15.0 // indirect
github.com/gosimple/unidecode v1.0.1 // indirect
github.com/h2non/filetype v1.1.3 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.7 // indirect
github.com/hashicorp/go-version v1.6.0 // indirect
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
github.com/hashicorp/go-uuid v1.0.3 // indirect
github.com/hashicorp/go-version v1.8.0 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/hbakhtiyor/strsim v0.0.0-20190107154042-4d2bbb273edf // indirect
github.com/hdm/jarm-go v0.0.7 // indirect
github.com/iancoleman/orderedmap v0.0.0-20190318233801-ac98e3ecb4b0 // indirect
github.com/itchyny/gojq v0.12.13 // indirect
github.com/itchyny/timefmt-go v0.1.5 // indirect
github.com/iangcarroll/cookiemonster v1.6.0 // indirect
github.com/imdario/mergo v0.3.16 // indirect
github.com/indece-official/go-ebcdic v1.2.0 // indirect
github.com/invopop/jsonschema v0.13.0 // indirect
github.com/invopop/yaml v0.3.1 // indirect
github.com/itchyny/gojq v0.12.17 // indirect
github.com/itchyny/timefmt-go v0.1.6 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jcmturner/aescts/v2 v2.0.0 // indirect
github.com/jcmturner/dnsutils/v2 v2.0.0 // indirect
github.com/jcmturner/gofork v1.7.6 // indirect
github.com/jcmturner/goidentity/v6 v6.0.1 // indirect
github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect
github.com/jcmturner/rpc/v2 v2.0.3 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/josharian/intern v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/julienschmidt/httprouter v1.3.0 // indirect
github.com/kataras/jwt v0.1.8 // indirect
github.com/k14s/starlark-go v0.0.0-20200720175618-3a5c849cc368 // indirect
github.com/kaiakz/ubuffer v0.0.0-20200803053910-dd1083087166 // indirect
github.com/kataras/jwt v0.1.10 // indirect
github.com/kennygrant/sanitize v1.2.4 // indirect
github.com/klauspost/compress v1.17.4 // indirect
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
github.com/klauspost/pgzip v1.2.5 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/libdns/libdns v0.2.1 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/kitabisa/go-ci v1.0.3 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/labstack/echo/v4 v4.13.4 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/leslie-qiwa/flat v0.0.0-20230424180412-f9d1cf014baa // indirect
github.com/lib/pq v1.11.2 // indirect
github.com/libdns/libdns v1.1.1 // indirect
github.com/logrusorgru/aurora v2.0.3+incompatible // indirect
github.com/lor00x/goldap v0.0.0-20180618054307-a546dffdd1a3 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/mackerelio/go-osstat v0.2.4 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.14 // indirect
github.com/mholt/acmez v1.2.0 // indirect
github.com/mholt/archiver v3.1.1+incompatible // indirect
github.com/mholt/archiver/v3 v3.5.1 // indirect
github.com/microcosm-cc/bluemonday v1.0.25 // indirect
github.com/miekg/dns v1.1.56 // indirect
github.com/logrusorgru/aurora/v4 v4.0.0 // indirect
github.com/lor00x/goldap v0.0.0-20240304151906-8d785c64d1c8 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/lufia/plan9stats v0.0.0-20250827001030-24949be3fa54 // indirect
github.com/mackerelio/go-osstat v0.2.6 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mattn/go-runewidth v0.0.17 // indirect
github.com/mattn/go-sqlite3 v1.14.28 // indirect
github.com/maypok86/otter/v2 v2.2.1 // indirect
github.com/mholt/acmez/v3 v3.1.3 // indirect
github.com/mholt/archives v0.1.5 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/microsoft/go-mssqldb v1.9.2 // indirect
github.com/miekg/dns v1.1.68 // indirect
github.com/mikelolasagasti/xz v1.0.1 // indirect
github.com/minio/minlz v1.0.1 // indirect
github.com/minio/selfupdate v0.6.1-0.20230907112617-f11e74f84ca7 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/moby/api v1.54.2 // indirect
github.com/moby/moby/client v0.4.1 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
github.com/montanaflynn/stats v0.7.1 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.15.2 // indirect
github.com/nwaples/rardecode v1.1.3 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/pierrec/lz4 v2.6.1+incompatible // indirect
github.com/pierrec/lz4/v4 v4.1.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/nlnwa/whatwg-url v0.6.2 // indirect
github.com/nwaples/rardecode/v2 v2.2.2 // indirect
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
github.com/oiweiwei/go-msrpc v1.2.12 // indirect
github.com/oiweiwei/go-smb2.fork v1.0.0 // indirect
github.com/oiweiwei/gokrb5.fork/v9 v9.0.6 // indirect
github.com/olekukonko/errors v1.1.0 // indirect
github.com/olekukonko/ll v0.0.9 // indirect
github.com/olekukonko/tablewriter v1.0.8 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pelletier/go-toml/v2 v2.0.8 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pierrec/lz4/v4 v4.1.23 // indirect
github.com/pjbgf/sha1cd v0.6.0 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/projectdiscovery/asnmap v1.1.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/praetorian-inc/fingerprintx v1.1.15 // indirect
github.com/projectdiscovery/asnmap v1.1.1 // indirect
github.com/projectdiscovery/blackrock v0.0.1 // indirect
github.com/projectdiscovery/cdncheck v1.0.9 // indirect
github.com/projectdiscovery/clistats v0.0.19 // indirect
github.com/projectdiscovery/dsl v0.0.20 // indirect
github.com/projectdiscovery/fastdialer v0.1.1 // indirect
github.com/projectdiscovery/cdncheck v1.2.39 // indirect
github.com/projectdiscovery/clistats v0.1.4 // indirect
github.com/projectdiscovery/dsl v0.8.19 // indirect
github.com/projectdiscovery/fastdialer v0.5.10 // indirect
github.com/projectdiscovery/fasttemplate v0.0.2 // indirect
github.com/projectdiscovery/freeport v0.0.5 // indirect
github.com/projectdiscovery/gologger v1.1.12 // indirect
github.com/projectdiscovery/gostruct v0.0.1 // indirect
github.com/projectdiscovery/hmap v0.0.45 // indirect
github.com/projectdiscovery/httpx v1.3.4 // indirect
github.com/projectdiscovery/interactsh v1.2.0 // indirect
github.com/projectdiscovery/freeport v0.0.7 // indirect
github.com/projectdiscovery/gcache v0.0.0-20241015120333-12546c6e3f4c // indirect
github.com/projectdiscovery/go-smb2 v0.0.0-20240129202741-052cc450c6cb // indirect
github.com/projectdiscovery/gologger v1.1.70 // indirect
github.com/projectdiscovery/gostruct v0.0.2 // indirect
github.com/projectdiscovery/govaluate v0.0.0-20260504230327-80320480bb6e // indirect
github.com/projectdiscovery/gozero v0.1.1-0.20260530071156-fa1dad563d76 // indirect
github.com/projectdiscovery/hmap v0.0.101 // indirect
github.com/projectdiscovery/httpx v1.9.0 // indirect
github.com/projectdiscovery/interactsh v1.3.1 // indirect
github.com/projectdiscovery/ldapserver v1.0.2-0.20240219154113-dcc758ebc0cb // indirect
github.com/projectdiscovery/machineid v0.0.0-20240226150047-2e2c51e35983 // indirect
github.com/projectdiscovery/mapcidr v1.1.34 // indirect
github.com/projectdiscovery/networkpolicy v0.0.8 // indirect
github.com/projectdiscovery/rawhttp v0.1.18 // indirect
github.com/projectdiscovery/machineid v0.0.0-20250715113114-c77eb3567582 // indirect
github.com/projectdiscovery/mapcidr v1.1.97 // indirect
github.com/projectdiscovery/n3iwf v0.0.0-20230523120440-b8cd232ff1f5 // indirect
github.com/projectdiscovery/networkpolicy v0.1.40 // indirect
github.com/projectdiscovery/ratelimit v0.0.88 // indirect
github.com/projectdiscovery/rawhttp v0.1.90 // indirect
github.com/projectdiscovery/rdap v0.9.1-0.20221108103045-9865884d1917 // indirect
github.com/projectdiscovery/retryabledns v1.0.62 // indirect
github.com/projectdiscovery/retryablehttp-go v1.0.63 // indirect
github.com/projectdiscovery/sarif v0.0.1 // indirect
github.com/projectdiscovery/tlsx v1.1.4 // indirect
github.com/projectdiscovery/yamldoc-go v1.0.4 // indirect
github.com/refraction-networking/utls v1.8.1 // indirect
github.com/projectdiscovery/retryablehttp-go v1.3.14 // indirect
github.com/projectdiscovery/sarif v0.1.0 // indirect
github.com/projectdiscovery/tlsx v1.2.2 // indirect
github.com/projectdiscovery/uncover v1.2.1 // indirect
github.com/projectdiscovery/useragent v0.0.108 // indirect
github.com/projectdiscovery/wappalyzergo v0.2.84 // indirect
github.com/projectdiscovery/yamldoc-go v1.0.6 // indirect
github.com/redis/go-redis/v9 v9.11.0 // indirect
github.com/refraction-networking/utls v1.8.2 // indirect
github.com/remeh/sizedwaitgroup v1.0.0 // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/rogpeppe/go-internal v1.10.0 // indirect
github.com/rs/xid v1.5.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/rs/zerolog v1.34.0 // indirect
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect
github.com/sashabaranov/go-openai v1.14.2 // indirect
github.com/sashabaranov/go-openai v1.37.0 // indirect
github.com/segmentio/ksuid v1.0.4 // indirect
github.com/shirou/gopsutil/v3 v3.23.7 // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/shirou/gopsutil v3.21.11+incompatible // indirect
github.com/shirou/gopsutil/v4 v4.26.3 // indirect
github.com/shurcooL/graphql v0.0.0-20230722043721-ed46e5a46466 // indirect
github.com/sijms/go-ora/v2 v2.9.0 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/skeema/knownhosts v1.3.1 // indirect
github.com/sorairolake/lzip-go v0.3.8 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/spf13/cast v1.5.1 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/syndtr/goleveldb v1.0.0 // indirect
github.com/temoto/robotstxt v1.1.2 // indirect
github.com/tidwall/btree v1.6.0 // indirect
github.com/tidwall/buntdb v1.3.0 // indirect
github.com/tidwall/gjson v1.14.4 // indirect
github.com/tidwall/btree v1.8.1 // indirect
github.com/tidwall/buntdb v1.3.2 // indirect
github.com/tidwall/grect v0.1.4 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
github.com/tidwall/rtred v0.1.2 // indirect
github.com/tidwall/tinyqueue v0.1.1 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/tim-ywliu/nested-logrus-formatter v1.3.2 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/tmthrgd/go-hex v0.0.0-20190904060850-447a3041c3bc // indirect
github.com/trivago/tgo v1.0.7 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/weppos/publicsuffix-go v0.30.1-0.20230422193905-8fecedd899db // indirect
github.com/xanzy/go-gitlab v0.84.0 // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
github.com/yl2chen/cidranger v1.0.2 // indirect
github.com/vmihailenco/bufpool v0.1.11 // indirect
github.com/vmihailenco/msgpack/v5 v5.3.4 // indirect
github.com/vmihailenco/tagparser v0.1.2 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/vulncheck-oss/go-exploit v1.51.0 // indirect
github.com/weppos/publicsuffix-go v0.50.3 // indirect
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
github.com/xdg-go/pbkdf2 v1.0.0 // indirect
github.com/xdg-go/scram v1.1.2 // indirect
github.com/xdg-go/stringprep v1.0.4 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yassinebenaid/godump v0.11.1 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
github.com/ysmood/fetchup v0.2.3 // indirect
github.com/ysmood/goob v0.4.0 // indirect
github.com/ysmood/got v0.34.1 // indirect
github.com/ysmood/got v0.40.0 // indirect
github.com/ysmood/gson v0.7.3 // indirect
github.com/ysmood/leakless v0.8.0 // indirect
github.com/yuin/goldmark v1.5.4 // indirect
github.com/yuin/goldmark-emoji v1.0.1 // indirect
github.com/ysmood/leakless v0.9.0 // indirect
github.com/yuin/goldmark v1.7.13 // indirect
github.com/yuin/goldmark-emoji v1.0.6 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
github.com/zcalusic/sysinfo v1.0.2 // indirect
github.com/zeebo/blake3 v0.2.3 // indirect
github.com/zcalusic/sysinfo v1.1.3 // indirect
github.com/zeebo/blake3 v0.2.4 // indirect
github.com/zmap/rc2 v0.0.0-20190804163417-abaa70531248 // indirect
github.com/zmap/zcrypto v0.0.0-20230422215203-9a665e1e9968 // indirect
go.etcd.io/bbolt v1.3.7 // indirect
github.com/zmap/zcrypto v0.0.0-20240803002437-3a861682ac77 // indirect
github.com/zmap/zgrab2 v0.1.8 // indirect
gitlab.com/gitlab-org/api/client-go v1.9.1 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
go.mongodb.org/mongo-driver v1.17.9 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.25.0 // indirect
go.uber.org/zap v1.27.0 // indirect
go.uber.org/zap/exp v0.3.0 // indirect
go4.org v0.0.0-20230225012048-214862532bf5 // indirect
goftp.io/server/v2 v2.0.1 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect
golang.org/x/mod v0.30.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/exp v0.0.0-20260527015227-08cc5374adb3 // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/term v0.38.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.39.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.33.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/term v0.44.0 // indirect
golang.org/x/text v0.38.0 // indirect
golang.org/x/tools v0.45.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/alecthomas/kingpin.v2 v2.2.6 // indirect
gopkg.in/corvus-ch/zbase32.v1 v1.0.0 // indirect
gopkg.in/djherbis/times.v1 v1.3.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
mellium.im/sasl v0.3.2 // indirect
moul.io/http2curl v1.0.0 // indirect
software.sslmate.com/src/go-pkcs12 v0.7.0 // indirect
)
+1205 -315
View File
File diff suppressed because it is too large Load Diff
+127 -8
View File
@@ -4,7 +4,7 @@
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2025 vmfunc (vmfunc), xyzeva, :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
@@ -13,6 +13,7 @@
package config
import (
"os"
"time"
"github.com/charmbracelet/log"
@@ -21,7 +22,16 @@ import (
type Settings struct {
Dirlist string
DirMatchCodes string // -mc dirlist: status codes to keep
DirFilterCodes string // -fc dirlist: status codes to drop
DirFilterSizes string // -fs dirlist: body sizes to drop
DirFilterWords string // -fw dirlist: word counts to drop
DirFilterRegex string // -fr dirlist: regex; body match drops response
Calibrate bool // -ac auto-calibrate the soft-404 baseline (dirlist, sql)
DirWordlist string // -w dirlist: custom wordlist (file path or url)
DirExtensions string // -e dirlist: extensions appended to each word
Dnslist string
Resolvers string // -resolvers dnslist: comma list overriding the bundled pool
Debug bool
LogDir string
NoScan bool
@@ -39,18 +49,55 @@ type Settings struct {
Template string
CMS bool
Headers bool
SecurityHeaders bool
CloudStorage bool
SubdomainTakeover bool
Shodan bool
SecurityTrails bool
SQL bool
LFI bool
JWT bool
OpenAPI bool
Favicon bool
CORS bool
Redirect bool
XSS bool
Framework bool
Crawl bool
CrawlDepth int
Passive bool
Probe bool
SARIF string // path to write a sarif 2.1.0 report to ("" = off)
Markdown string // path to write a markdown report to ("" = off)
Silent bool // route chrome to stderr, print one finding per line to stdout
Diff bool // surface only findings added/removed vs the last snapshot
Store string // snapshot dir for diff mode ("" = default state dir)
Modules string // Comma-separated list of module IDs to run
ModuleTags string // Run modules matching these tags
AllModules bool // Run all loaded modules
ListModules bool // List available modules and exit
Proxy string
Header goflags.StringSlice // custom request headers ("Key: Value")
Cookie string
RateLimit int
Notify bool // -notify: ship findings to configured providers
NotifySeverity string // -notify-severity: minimum severity to send (info..critical)
NotifyConfig string // -notify-config: path to a notify-compatible yaml file
}
// minThreads is the floor for the worker count. Threads feeds wg.Add across the
// scanners, so 0 silently runs nothing and a negative value panics with
// "negative WaitGroup counter"; clamp the parsed value up to this.
const minThreads = 1
// defaultCrawlDepth bounds how far the spider recurses by default; deep enough
// to find linked pages without crawling an entire site.
const defaultCrawlDepth = 2
// defaultNotifySeverity is the floor notify sends at when -notify-severity is
// unset: medium drops pure recon/info noise so alerts stay actionable.
const defaultNotifySeverity = "medium"
const (
Nil goflags.EnumVariable = iota
@@ -64,9 +111,9 @@ const (
Full
)
func Parse() *Settings {
settings := &Settings{}
// registerFlags builds the flag set for the given settings without parsing it,
// so callers (Parse and tests) can inspect the registered flags.
func registerFlags(settings *Settings) *goflags.FlagSet {
flagSet := goflags.NewFlagSet()
flagSet.SetDescription("a blazing-fast pentesting (recon/exploitation) suite")
@@ -79,7 +126,16 @@ func Parse() *Settings {
portScopes := goflags.AllowdTypes{"common": Common, "full": Full, "none": Nil}
flagSet.CreateGroup("scans", "Scans",
flagSet.EnumVar(&settings.Dirlist, "dirlist", Nil, "Directory fuzzing scan size (small/medium/large)", listSizes),
flagSet.StringVar(&settings.DirMatchCodes, "mc", "", "Dirlist: match these status codes (comma list, e.g. 200,301)"),
flagSet.StringVar(&settings.DirFilterCodes, "fc", "", "Dirlist: filter out these status codes (comma list)"),
flagSet.StringVar(&settings.DirFilterSizes, "fs", "", "Dirlist: filter out responses of these body sizes (comma list)"),
flagSet.StringVar(&settings.DirFilterWords, "fw", "", "Dirlist: filter out responses with these word counts (comma list)"),
flagSet.StringVar(&settings.DirFilterRegex, "fr", "", "Dirlist: filter out responses whose body matches this regex"),
flagSet.BoolVar(&settings.Calibrate, "ac", false, "Auto-calibrate the soft-404 wildcard baseline (dirlist, sql)"),
flagSet.StringVar(&settings.DirWordlist, "w", "", "Dirlist: custom wordlist (local file path or url; overrides -dirlist size)"),
flagSet.StringVar(&settings.DirExtensions, "e", "", "Dirlist: extensions appended to each word (comma list, e.g. php,bak,env)"),
flagSet.EnumVar(&settings.Dnslist, "dnslist", Nil, "DNS fuzzing scan size (small/medium/large)", listSizes),
flagSet.StringVar(&settings.Resolvers, "resolvers", "", "Dnslist: DNS resolvers to use (comma list, e.g. 1.1.1.1,8.8.8.8; overrides the bundled pool)"),
flagSet.EnumVar(&settings.Ports, "ports", Nil, "Port scanning scope (common/full)", portScopes),
flagSet.BoolVar(&settings.Dorking, "dork", false, "Enable Google dorking"),
flagSet.BoolVar(&settings.Git, "git", false, "Enable git repository scanning"),
@@ -89,12 +145,24 @@ func Parse() *Settings {
flagSet.BoolVar(&settings.JavaScript, "js", false, "Enable JavaScript scans"),
flagSet.BoolVar(&settings.CMS, "cms", false, "Enable CMS detection"),
flagSet.BoolVar(&settings.Headers, "headers", false, "Enable HTTP Header Analysis"),
flagSet.BoolVarP(&settings.SecurityHeaders, "security-headers", "sh", false, "Enable security header analysis (missing/weak headers)"),
flagSet.BoolVar(&settings.CloudStorage, "c3", false, "Enable C3 Misconfiguration Scan"),
flagSet.BoolVar(&settings.SubdomainTakeover, "st", false, "Enable Subdomain Takeover Check"),
flagSet.BoolVar(&settings.Shodan, "shodan", false, "Enable Shodan lookup (requires SHODAN_API_KEY env var)"),
flagSet.BoolVar(&settings.SecurityTrails, "securitytrails", false, "Enable SecurityTrails domain discovery (requires SECURITYTRAILS_API_KEY env var)"),
flagSet.BoolVar(&settings.SQL, "sql", false, "Enable SQL reconnaissance (admin panels, error disclosure)"),
flagSet.BoolVar(&settings.LFI, "lfi", false, "Enable LFI (Local File Inclusion) reconnaissance"),
flagSet.BoolVar(&settings.JWT, "jwt", false, "Enable JWT discovery + offline weakness analysis"),
flagSet.BoolVar(&settings.OpenAPI, "openapi", false, "Enable OpenAPI/Swagger spec exposure probe"),
flagSet.BoolVar(&settings.Favicon, "favicon", false, "Enable favicon hash fingerprinting (shodan-style)"),
flagSet.BoolVar(&settings.CORS, "cors", false, "Enable CORS misconfiguration probe"),
flagSet.BoolVar(&settings.Redirect, "redirect", false, "Enable open redirect probe"),
flagSet.BoolVar(&settings.XSS, "xss", false, "Enable reflected XSS probe"),
flagSet.BoolVar(&settings.Framework, "framework", false, "Enable framework detection"),
flagSet.BoolVar(&settings.Crawl, "crawl", false, "Enable web crawling (spider same-host links/scripts/forms)"),
flagSet.IntVar(&settings.CrawlDepth, "crawl-depth", defaultCrawlDepth, "Max crawl recursion depth"),
flagSet.BoolVar(&settings.Passive, "passive", false, "Enable passive subdomain/url discovery (zero traffic to target)"),
flagSet.BoolVar(&settings.Probe, "probe", false, "Probe the target for liveness (status, title, server, redirect chain)"),
)
flagSet.CreateGroup("runtime", "Runtime",
@@ -102,11 +170,32 @@ func Parse() *Settings {
flagSet.DurationVarP(&settings.Timeout, "timeout", "t", 10*time.Second, "HTTP request timeout"),
flagSet.StringVarP(&settings.LogDir, "log", "l", "", "Directory to store logs in"),
flagSet.IntVar(&settings.Threads, "threads", 10, "Number of threads to run scans on"),
flagSet.StringVar(&settings.Template, "template", "", "Sif runtime template to use"),
flagSet.StringVar(&settings.Template, "template", "", "Load scan settings from a template (preset minimal/recon/full, or a local yaml file)"),
)
flagSet.CreateGroup("http", "HTTP",
flagSet.StringVar(&settings.Proxy, "proxy", "", "Proxy for all requests (http/https/socks5 url)"),
flagSet.StringSliceVarP(&settings.Header, "header", "H", nil, "Custom header to send (repeatable or comma-separated, \"Key: Value\")", goflags.CommaSeparatedStringSliceOptions),
flagSet.StringVar(&settings.Cookie, "cookie", "", "Cookie header to send with every request"),
flagSet.IntVar(&settings.RateLimit, "rate-limit", 0, "Max requests per second (0 = unlimited)"),
)
flagSet.CreateGroup("output", "Output",
flagSet.StringVar(&settings.SARIF, "sarif", "", "Write a SARIF 2.1.0 report to this file"),
flagSet.StringVarP(&settings.Markdown, "markdown", "md", "", "Write a markdown report to this file"),
flagSet.BoolVar(&settings.Silent, "silent", false, "Plain output: chrome to stderr, one finding per line to stdout (for pipelines)"),
flagSet.BoolVar(&settings.Diff, "diff", false, "Diff mode: surface only findings added/removed since the last snapshot of each target"),
flagSet.StringVar(&settings.Store, "store", "", "Snapshot directory for -diff (default: log dir, else <user-config>/sif/state)"),
)
flagSet.CreateGroup("notify", "Notify",
flagSet.BoolVar(&settings.Notify, "notify", false, "Ship findings to configured providers (slack/discord/telegram/webhook)"),
flagSet.StringVar(&settings.NotifySeverity, "notify-severity", defaultNotifySeverity, "Minimum severity to notify on (info/low/medium/high/critical)"),
flagSet.StringVar(&settings.NotifyConfig, "notify-config", "", "Path to a notify-compatible yaml config (overrides env vars)"),
)
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",
@@ -116,8 +205,38 @@ func Parse() *Settings {
flagSet.BoolVarP(&settings.ListModules, "list-modules", "lm", false, "List available modules and exit"),
)
if err := flagSet.Parse(); err != nil {
log.Fatalf("Could not parse flags: %s", err)
return flagSet
}
func Parse() *Settings {
settings := &Settings{}
flagSet := registerFlags(settings)
// -template presets a batch of scans from a yaml file or named preset; point
// goflags at it before Parse so it merges as config (cli flags still win) and
// replaces the ambient config for this run.
templatePath, cleanup, err := templateConfigPath(os.Args[1:])
if err != nil {
log.Fatalf("Could not load template: %s", err)
}
if templatePath != "" {
flagSet.SetConfigFilePath(templatePath)
}
// Parse merges the template config synchronously, so a temp preset file can
// be removed right after, before any fatal exit (no leaking defer).
parseErr := flagSet.Parse()
if cleanup != nil {
cleanup()
}
if parseErr != nil {
log.Fatalf("Could not parse flags: %s", parseErr)
}
// threads feeds wg.Add directly; floor it so 0 isn't a silent no-op and a
// negative value can't panic the waitgroup.
if settings.Threads < minThreads {
settings.Threads = minThreads
}
return settings
+9 -1
View File
@@ -4,7 +4,7 @@
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2025 vmfunc (vmfunc), xyzeva, :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
@@ -61,6 +61,14 @@ func TestSettingsDefaults(t *testing.T) {
if settings.Ports != "" {
t.Errorf("expected Ports default to be empty, got %v", settings.Ports)
}
// diff mode is opt-in and its store dir defaults empty (resolved at runtime).
if settings.Diff != false {
t.Errorf("expected Diff default to be false, got %v", settings.Diff)
}
if settings.Store != "" {
t.Errorf("expected Store default to be empty, got %v", settings.Store)
}
}
func TestSettingsNoScanBehavior(t *testing.T) {
+116
View File
@@ -0,0 +1,116 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package config
import (
"embed"
"fmt"
"os"
"strings"
)
//go:embed templates/*.yaml
var embeddedTemplates embed.FS
// presetNames are the templates shipped in the binary, listed in help and
// error text. each presets a batch of scans without listing every flag.
var presetNames = []string{"minimal", "recon", "full"}
// templateConfigPath resolves the -template value into a config file path for
// goflags to merge, plus a cleanup to run after Parse (embedded presets are
// written to a temp file). it returns "" when -template is unset.
func templateConfigPath(args []string) (string, func(), error) {
value := templateFlagValue(args)
if value == "" {
return "", nil, nil
}
return resolveTemplate(value)
}
// templateFlagValue pulls the -template value out of raw args; the config path
// has to be known before Parse, so it cannot come from the parsed flag itself.
func templateFlagValue(args []string) string {
for i, arg := range args {
if arg == "-template" || arg == "--template" {
if i+1 < len(args) {
return args[i+1]
}
return ""
}
if v, ok := strings.CutPrefix(arg, "-template="); ok {
return v
}
if v, ok := strings.CutPrefix(arg, "--template="); ok {
return v
}
}
return ""
}
// resolveTemplate turns the -template value into a config file path. an existing
// local file wins; a named preset is materialized from the embedded set; a
// path-shaped miss or an unknown name is a hard error.
func resolveTemplate(value string) (string, func(), error) {
info, err := os.Stat(value) //nolint:gosec // G304: user-supplied local template path, by design (same as the -f/-w wordlist paths)
switch {
case err == nil && info.IsDir():
return "", nil, fmt.Errorf("template path %q is a directory", value)
case err == nil:
return value, nil, nil
}
if data, ok := embeddedPreset(value); ok {
return materializePreset(data)
}
if looksLikePath(value) {
return "", nil, fmt.Errorf("template file %q not found", value)
}
return "", nil, fmt.Errorf("unknown template %q; use a local yaml file or one of: %s",
value, strings.Join(presetNames, ", "))
}
// embeddedPreset returns the bytes of a named preset shipped in the binary.
func embeddedPreset(name string) ([]byte, bool) {
data, err := embeddedTemplates.ReadFile("templates/" + name + ".yaml")
if err != nil {
return nil, false
}
return data, true
}
// materializePreset writes preset bytes to a temp file so goflags, which merges
// a config by path, can read it; the cleanup removes the file after Parse.
func materializePreset(data []byte) (string, func(), error) {
file, err := os.CreateTemp("", "sif-template-*.yaml")
if err != nil {
return "", nil, err
}
cleanup := func() { _ = os.Remove(file.Name()) }
if _, err := file.Write(data); err != nil {
cleanup()
return "", nil, err
}
if err := file.Close(); err != nil {
cleanup()
return "", nil, err
}
return file.Name(), cleanup, nil
}
// looksLikePath reports whether the value addresses a file rather than a named
// preset: a path separator or a yaml suffix marks a file.
func looksLikePath(value string) bool {
if strings.ContainsAny(value, `/\`) {
return true
}
return strings.HasSuffix(value, ".yaml") || strings.HasSuffix(value, ".yml")
}
+211
View File
@@ -0,0 +1,211 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package config
import (
"flag"
"os"
"path/filepath"
"testing"
"github.com/projectdiscovery/goflags"
"gopkg.in/yaml.v3"
)
// writeTemplate drops a yaml template in a temp dir and returns its path.
func writeTemplate(t *testing.T, body string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "tmpl.yaml")
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write template: %s", err)
}
return path
}
// loadPreset registers the real flags, merges the named embedded preset, and
// returns the resulting settings (no cli scan flags set).
func loadPreset(t *testing.T, name string) *Settings {
t.Helper()
goflags.DisableAutoConfigMigration = true
path, cleanup, err := resolveTemplate(name)
if err != nil {
t.Fatalf("resolve %s: %s", name, err)
}
if cleanup != nil {
defer cleanup()
}
settings := &Settings{}
flagSet := registerFlags(settings)
flagSet.SetConfigFilePath(path)
if err := flagSet.Parse("-silent"); err != nil {
t.Fatalf("parse %s: %s", name, err)
}
return settings
}
// every key in an embedded preset must be a real flag long-name. goflags drops
// unknown config keys silently, so a typo would otherwise ship as a dead no-op.
func TestPresetKeysAreRegisteredFlags(t *testing.T) {
valid := map[string]bool{}
registerFlags(&Settings{}).CommandLine.VisitAll(func(f *flag.Flag) {
valid[f.Name] = true
})
for _, name := range presetNames {
data, ok := embeddedPreset(name)
if !ok {
t.Errorf("preset %q is not embedded", name)
continue
}
var keys map[string]any
if err := yaml.Unmarshal(data, &keys); err != nil {
t.Errorf("preset %q is not valid yaml: %s", name, err)
continue
}
for key := range keys {
if !valid[key] {
t.Errorf("preset %q references unknown flag %q", name, key)
}
}
}
}
func TestPresetMinimal(t *testing.T) {
s := loadPreset(t, "minimal")
if !s.Probe || !s.Headers || !s.Favicon {
t.Errorf("minimal should enable probe/headers/favicon, got probe=%v headers=%v favicon=%v",
s.Probe, s.Headers, s.Favicon)
}
if s.XSS || s.SQL || s.Nuclei {
t.Error("minimal should not enable heavy or intrusive scans")
}
}
func TestPresetReconIsNonIntrusive(t *testing.T) {
s := loadPreset(t, "recon")
if !s.Passive || !s.Whois || !s.CMS || !s.Probe {
t.Errorf("recon should enable passive/whois/cms/probe, got %v/%v/%v/%v",
s.Passive, s.Whois, s.CMS, s.Probe)
}
if s.XSS || s.SQL || s.LFI || s.Redirect {
t.Errorf("recon must not enable payload-injecting scans: xss=%v sql=%v lfi=%v redirect=%v",
s.XSS, s.SQL, s.LFI, s.Redirect)
}
}
func TestPresetFull(t *testing.T) {
s := loadPreset(t, "full")
if !s.XSS || !s.SQL || !s.LFI || !s.Redirect {
t.Error("full should enable the intrusive scans")
}
if s.Dirlist != "large" || s.Ports != "full" {
t.Errorf("full should set dirlist=large ports=full, got dirlist=%q ports=%q",
s.Dirlist, s.Ports)
}
}
// the template merges as the goflags config: it fills flags left at their
// default, an explicit cli flag still wins, and an untouched flag stays put.
func TestTemplateConfigPrecedence(t *testing.T) {
goflags.DisableAutoConfigMigration = true
tmpl := writeTemplate(t, "cms: true\nthreads: 99\n")
var cms, sql bool
var threads int
flagSet := goflags.NewFlagSet()
flagSet.BoolVar(&cms, "cms", false, "")
flagSet.BoolVar(&sql, "sql", false, "")
flagSet.IntVar(&threads, "threads", 10, "")
flagSet.SetConfigFilePath(tmpl)
if err := flagSet.Parse("-threads", "5"); err != nil {
t.Fatalf("parse: %s", err)
}
if !cms {
t.Error("expected template to set cms=true")
}
if threads != 5 {
t.Errorf("expected cli threads 5 to win over template, got %d", threads)
}
if sql {
t.Error("expected sql left untouched to stay false")
}
}
func TestTemplateFlagValue(t *testing.T) {
cases := []struct {
name string
args []string
want string
}{
{"long with space", []string{"-template", "a.yaml"}, "a.yaml"},
{"double dash with space", []string{"--template", "b.yaml"}, "b.yaml"},
{"long with equals", []string{"-template=c.yaml"}, "c.yaml"},
{"double dash with equals", []string{"--template=d.yaml"}, "d.yaml"},
{"absent", []string{"-u", "x"}, ""},
{"trailing without value", []string{"-u", "x", "-template"}, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := templateFlagValue(tc.args); got != tc.want {
t.Errorf("expected %q, got %q", tc.want, got)
}
})
}
}
func TestResolveTemplateExistingFile(t *testing.T) {
path := writeTemplate(t, "cms: true\n")
got, cleanup, err := resolveTemplate(path)
if cleanup != nil {
defer cleanup()
}
if err != nil {
t.Fatalf("resolveTemplate: %s", err)
}
if got != path {
t.Errorf("expected %q, got %q", path, got)
}
}
func TestResolveTemplateNamedPreset(t *testing.T) {
path, cleanup, err := resolveTemplate("recon")
if cleanup != nil {
defer cleanup()
}
if err != nil {
t.Fatalf("recon preset should resolve: %s", err)
}
if path == "" {
t.Fatal("expected a materialized preset path")
}
}
func TestResolveTemplateMissingFile(t *testing.T) {
if _, _, err := resolveTemplate("./does-not-exist.yaml"); err == nil {
t.Fatal("expected an error for a missing template file")
}
}
func TestResolveTemplateDirectory(t *testing.T) {
if _, _, err := resolveTemplate(t.TempDir()); err == nil {
t.Fatal("expected an error for a directory")
}
}
func TestResolveTemplateUnknownName(t *testing.T) {
if _, _, err := resolveTemplate("bogus"); err == nil {
t.Fatal("expected an error for an unknown template name")
}
}
+28
View File
@@ -0,0 +1,28 @@
# full: thorough and active, including intrusive probes that inject payloads
# (xss, sql, lfi, redirect). api-key scans (shodan, securitytrails) stay opt-in
# via their own flags.
passive: true
whois: true
dork: true
favicon: true
headers: true
security-headers: true
cms: true
framework: true
probe: true
git: true
js: true
nuclei: true
openapi: true
cors: true
jwt: true
c3: true
st: true
crawl: true
sql: true
lfi: true
xss: true
redirect: true
dirlist: large
dnslist: large
ports: full
+4
View File
@@ -0,0 +1,4 @@
# minimal: fast liveness + fingerprint, a handful of benign GETs per target.
probe: true
headers: true
favicon: true
+11
View File
@@ -0,0 +1,11 @@
# recon: broad non-intrusive discovery (light traffic, no attack payloads).
passive: true
whois: true
dork: true
favicon: true
headers: true
security-headers: true
cms: true
framework: true
probe: true
dnslist: small
+270
View File
@@ -0,0 +1,270 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
// Package dnsx resolves subdomain candidates against a bundled resolver pool
// before anything is probed over http, so the slow/inaccurate path of HTTP-ing
// every wordlist entry through the OS resolver is gone. it also fingerprints
// wildcard zones (a zone that answers every random label) so a catch-all
// nameserver can't flood the caller with phantom subdomains.
package dnsx
import (
"crypto/rand"
"fmt"
"math/big"
"sort"
"strings"
retryabledns "github.com/projectdiscovery/retryabledns"
)
// bundled default resolver pool. anycast cloudflare/google/quad9 - fast, public,
// and unlikely to rate-limit a recon sweep. -resolvers overrides this set.
const (
resolverCloudflare = "1.1.1.1:53"
resolverGoogle = "8.8.8.8:53"
resolverQuad9 = "9.9.9.9:53"
)
// defaultResolvers is the bundled pool used when the caller passes none.
var defaultResolvers = []string{resolverCloudflare, resolverGoogle, resolverQuad9}
const (
// defaultRetries is how many times retryabledns rotates through the pool on a
// timeout before giving up on a name. low enough to stay fast on a big list.
defaultRetries = 3
// wildcardProbes is how many random nonexistent labels we resolve to
// fingerprint a wildcard zone. more samples make a rotating catch-all (one
// that hands back a different ip per query) harder to miss, but each is a
// real lookup so this stays small.
wildcardProbes = 3
// randomLabelLen is the length of each random wildcard-probe label. long
// enough that a collision with a real host is astronomically unlikely.
randomLabelLen = 16
)
// randomLabelAlphabet is the lowercase-alnum set wildcard probe labels draw
// from; a valid dns label so the query isn't rejected before it leaves.
const randomLabelAlphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
// defaultDNSPort is appended to any resolver entry given without an explicit
// port, so "1.1.1.1" and "1.1.1.1:53" both work on the cli.
const defaultDNSPort = "53"
// ParseResolvers splits a comma list of resolvers into a normalized slice,
// appending the default port to bare ips/hosts. an empty or blank input returns
// nil so the caller falls back to the bundled pool.
func ParseResolvers(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for i := 0; i < len(parts); i++ {
entry := strings.TrimSpace(parts[i])
if entry == "" {
continue
}
// a bare ip/host gets the default port; an entry already carrying ":port"
// (or a bracketed ipv6 literal) is left as-is.
if !strings.Contains(entry, ":") {
entry += ":" + defaultDNSPort
}
out = append(out, entry)
}
return out
}
// resolution is the resolved address set for one host. empty Addrs means the
// name did not resolve (nxdomain / no records).
type resolution struct {
Addrs []string
}
// resolved reports whether the name returned any address.
func (r resolution) resolved() bool {
return len(r.Addrs) > 0
}
// resolverFn is the test seam: every lookup the package makes goes through this
// var, so a fake can answer without touching the network. real runs point it at
// a retryabledns-backed client via NewResolver.
var resolverFn func(host string) (resolution, error)
// Resolver resolves candidates against a pool and filters wildcard answers. it
// is built once per scan and shared across the worker goroutines; the
// underlying retryabledns client is safe for concurrent use.
type Resolver struct {
// wildcardSigs holds the address sets a wildcard zone answers random labels
// with. nil/empty means the zone is not wildcard. a candidate whose answer is
// covered by one of these is a catch-all hit, not a real host.
wildcardSigs []map[string]struct{}
}
// NewResolver wires resolverFn to a retryabledns client over the given pool
// (bundled default when resolvers is empty) and returns a Resolver. it does not
// fingerprint anything yet - call FingerprintWildcard with the apex first.
func NewResolver(resolvers []string) (*Resolver, error) {
pool := resolvers
if len(pool) == 0 {
pool = defaultResolvers
}
client, err := retryabledns.New(pool, defaultRetries)
if err != nil {
return nil, fmt.Errorf("dnsx: build resolver over %v: %w", pool, err)
}
// only install the real client when a test hasn't already injected a fake;
// the seam wins so hermetic tests never reach this client.
if resolverFn == nil {
resolverFn = func(host string) (resolution, error) {
data, err := client.Resolve(host)
if err != nil {
return resolution{}, fmt.Errorf("dnsx: resolve %q: %w", host, err)
}
return resolution{Addrs: mergeAddrs(data)}, nil
}
}
return &Resolver{}, nil
}
// FingerprintWildcard resolves wildcardProbes random labels under apex. any that
// answer mean the zone is a catch-all, so their address sets are recorded as
// signatures to filter real candidates against later. a clean zone leaves the
// signature list empty and nothing gets filtered.
func (r *Resolver) FingerprintWildcard(apex string) error {
apex = strings.TrimSuffix(apex, ".")
for i := 0; i < wildcardProbes; i++ {
label, err := randomLabel(randomLabelLen)
if err != nil {
return fmt.Errorf("dnsx: wildcard probe label: %w", err)
}
res, err := resolverFn(label + "." + apex)
if err != nil {
// a probe failure (timeout / nxdomain surfaced as error) just means this
// sample says "not wildcard"; don't abort the whole fingerprint on it.
continue
}
if res.resolved() {
r.wildcardSigs = append(r.wildcardSigs, toSet(res.Addrs))
}
}
return nil
}
// Resolve looks up host and reports whether it is a real, non-wildcard hit. a
// name that doesn't resolve, or whose answer matches a recorded wildcard
// signature, returns false so the caller skips probing it.
func (r *Resolver) Resolve(host string) (bool, error) {
res, err := resolverFn(host)
if err != nil {
return false, fmt.Errorf("dnsx: resolve %q: %w", host, err)
}
if !res.resolved() {
return false, nil
}
if r.isWildcard(res.Addrs) {
return false, nil
}
return true, nil
}
// isWildcard reports whether addrs is covered by any recorded wildcard
// signature. a candidate whose every address appears in a wildcard answer is a
// catch-all hit; a host with even one address outside the signature is a real,
// distinct record and survives.
func (r *Resolver) isWildcard(addrs []string) bool {
if len(r.wildcardSigs) == 0 {
return false
}
for i := 0; i < len(r.wildcardSigs); i++ {
if subset(addrs, r.wildcardSigs[i]) {
return true
}
}
return false
}
// mergeAddrs flattens the A and AAAA answers into one sorted, deduped slice so
// two equal answers compare equal regardless of record ordering.
func mergeAddrs(data *retryabledns.DNSData) []string {
if data == nil {
return nil
}
seen := make(map[string]struct{}, len(data.A)+len(data.AAAA))
for i := 0; i < len(data.A); i++ {
seen[data.A[i]] = struct{}{}
}
for i := 0; i < len(data.AAAA); i++ {
seen[data.AAAA[i]] = struct{}{}
}
addrs := make([]string, 0, len(seen))
for addr := range seen {
addrs = append(addrs, addr)
}
sort.Strings(addrs)
return addrs
}
// toSet turns addrs into a lookup set for subset checks.
func toSet(addrs []string) map[string]struct{} {
set := make(map[string]struct{}, len(addrs))
for i := 0; i < len(addrs); i++ {
set[addrs[i]] = struct{}{}
}
return set
}
// subset reports whether every addr is present in sig (and addrs is non-empty);
// an empty addrs can't be a wildcard match.
func subset(addrs []string, sig map[string]struct{}) bool {
if len(addrs) == 0 {
return false
}
for i := 0; i < len(addrs); i++ {
if _, ok := sig[addrs[i]]; !ok {
return false
}
}
return true
}
// randomLabel returns a cryptographically-random lowercase-alnum dns label of
// length n. crypto/rand (not math/rand) so a target can't predict the probe
// labels and special-case them to defeat wildcard detection.
func randomLabel(n int) (string, error) {
var b strings.Builder
b.Grow(n)
alphabetLen := big.NewInt(int64(len(randomLabelAlphabet)))
for i := 0; i < n; i++ {
idx, err := rand.Int(rand.Reader, alphabetLen)
if err != nil {
return "", fmt.Errorf("dnsx: random index: %w", err)
}
b.WriteByte(randomLabelAlphabet[idx.Int64()])
}
return b.String(), nil
}
+176
View File
@@ -0,0 +1,176 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package dnsx
import (
"reflect"
"strings"
"testing"
)
// withFakeResolver swaps resolverFn for fn for the duration of one test, then
// restores it - the seam that keeps every case below network-free.
func withFakeResolver(t *testing.T, fn func(host string) (resolution, error)) {
t.Helper()
orig := resolverFn
resolverFn = fn
t.Cleanup(func() { resolverFn = orig })
}
// newFingerprinted builds a Resolver and runs the wildcard fingerprint against
// apex using the already-injected fake; fatal on error.
func newFingerprinted(t *testing.T, apex string) *Resolver {
t.Helper()
r := &Resolver{}
if err := r.FingerprintWildcard(apex); err != nil {
t.Fatalf("FingerprintWildcard: %v", err)
}
return r
}
const testApex = "example.com"
// a host that resolves to a real address, in a clean (non-wildcard) zone, is a
// genuine hit.
func TestResolve_FoundInCleanZone(t *testing.T) {
withFakeResolver(t, func(host string) (resolution, error) {
// nothing answers a random wildcard probe -> clean zone.
if strings.HasSuffix(host, "."+testApex) && host != "www."+testApex {
return resolution{}, nil
}
if host == "www."+testApex {
return resolution{Addrs: []string{"93.184.216.34"}}, nil
}
return resolution{}, nil
})
r := newFingerprinted(t, testApex)
if len(r.wildcardSigs) != 0 {
t.Fatalf("clean zone should record no wildcard signatures, got %d", len(r.wildcardSigs))
}
ok, err := r.Resolve("www." + testApex)
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if !ok {
t.Error("a resolving host in a clean zone should be a hit")
}
}
// nxdomain (no addresses) is not a hit, so the caller skips probing it.
func TestResolve_NxdomainSkipped(t *testing.T) {
withFakeResolver(t, func(string) (resolution, error) {
// every name, probes included, returns no records.
return resolution{}, nil
})
r := newFingerprinted(t, testApex)
ok, err := r.Resolve("ghost." + testApex)
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if ok {
t.Error("an nxdomain host must not count as found")
}
}
// a wildcard zone answers the random probe labels, so a candidate that resolves
// to the same catch-all address is filtered out.
func TestResolve_WildcardFiltered(t *testing.T) {
const catchAll = "10.0.0.1"
withFakeResolver(t, func(string) (resolution, error) {
// the zone answers everything - probes and candidates alike - with one ip.
return resolution{Addrs: []string{catchAll}}, nil
})
r := newFingerprinted(t, testApex)
if len(r.wildcardSigs) == 0 {
t.Fatal("wildcard zone should record at least one signature")
}
ok, err := r.Resolve("anything." + testApex)
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if ok {
t.Error("a candidate matching the wildcard answer must be filtered")
}
}
// a real host in a wildcard zone that resolves to a distinct address (not the
// catch-all) still survives the filter - one address outside the signature is
// enough to be a genuine record.
func TestResolve_DistinctHostSurvivesWildcard(t *testing.T) {
const catchAll = "10.0.0.1"
const realHost = "api." + testApex
withFakeResolver(t, func(host string) (resolution, error) {
if host == realHost {
return resolution{Addrs: []string{"203.0.113.7"}}, nil
}
// everything else (probes + other candidates) hits the catch-all.
return resolution{Addrs: []string{catchAll}}, nil
})
r := newFingerprinted(t, testApex)
if len(r.wildcardSigs) == 0 {
t.Fatal("wildcard zone should record at least one signature")
}
ok, err := r.Resolve(realHost)
if err != nil {
t.Fatalf("Resolve: %v", err)
}
if !ok {
t.Error("a host resolving to a distinct address should survive the wildcard filter")
}
}
func TestParseResolvers(t *testing.T) {
tests := []struct {
name string
in string
want []string
}{
{"empty falls back to bundled", "", nil},
{"blank falls back to bundled", " ", nil},
{"bare ips get default port", "1.1.1.1,8.8.8.8", []string{"1.1.1.1:53", "8.8.8.8:53"}},
{"explicit port preserved", "9.9.9.9:5353", []string{"9.9.9.9:5353"}},
{"whitespace and empties trimmed", " 1.1.1.1 , ,8.8.8.8 ", []string{"1.1.1.1:53", "8.8.8.8:53"}},
{"mixed bare and ported", "1.1.1.1,9.9.9.9:5353", []string{"1.1.1.1:53", "9.9.9.9:5353"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ParseResolvers(tt.in); !reflect.DeepEqual(got, tt.want) {
t.Errorf("ParseResolvers(%q) = %v, want %v", tt.in, got, tt.want)
}
})
}
}
func TestNewResolver_DefaultsToBundledPool(t *testing.T) {
// keep the seam already installed so New doesn't replace it with a real
// client; we only assert the constructor accepts an empty override.
withFakeResolver(t, func(string) (resolution, error) { return resolution{}, nil })
r, err := NewResolver(nil)
if err != nil {
t.Fatalf("NewResolver(nil): %v", err)
}
if r == nil {
t.Fatal("NewResolver returned nil resolver")
}
}
+730
View File
@@ -0,0 +1,730 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
// Package finding is the one normalization layer between the scan results and
// the consumers that don't want to know about ~two dozen result structs: notify
// (later) gates and renders on it, diff (later) keys runs off it. Flatten is the
// single type-switch; adding a scanner without teaching Flatten about it trips
// the guard test in flatten_test.go, on purpose.
package finding
import (
"fmt"
"strings"
"github.com/dropalldatabases/sif/internal/modules"
"github.com/dropalldatabases/sif/internal/scan"
"github.com/dropalldatabases/sif/internal/scan/frameworks"
"github.com/dropalldatabases/sif/internal/scan/js"
"github.com/projectdiscovery/nuclei/v3/pkg/output"
)
// Finding is the normalized shape every scanner result collapses to. one
// Finding is one underlying item (a single header, one cors hit, one nuclei
// match) rather than a whole module's blob, so consumers diff and notify at
// item granularity.
type Finding struct {
Target string // the url/host the scan ran against
Module string // the ResultType() of the source scanner
Severity Severity // ranked severity, SeverityUnknown when the source has none
Key string // stable identity for dedup/diff: module + ":" + identifier
Title string // short human label
Raw string // short evidence string, not the full body
}
// Line renders a finding as one stable, terse, machine-friendly line for the
// -silent plain sink: "[severity] target module title". no styling, no color -
// a downstream pipe (notify, grep, awk) keys off the bracketed severity and the
// fixed field order, so the shape stays frozen. pointer receiver: Finding is
// wide enough that copying it per line is wasteful.
func (f *Finding) Line() string {
return fmt.Sprintf("[%s] %s %s %s", f.Severity, f.Target, f.Module, f.Title)
}
// static per-module severities for results that carry no severity field of
// their own. these are the editorial baseline; a scanner that emits its own
// severity (cors, xss, nuclei, ...) overrides this on a per-item basis.
const (
// a live admin panel / takeover / public bucket is high on its own.
sevTakeover = SeverityHigh
sevPublicS3 = SeverityHigh
sevAdminPanel = SeverityHigh
// disclosure-grade signals: dberrors, secrets, supabase keys.
sevDBError = SeverityMedium
sevSecret = SeverityMedium
// pure recon/inventory: headers, crawl urls, passive hosts, ports.
sevRecon = SeverityInfo
)
// keySep joins the module id and the per-item identifier into a Key. kept as a
// const so the diff layer can split on it without re-deriving the separator.
const keySep = ":"
// key builds a stable per-item identity: module:identifier. identifier is
// whatever uniquely names the item within its module (a url, a header name, a
// subdomain) so the same finding across two runs produces the same Key.
func key(module, identifier string) string {
return module + keySep + identifier
}
// Flatten normalizes one module's result into zero or more Findings. result is
// the raw data carried in a ModuleResult; the type switch covers every scan
// result struct. an unrecognized type yields a single SeverityUnknown finding
// keyed "module:unhandled" so a new scanner surfaces loudly instead of
// vanishing - the guard test asserts this never happens for a known type.
func Flatten(target, module string, result any) []Finding {
switch r := result.(type) {
case *scan.ShodanResult:
return flattenShodan(target, r)
case *scan.SQLResult:
return flattenSQL(target, r)
case *scan.LFIResult:
return flattenLFI(target, r)
case *scan.JWTResult:
return flattenJWT(target, r)
case *scan.OpenAPIResult:
return flattenOpenAPI(target, r)
case *scan.FaviconResult:
return flattenFavicon(target, r)
case *scan.CMSResult:
return flattenCMS(target, r)
case *scan.SecurityTrailsResult:
return flattenSecurityTrails(target, r)
case *scan.CORSResult:
return flattenCORS(target, r)
case *scan.RedirectResult:
return flattenRedirect(target, r)
case *scan.XSSResult:
return flattenXSS(target, r)
case *scan.CrawlResult:
return flattenCrawl(target, r)
case *scan.PassiveResult:
return flattenPassive(target, r)
case *scan.ProbeResult:
return flattenProbe(target, r)
case scan.HeaderResults:
return flattenHeaders(target, r)
case []scan.HeaderResult:
// the headers module appends a literal []HeaderResult, not the named
// slice type; both reach here so cover both.
return flattenHeaders(target, r)
case scan.SecurityHeaderResults:
return flattenSecurityHeaders(target, r)
case []scan.SecurityHeaderResult:
return flattenSecurityHeaders(target, r)
case scan.DirectoryResults:
return flattenDirlist(target, r)
case []scan.DirectoryResult:
return flattenDirlist(target, r)
case scan.CloudStorageResults:
return flattenCloudStorage(target, r)
case []scan.CloudStorageResult:
return flattenCloudStorage(target, r)
case scan.DorkResults:
return flattenDork(target, r)
case []scan.DorkResult:
return flattenDork(target, r)
case scan.SubdomainTakeoverResults:
return flattenTakeover(target, r)
case []scan.SubdomainTakeoverResult:
return flattenTakeover(target, r)
case *frameworks.FrameworkResult:
return flattenFramework(target, r)
case *js.JavascriptScanResult:
return flattenJS(target, r)
case *modules.Result:
// yaml/builtin modules carry their own module id; honor it over the
// passed-in module so per-module findings stay attributed correctly.
return flattenModule(target, r)
case []output.ResultEvent:
return flattenNuclei(target, r)
case []string:
// dnslist/portscan/git all hand back a bare []string of discovered
// items; module disambiguates which inventory it is.
return flattenStrings(target, module, r)
default:
// unknown type: emit a loud placeholder rather than dropping it.
return []Finding{{
Target: target,
Module: module,
Severity: SeverityUnknown,
Key: key(module, "unhandled"),
Title: fmt.Sprintf("unhandled result type %T", result),
Raw: fmt.Sprintf("%T", result),
}}
}
}
func flattenShodan(target string, r *scan.ShodanResult) []Finding {
if r == nil {
return nil
}
// one host snapshot -> one inventory finding; vulns are the interesting bit
// so they bump severity and ride along in the evidence string.
sev := sevRecon
if len(r.Vulns) > 0 {
sev = SeverityHigh
}
raw := fmt.Sprintf("%d ports", len(r.Ports))
if len(r.Vulns) > 0 {
raw = fmt.Sprintf("%s, %d vulns", raw, len(r.Vulns))
}
return []Finding{{
Target: target,
Module: "shodan",
Severity: sev,
Key: key("shodan", r.IP),
Title: "shodan host " + r.IP,
Raw: raw,
}}
}
func flattenSQL(target string, r *scan.SQLResult) []Finding {
if r == nil {
return nil
}
out := make([]Finding, 0, len(r.AdminPanels)+len(r.DatabaseErrors)+len(r.ExposedPorts))
for i := 0; i < len(r.AdminPanels); i++ {
p := r.AdminPanels[i]
out = append(out, Finding{
Target: target,
Module: "sql",
Severity: sevAdminPanel,
Key: key("sql", "admin:"+p.URL),
Title: p.Type + " admin panel",
Raw: fmt.Sprintf("%s (%d)", p.URL, p.Status),
})
}
for i := 0; i < len(r.DatabaseErrors); i++ {
e := r.DatabaseErrors[i]
out = append(out, Finding{
Target: target,
Module: "sql",
Severity: sevDBError,
Key: key("sql", "dberr:"+e.URL+":"+e.DatabaseType),
Title: e.DatabaseType + " error disclosure",
Raw: e.ErrorPattern,
})
}
for i := 0; i < len(r.ExposedPorts); i++ {
p := r.ExposedPorts[i]
out = append(out, Finding{
Target: target,
Module: "sql",
Severity: SeverityMedium,
Key: key("sql", fmt.Sprintf("port:%d", p)),
Title: fmt.Sprintf("exposed db port %d", p),
Raw: fmt.Sprintf("%d", p),
})
}
return out
}
func flattenLFI(target string, r *scan.LFIResult) []Finding {
if r == nil {
return nil
}
out := make([]Finding, 0, len(r.Vulnerabilities))
for i := 0; i < len(r.Vulnerabilities); i++ {
v := r.Vulnerabilities[i]
out = append(out, Finding{
Target: target,
Module: "lfi",
Severity: ParseSeverity(v.Severity),
Key: key("lfi", v.URL+":"+v.Parameter),
Title: "lfi via " + v.Parameter,
Raw: v.Evidence,
})
}
return out
}
func flattenJWT(target string, r *scan.JWTResult) []Finding {
if r == nil {
return nil
}
out := make([]Finding, 0, len(r.Tokens))
for i := 0; i < len(r.Tokens); i++ {
t := r.Tokens[i]
// one finding per weakness, not per token: a token with alg:none and a
// weak key is two distinct issues a consumer wants to diff separately.
for j := 0; j < len(t.Issues); j++ {
iss := t.Issues[j]
out = append(out, Finding{
Target: target,
Module: "jwt",
Severity: ParseSeverity(iss.Severity),
Key: key("jwt", t.Source+":"+iss.Kind),
Title: "jwt " + iss.Kind,
Raw: iss.Detail,
})
}
}
return out
}
func flattenOpenAPI(target string, r *scan.OpenAPIResult) []Finding {
if r == nil {
return nil
}
return []Finding{{
Target: target,
Module: "openapi",
Severity: ParseSeverity(r.Severity),
Key: key("openapi", r.SpecURL),
Title: "openapi spec exposed",
Raw: fmt.Sprintf("%s (%d endpoints)", r.SpecURL, len(r.Endpoints)),
}}
}
func flattenFavicon(target string, r *scan.FaviconResult) []Finding {
if r == nil {
return nil
}
// a matched fingerprint is a real signal; an unmatched hash is just inventory
// (still useful as a shodan pivot, so we keep it at recon).
sev := sevRecon
title := fmt.Sprintf("favicon hash %d", r.Hash)
if r.Tech != "" {
sev = SeverityLow
title = r.Tech + " (favicon)"
}
return []Finding{{
Target: target,
Module: "favicon",
Severity: sev,
Key: key("favicon", fmt.Sprintf("%d", r.Hash)),
Title: title,
Raw: r.ShodanQ,
}}
}
func flattenCMS(target string, r *scan.CMSResult) []Finding {
if r == nil || r.Name == "" {
return nil
}
return []Finding{{
Target: target,
Module: "cms",
Severity: sevRecon,
Key: key("cms", r.Name),
Title: r.Name + " detected",
Raw: strings.TrimSpace(r.Name + " " + r.Version),
}}
}
func flattenSecurityTrails(target string, r *scan.SecurityTrailsResult) []Finding {
if r == nil {
return nil
}
out := make([]Finding, 0, len(r.Subdomains)+len(r.AssociatedDomains))
for i := 0; i < len(r.Subdomains); i++ {
d := r.Subdomains[i]
out = append(out, Finding{
Target: target,
Module: "securitytrails",
Severity: sevRecon,
Key: key("securitytrails", "sub:"+d),
Title: "subdomain " + d,
Raw: d,
})
}
for i := 0; i < len(r.AssociatedDomains); i++ {
d := r.AssociatedDomains[i]
out = append(out, Finding{
Target: target,
Module: "securitytrails",
Severity: sevRecon,
Key: key("securitytrails", "assoc:"+d),
Title: "associated domain " + d,
Raw: d,
})
}
return out
}
func flattenCORS(target string, r *scan.CORSResult) []Finding {
if r == nil {
return nil
}
out := make([]Finding, 0, len(r.Findings))
for i := 0; i < len(r.Findings); i++ {
f := r.Findings[i]
out = append(out, Finding{
Target: target,
Module: "cors",
Severity: ParseSeverity(f.Severity),
Key: key("cors", f.URL+":"+f.OriginTested),
Title: f.Note,
Raw: "allow-origin: " + f.AllowOrigin,
})
}
return out
}
func flattenRedirect(target string, r *scan.RedirectResult) []Finding {
if r == nil {
return nil
}
out := make([]Finding, 0, len(r.Findings))
for i := 0; i < len(r.Findings); i++ {
f := r.Findings[i]
out = append(out, Finding{
Target: target,
Module: "redirect",
Severity: ParseSeverity(f.Severity),
Key: key("redirect", f.URL+":"+f.Parameter+":"+f.Via),
Title: "open redirect via " + f.Parameter,
Raw: f.Location,
})
}
return out
}
func flattenXSS(target string, r *scan.XSSResult) []Finding {
if r == nil {
return nil
}
out := make([]Finding, 0, len(r.Findings))
for i := 0; i < len(r.Findings); i++ {
f := r.Findings[i]
out = append(out, Finding{
Target: target,
Module: "xss",
Severity: ParseSeverity(f.Severity),
Key: key("xss", f.URL+":"+f.Parameter+":"+f.Context),
Title: "reflected xss in " + f.Parameter,
Raw: strings.Join(f.SurvivedRaw, " "),
})
}
return out
}
func flattenCrawl(target string, r *scan.CrawlResult) []Finding {
if r == nil {
return nil
}
out := make([]Finding, 0, len(r.URLs))
for i := 0; i < len(r.URLs); i++ {
u := r.URLs[i]
out = append(out, Finding{
Target: target,
Module: "crawl",
Severity: sevRecon,
Key: key("crawl", u),
Title: "crawled url",
Raw: u,
})
}
return out
}
func flattenPassive(target string, r *scan.PassiveResult) []Finding {
if r == nil {
return nil
}
out := make([]Finding, 0, len(r.Subdomains)+len(r.URLs))
for i := 0; i < len(r.Subdomains); i++ {
s := r.Subdomains[i]
out = append(out, Finding{
Target: target,
Module: "passive",
Severity: sevRecon,
Key: key("passive", "sub:"+s),
Title: "passive subdomain " + s,
Raw: s,
})
}
for i := 0; i < len(r.URLs); i++ {
u := r.URLs[i]
out = append(out, Finding{
Target: target,
Module: "passive",
Severity: sevRecon,
Key: key("passive", "url:"+u),
Title: "passive url",
Raw: u,
})
}
return out
}
func flattenProbe(target string, r *scan.ProbeResult) []Finding {
if r == nil || !r.Alive {
// a dead probe isn't a finding, just an absent host.
return nil
}
return []Finding{{
Target: target,
Module: "probe",
Severity: sevRecon,
Key: key("probe", r.URL),
Title: fmt.Sprintf("alive %d", r.StatusCode),
Raw: strings.TrimSpace(fmt.Sprintf("%d %s", r.StatusCode, r.Title)),
}}
}
func flattenHeaders(target string, rs []scan.HeaderResult) []Finding {
out := make([]Finding, 0, len(rs))
for i := 0; i < len(rs); i++ {
h := rs[i]
out = append(out, Finding{
Target: target,
Module: "headers",
Severity: sevRecon,
Key: key("headers", h.Name),
Title: h.Name,
Raw: h.Value,
})
}
return out
}
func flattenSecurityHeaders(target string, rs []scan.SecurityHeaderResult) []Finding {
out := make([]Finding, 0, len(rs))
for i := 0; i < len(rs); i++ {
h := rs[i]
out = append(out, Finding{
Target: target,
Module: "security_headers",
Severity: ParseSeverity(h.Severity),
Key: key("security_headers", h.Header),
Title: h.Header,
Raw: h.Note,
})
}
return out
}
// dirInteresting bounds the "noteworthy" 3xx range for a listed directory; a
// redirect (>=300) or anything past it is worth more than a plain 200 hit.
const dirRedirectFloor = 300
func flattenDirlist(target string, rs []scan.DirectoryResult) []Finding {
out := make([]Finding, 0, len(rs))
for i := 0; i < len(rs); i++ {
d := rs[i]
sev := sevRecon
if d.StatusCode >= dirRedirectFloor {
sev = SeverityLow
}
out = append(out, Finding{
Target: target,
Module: "dirlist",
Severity: sev,
Key: key("dirlist", d.Url),
Title: fmt.Sprintf("%s [%d]", d.Url, d.StatusCode),
Raw: fmt.Sprintf("status=%d size=%d", d.StatusCode, d.Size),
})
}
return out
}
func flattenCloudStorage(target string, rs []scan.CloudStorageResult) []Finding {
out := make([]Finding, 0, len(rs))
for i := 0; i < len(rs); i++ {
b := rs[i]
sev := sevRecon
if b.IsPublic {
sev = sevPublicS3
}
title := "bucket " + b.BucketName
if b.IsPublic {
title = "public bucket " + b.BucketName
}
out = append(out, Finding{
Target: target,
Module: "cloudstorage",
Severity: sev,
Key: key("cloudstorage", b.BucketName),
Title: title,
Raw: fmt.Sprintf("public=%t", b.IsPublic),
})
}
return out
}
func flattenDork(target string, rs []scan.DorkResult) []Finding {
out := make([]Finding, 0, len(rs))
for i := 0; i < len(rs); i++ {
d := rs[i]
out = append(out, Finding{
Target: target,
Module: "dork",
Severity: sevRecon,
Key: key("dork", d.Url),
Title: "dork hit",
Raw: d.Url,
})
}
return out
}
func flattenTakeover(target string, rs []scan.SubdomainTakeoverResult) []Finding {
out := make([]Finding, 0, len(rs))
for i := 0; i < len(rs); i++ {
t := rs[i]
// only the vulnerable ones are findings; a safe cname is noise here.
if !t.Vulnerable {
continue
}
out = append(out, Finding{
Target: target,
Module: "subdomain_takeover",
Severity: sevTakeover,
Key: key("subdomain_takeover", t.Subdomain),
Title: "takeover: " + t.Subdomain,
Raw: t.Service,
})
}
return out
}
func flattenFramework(target string, r *frameworks.FrameworkResult) []Finding {
if r == nil || r.Name == "" {
return nil
}
// framework risk maps onto severity; an unset risk falls back to recon.
sev := ParseSeverity(r.RiskLevel)
if sev == SeverityUnknown {
sev = sevRecon
}
raw := strings.TrimSpace(r.Name + " " + r.Version)
if len(r.CVEs) > 0 {
raw = fmt.Sprintf("%s, %d cves", raw, len(r.CVEs))
}
return []Finding{{
Target: target,
Module: "framework",
Severity: sev,
Key: key("framework", r.Name),
Title: r.Name + " detected",
Raw: raw,
}}
}
func flattenJS(target string, r *js.JavascriptScanResult) []Finding {
if r == nil {
return nil
}
supabase := r.SupabaseFindings()
out := make([]Finding, 0, len(r.SecretMatches)+len(supabase)+len(r.Endpoints)+len(r.FoundEnvironmentVars))
for i := 0; i < len(r.SecretMatches); i++ {
s := r.SecretMatches[i]
out = append(out, Finding{
Target: target,
Module: "js",
Severity: sevSecret,
Key: key("js", "secret:"+s.Rule+":"+s.Source),
Title: "secret: " + s.Rule,
Raw: s.Source,
})
}
for i := 0; i < len(supabase); i++ {
s := supabase[i]
// a non-anon role on an exposed key is the real bug; anon is just recon.
sev := sevRecon
if s.Role != "" && s.Role != "anon" {
sev = SeverityHigh
}
out = append(out, Finding{
Target: target,
Module: "js",
Severity: sev,
Key: key("js", "supabase:"+s.ProjectId),
Title: "supabase project " + s.ProjectId,
Raw: fmt.Sprintf("role=%s collections=%d", s.Role, s.Collections),
})
}
for i := 0; i < len(r.Endpoints); i++ {
e := r.Endpoints[i]
out = append(out, Finding{
Target: target,
Module: "js",
Severity: sevRecon,
Key: key("js", "endpoint:"+e),
Title: "js endpoint",
Raw: e,
})
}
// env vars are a map; sort-free since the Key carries the name, and diff
// keys on the Key not on iteration order.
for name, value := range r.FoundEnvironmentVars {
out = append(out, Finding{
Target: target,
Module: "js",
Severity: sevSecret,
Key: key("js", "env:"+name),
Title: "env var " + name,
Raw: value,
})
}
return out
}
func flattenModule(target string, r *modules.Result) []Finding {
if r == nil {
return nil
}
module := r.ResultType()
out := make([]Finding, 0, len(r.Findings))
for i := 0; i < len(r.Findings); i++ {
f := r.Findings[i]
out = append(out, Finding{
Target: target,
Module: module,
Severity: ParseSeverity(f.Severity),
Key: key(module, f.URL),
Title: module + " finding",
Raw: f.Evidence,
})
}
return out
}
func flattenNuclei(target string, events []output.ResultEvent) []Finding {
out := make([]Finding, 0, len(events))
for i := 0; i < len(events); i++ {
e := events[i]
// host is the most reliable per-hit identifier; matched-at sharpens it
// when several templates fire on one host.
ident := e.TemplateID + ":" + e.Host
if e.Matched != "" {
ident = e.TemplateID + ":" + e.Matched
}
out = append(out, Finding{
Target: target,
Module: "nuclei",
Severity: ParseSeverity(e.Info.SeverityHolder.Severity.String()),
Key: key("nuclei", ident),
Title: e.Info.Name,
Raw: e.Matched,
})
}
return out
}
func flattenStrings(target, module string, items []string) []Finding {
out := make([]Finding, 0, len(items))
for i := 0; i < len(items); i++ {
v := items[i]
out = append(out, Finding{
Target: target,
Module: module,
Severity: sevRecon,
Key: key(module, v),
Title: module + " item",
Raw: v,
})
}
return out
}
+383
View File
@@ -0,0 +1,383 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package finding
import (
"strings"
"testing"
"github.com/dropalldatabases/sif/internal/modules"
"github.com/dropalldatabases/sif/internal/scan"
"github.com/dropalldatabases/sif/internal/scan/frameworks"
"github.com/dropalldatabases/sif/internal/scan/js"
"github.com/projectdiscovery/nuclei/v3/pkg/model"
"github.com/projectdiscovery/nuclei/v3/pkg/model/types/severity"
"github.com/projectdiscovery/nuclei/v3/pkg/output"
)
// scanResultType mirrors the minimal interface the scan packages implement; the
// coverage table below carries a value per ResultType() so a new scanner whose
// ResultType isn't represented (or isn't handled by Flatten) trips a failure.
type scanResultType interface {
ResultType() string
}
// coverageCase is one representative, non-empty instance of a result type plus
// its expected module attribution. wantItems is how many findings Flatten must
// emit for the populated instance, proving the per-item fan-out works.
type coverageCase struct {
value any // the result as it reaches Flatten
typed scanResultType // same value when it implements ResultType(), else nil
module string // module id Flatten should stamp
wantItems int // findings the populated instance must produce
}
// coverageCases is the registry the guard checks against. there must be one
// entry per distinct ResultType() in the scan tree (plus the raw []string and
// nuclei []ResultEvent that flow through the report without a ResultType). add a
// scanner without adding it here and TestFlattenCoversEveryResultType fails.
func coverageCases() []coverageCase {
return []coverageCase{
{
value: &scan.ShodanResult{IP: "1.2.3.4", Ports: []int{80}, Vulns: []string{"CVE-1"}},
typed: &scan.ShodanResult{},
module: "shodan",
wantItems: 1,
},
{
value: &scan.SQLResult{
AdminPanels: []scan.SQLAdminPanel{{URL: "http://x/pma", Type: "phpMyAdmin", Status: 200}},
DatabaseErrors: []scan.SQLDatabaseError{{URL: "http://x", DatabaseType: "mysql", ErrorPattern: "syntax"}},
ExposedPorts: []int{3306},
},
typed: &scan.SQLResult{},
module: "sql",
wantItems: 3,
},
{
value: &scan.LFIResult{Vulnerabilities: []scan.LFIVulnerability{
{URL: "http://x", Parameter: "file", Evidence: "root:x", Severity: "high"},
}},
typed: &scan.LFIResult{},
module: "lfi",
wantItems: 1,
},
{
value: &scan.JWTResult{Tokens: []scan.JWTToken{{
Source: "header:Authorization",
Alg: "none",
Issues: []scan.JWTIssue{
{Kind: "alg:none", Severity: "critical", Detail: "no signature"},
{Kind: "missing exp", Severity: "medium", Detail: "no expiry"},
},
}}},
typed: &scan.JWTResult{},
module: "jwt",
wantItems: 2,
},
{
value: &scan.OpenAPIResult{
SpecURL: "http://x/openapi.json",
Severity: "high",
Endpoints: []scan.OpenAPIEndpoint{{Path: "/users", Method: "GET", Unauth: true}},
},
typed: &scan.OpenAPIResult{},
module: "openapi",
wantItems: 1,
},
{
value: &scan.FaviconResult{Hash: 116323821, Tech: "Apache Tomcat", ShodanQ: "http.favicon.hash:116323821"},
typed: &scan.FaviconResult{},
module: "favicon",
wantItems: 1,
},
{
value: &scan.CMSResult{Name: "WordPress", Version: "6.1"},
typed: &scan.CMSResult{},
module: "cms",
wantItems: 1,
},
{
value: &scan.SecurityTrailsResult{Domain: "x.com", Subdomains: []string{"a.x.com"}, AssociatedDomains: []string{"y.com"}},
typed: &scan.SecurityTrailsResult{},
module: "securitytrails",
wantItems: 2,
},
{
value: &scan.CORSResult{Findings: []scan.CORSFinding{{URL: "http://x", OriginTested: "null", AllowOrigin: "null", Severity: "medium", Note: "null origin"}}},
typed: &scan.CORSResult{},
module: "cors",
wantItems: 1,
},
{
value: &scan.RedirectResult{Findings: []scan.RedirectFinding{{URL: "http://x", Parameter: "next", Location: "http://evil", Via: "header", Severity: "medium"}}},
typed: &scan.RedirectResult{},
module: "redirect",
wantItems: 1,
},
{
value: &scan.XSSResult{Findings: []scan.XSSFinding{{URL: "http://x", Parameter: "q", Context: "html", SurvivedRaw: []string{"<"}, Severity: "high"}}},
typed: &scan.XSSResult{},
module: "xss",
wantItems: 1,
},
{
value: &scan.CrawlResult{URLs: []string{"http://x/a"}},
typed: &scan.CrawlResult{},
module: "crawl",
wantItems: 1,
},
{
value: &scan.PassiveResult{Subdomains: []string{"a.x.com"}, URLs: []string{"http://x/old"}},
typed: &scan.PassiveResult{},
module: "passive",
wantItems: 2,
},
{
value: &scan.ProbeResult{URL: "http://x", Alive: true, StatusCode: 200, Title: "home"},
typed: &scan.ProbeResult{},
module: "probe",
wantItems: 1,
},
{
value: scan.HeaderResults{{Name: "Server", Value: "nginx"}},
typed: scan.HeaderResults{},
module: "headers",
wantItems: 1,
},
{
value: scan.SecurityHeaderResults{{Header: "Content-Security-Policy", Present: false, Severity: "medium", Note: "missing"}},
typed: scan.SecurityHeaderResults{},
module: "security_headers",
wantItems: 1,
},
{
value: scan.DirectoryResults{{Url: "http://x/admin", StatusCode: 301, Size: 10, Words: 2}},
typed: scan.DirectoryResults{},
module: "dirlist",
wantItems: 1,
},
{
value: scan.CloudStorageResults{{BucketName: "x-assets", IsPublic: true}},
typed: scan.CloudStorageResults{},
module: "cloudstorage",
wantItems: 1,
},
{
value: scan.DorkResults{{Url: "http://x/leak", Count: 1}},
typed: scan.DorkResults{},
module: "dork",
wantItems: 1,
},
{
value: scan.SubdomainTakeoverResults{{Subdomain: "old.x.com", Vulnerable: true, Service: "GitHub Pages"}},
typed: scan.SubdomainTakeoverResults{},
module: "subdomain_takeover",
wantItems: 1,
},
{
value: &frameworks.FrameworkResult{Name: "Laravel", Version: "9.0", RiskLevel: "high", CVEs: []string{"CVE-2"}},
typed: &frameworks.FrameworkResult{},
module: "framework",
wantItems: 1,
},
{
value: &js.JavascriptScanResult{
SecretMatches: []js.SecretMatch{{Rule: "aws-key", Match: "AKIA...", Source: "http://x/app.js"}},
Endpoints: []string{"/api/v1"},
},
typed: &js.JavascriptScanResult{},
module: "js",
wantItems: 2,
},
{
value: &modules.Result{ModuleID: "custom-mod", Target: "http://x", Findings: []modules.Finding{{URL: "http://x", Severity: "low", Evidence: "hit"}}},
typed: &modules.Result{ModuleID: "custom-mod"},
module: "custom-mod",
wantItems: 1,
},
{
// nuclei results aren't ScanResult-typed; they ride through the report
// as a raw []ResultEvent, so cover that shape explicitly.
value: []output.ResultEvent{{TemplateID: "t1", Host: "x", Matched: "http://x", Info: model.Info{Name: "n", SeverityHolder: severity.Holder{Severity: severity.High}}}},
module: "nuclei",
wantItems: 1,
},
{
// dnslist/portscan/git all hand Flatten a bare []string keyed only by
// the module argument.
value: []string{"sub.x.com"},
module: "dnslist",
wantItems: 1,
},
}
}
const target = "http://target.example"
// TestFlattenCoversEveryResultType is the guard: every result type in the
// coverage table must flatten into the expected module without hitting the
// "unhandled" fallback. a new scanner that skips both the table and Flatten's
// switch trips this loudly.
func TestFlattenCoversEveryResultType(t *testing.T) {
for _, tc := range coverageCases() {
findings := Flatten(target, tc.module, tc.value)
if len(findings) != tc.wantItems {
t.Errorf("module %q: got %d findings, want %d", tc.module, len(findings), tc.wantItems)
}
for i := 0; i < len(findings); i++ {
f := findings[i]
if strings.HasSuffix(f.Key, keySep+"unhandled") {
t.Errorf("module %q: Flatten has no case, fell through to unhandled (key=%q)", tc.module, f.Key)
}
if f.Target != target {
t.Errorf("module %q: target=%q, want %q", tc.module, f.Target, target)
}
if f.Module != tc.module {
t.Errorf("module %q: finding stamped module=%q, want %q", tc.module, f.Module, tc.module)
}
if f.Key == "" {
t.Errorf("module %q: empty Key", tc.module)
}
if !strings.HasPrefix(f.Key, tc.module+keySep) {
t.Errorf("module %q: Key %q not prefixed with module", tc.module, f.Key)
}
}
}
}
// TestEveryResultTypeIsInCoverageTable cross-checks the table against the actual
// ResultType() registry: if a scanner type exists whose ResultType() isn't in
// the table, the coverage guard above would never exercise it. enumerate the
// known typed entries and assert each ResultType() string is present.
func TestEveryResultTypeIsInCoverageTable(t *testing.T) {
covered := make(map[string]struct{})
for _, tc := range coverageCases() {
if tc.typed == nil {
continue
}
covered[tc.typed.ResultType()] = struct{}{}
}
// the full set of ResultType() strings the scan tree exposes. keep this in
// lockstep with the ScanResult implementers; a missing entry means the table
// (and very likely Flatten) skipped a scanner.
want := []string{
"shodan", "sql", "lfi", "jwt", "openapi", "favicon", "cms", "securitytrails",
"cors", "redirect", "xss", "crawl", "passive", "probe",
"headers", "security_headers", "dirlist", "cloudstorage",
"dork", "subdomain_takeover", "framework", "js", "custom-mod",
}
for _, rt := range want {
if _, ok := covered[rt]; !ok {
t.Errorf("ResultType %q has no entry in coverageCases; Flatten coverage unverified", rt)
}
}
}
// TestFlattenStableKeysAndSeverities pins the keys and severities for a couple
// of representative items so a refactor that quietly reshuffles them is caught.
func TestFlattenStableKeysAndSeverities(t *testing.T) {
tests := []struct {
name string
value any
module string
wantKey string
wantSev Severity
}{
{
name: "cors honors source severity",
value: &scan.CORSResult{Findings: []scan.CORSFinding{{URL: "http://x", OriginTested: "null", AllowOrigin: "null", Severity: "high", Note: "n"}}},
module: "cors",
wantKey: "cors:http://x:null",
wantSev: SeverityHigh,
},
{
name: "public bucket is high",
value: scan.CloudStorageResults{{BucketName: "b", IsPublic: true}},
module: "cloudstorage",
wantKey: "cloudstorage:b",
wantSev: SeverityHigh,
},
{
name: "header is recon info",
value: scan.HeaderResults{{Name: "Server", Value: "nginx"}},
module: "headers",
wantKey: "headers:Server",
wantSev: SeverityInfo,
},
{
name: "vulnerable takeover is high",
value: scan.SubdomainTakeoverResults{{Subdomain: "old.x.com", Vulnerable: true, Service: "GitHub Pages"}},
module: "subdomain_takeover",
wantKey: "subdomain_takeover:old.x.com",
wantSev: SeverityHigh,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
findings := Flatten(target, tt.module, tt.value)
if len(findings) != 1 {
t.Fatalf("got %d findings, want 1", len(findings))
}
f := findings[0]
if f.Key != tt.wantKey {
t.Errorf("Key = %q, want %q", f.Key, tt.wantKey)
}
if f.Severity != tt.wantSev {
t.Errorf("Severity = %v, want %v", f.Severity, tt.wantSev)
}
})
}
}
// TestFlattenUnhandledTypeIsLoud asserts the fallback fires for a type Flatten
// doesn't know - this is what makes the guard above meaningful.
func TestFlattenUnhandledTypeIsLoud(t *testing.T) {
type bogus struct{}
findings := Flatten(target, "mystery", bogus{})
if len(findings) != 1 {
t.Fatalf("got %d findings, want 1 placeholder", len(findings))
}
if !strings.HasSuffix(findings[0].Key, keySep+"unhandled") {
t.Errorf("unhandled type should key on :unhandled, got %q", findings[0].Key)
}
if findings[0].Severity != SeverityUnknown {
t.Errorf("unhandled severity = %v, want SeverityUnknown", findings[0].Severity)
}
}
// TestSubdomainTakeoverSkipsSafe confirms a non-vulnerable cname produces no
// finding; only the real takeover is a finding.
func TestSubdomainTakeoverSkipsSafe(t *testing.T) {
value := scan.SubdomainTakeoverResults{
{Subdomain: "safe.x.com", Vulnerable: false},
{Subdomain: "bad.x.com", Vulnerable: true, Service: "Heroku"},
}
findings := Flatten(target, "subdomain_takeover", value)
if len(findings) != 1 {
t.Fatalf("got %d findings, want 1 (only the vulnerable one)", len(findings))
}
if findings[0].Key != "subdomain_takeover:bad.x.com" {
t.Errorf("Key = %q, want subdomain_takeover:bad.x.com", findings[0].Key)
}
}
// TestDeadProbeIsNotAFinding confirms a host that didn't answer yields nothing.
func TestDeadProbeIsNotAFinding(t *testing.T) {
findings := Flatten(target, "probe", &scan.ProbeResult{URL: "http://x", Alive: false})
if len(findings) != 0 {
t.Errorf("dead probe produced %d findings, want 0", len(findings))
}
}
+48
View File
@@ -0,0 +1,48 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package finding
import "testing"
// Line is the -silent wire format; its shape is frozen, so pin it.
func TestFindingLine(t *testing.T) {
tests := []struct {
name string
f Finding
want string
}{
{
name: "high severity",
f: Finding{Target: "https://x.com", Module: "sql", Severity: SeverityHigh, Title: "admin panel"},
want: "[high] https://x.com sql admin panel",
},
{
name: "info recon",
f: Finding{Target: "https://y.com", Module: "headers", Severity: SeverityInfo, Title: "Server"},
want: "[info] https://y.com headers Server",
},
{
name: "unknown severity",
f: Finding{Target: "z.com", Module: "mystery", Severity: SeverityUnknown, Title: "?"},
want: "[unknown] z.com mystery ?",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := tt.f.Line(); got != tt.want {
t.Errorf("Line() = %q, want %q", got, tt.want)
}
})
}
}
+78
View File
@@ -0,0 +1,78 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package finding
import "strings"
// Severity is an ordered severity rank shared by every normalized finding.
// the order matters: notify gates on a threshold and diff sorts by it, so the
// underlying ints have to compare info < low < medium < high < critical.
type Severity int
// severity ranks, lowest to highest. SeverityUnknown sorts below everything so
// an unrecognized scanner string never silently outranks a real critical.
const (
SeverityUnknown Severity = iota
SeverityInfo
SeverityLow
SeverityMedium
SeverityHigh
SeverityCritical
)
// severityNames maps each rank to its canonical lowercase string. the wire
// format scanners emit ("info"/"low"/...) round-trips through this table.
var severityNames = map[Severity]string{
SeverityUnknown: "unknown",
SeverityInfo: "info",
SeverityLow: "low",
SeverityMedium: "medium",
SeverityHigh: "high",
SeverityCritical: "critical",
}
// String renders the canonical lowercase name for the rank.
func (s Severity) String() string {
if name, ok := severityNames[s]; ok {
return name
}
return severityNames[SeverityUnknown]
}
// ParseSeverity maps a scanner's free-form severity string onto a rank. it's
// case/space insensitive and folds the common synonyms ("informational",
// "warning", "moderate") so the dozen scanners that each picked their own
// spelling all land on the same ladder. an empty or unrecognized value is
// SeverityUnknown rather than a guess.
func ParseSeverity(raw string) Severity {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "critical":
return SeverityCritical
case "high":
return SeverityHigh
case "medium", "moderate", "warning":
return SeverityMedium
case "low":
return SeverityLow
case "info", "informational", "information", "none":
return SeverityInfo
default:
return SeverityUnknown
}
}
// AtLeast reports whether s is at or above threshold; notify uses it to drop
// findings below the configured floor.
func (s Severity) AtLeast(threshold Severity) bool {
return s >= threshold
}
+84
View File
@@ -0,0 +1,84 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package finding
import "testing"
func TestParseSeverity(t *testing.T) {
tests := []struct {
in string
want Severity
}{
{"critical", SeverityCritical},
{"CRITICAL", SeverityCritical},
{" high ", SeverityHigh},
{"medium", SeverityMedium},
{"moderate", SeverityMedium},
{"warning", SeverityMedium},
{"low", SeverityLow},
{"info", SeverityInfo},
{"informational", SeverityInfo},
{"none", SeverityInfo},
{"", SeverityUnknown},
{"bogus", SeverityUnknown},
}
for _, tt := range tests {
if got := ParseSeverity(tt.in); got != tt.want {
t.Errorf("ParseSeverity(%q) = %v, want %v", tt.in, got, tt.want)
}
}
}
func TestSeverityOrdering(t *testing.T) {
// the ladder must be strictly increasing for AtLeast/sort to behave.
ordered := []Severity{
SeverityUnknown, SeverityInfo, SeverityLow,
SeverityMedium, SeverityHigh, SeverityCritical,
}
for i := 1; i < len(ordered); i++ {
if ordered[i-1] >= ordered[i] {
t.Errorf("severity ladder not increasing at %d: %v !< %v", i, ordered[i-1], ordered[i])
}
}
}
func TestSeverityAtLeast(t *testing.T) {
tests := []struct {
sev Severity
threshold Severity
want bool
}{
{SeverityHigh, SeverityMedium, true},
{SeverityMedium, SeverityMedium, true},
{SeverityLow, SeverityMedium, false},
{SeverityCritical, SeverityInfo, true},
{SeverityUnknown, SeverityInfo, false},
}
for _, tt := range tests {
if got := tt.sev.AtLeast(tt.threshold); got != tt.want {
t.Errorf("%v.AtLeast(%v) = %v, want %v", tt.sev, tt.threshold, got, tt.want)
}
}
}
func TestSeverityStringRoundTrip(t *testing.T) {
// every named rank renders to a string ParseSeverity maps back to the same
// rank, so the wire format is lossless for known severities.
for _, sev := range []Severity{
SeverityInfo, SeverityLow, SeverityMedium, SeverityHigh, SeverityCritical,
} {
if got := ParseSeverity(sev.String()); got != sev {
t.Errorf("round-trip %v -> %q -> %v", sev, sev.String(), got)
}
}
}
+56
View File
@@ -0,0 +1,56 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
// Package fingerprint holds small response-fingerprinting primitives shared by
// the scan checks and the module engine, so both compute identical values.
package fingerprint
import (
"encoding/base64"
"strings"
"github.com/twmb/murmur3"
)
// b64LineLen is python's base64.encodebytes line width. mmh3/shodan hash the
// chunked base64 (newline every 76 chars, trailing newline), so we must wrap at
// exactly this width to land on the same hash.
const b64LineLen = 76
// FaviconHash computes shodan's favicon hash: murmur3 32-bit over the python
// base64.encodebytes encoding of the raw icon (newline every 76 chars plus a
// trailing newline), reinterpreted as a signed int32 (both load-bearing, golden-pinned).
func FaviconHash(data []byte) int32 {
encoded := encodeFaviconBase64(data)
return int32(murmur3.Sum32(encoded)) //nolint:gosec // shodan stores the signed reinterpretation on purpose
}
// encodeFaviconBase64 mirrors python's base64.encodebytes: standard base64 with
// a newline inserted every 76 output characters and a trailing newline. this is
// the exact byte stream shodan feeds to mmh3, so it must match byte-for-byte.
func encodeFaviconBase64(data []byte) []byte {
raw := base64.StdEncoding.EncodeToString(data)
var b strings.Builder
// final size: the base64 body plus one '\n' per (full or partial) 76-char
// line. preallocate so the builder never regrows mid-loop.
b.Grow(len(raw) + len(raw)/b64LineLen + 1)
for i := 0; i < len(raw); i += b64LineLen {
end := i + b64LineLen
if end > len(raw) {
end = len(raw)
}
b.WriteString(raw[i:end])
b.WriteByte('\n')
}
return []byte(b.String())
}
+68
View File
@@ -0,0 +1,68 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package fingerprint
import (
"strings"
"testing"
)
// goldenFaviconBytes is a fixed payload long enough to span multiple base64
// lines, so the python-style 76-char chunking is actually exercised by the hash.
var goldenFaviconBytes = []byte(strings.Repeat("sif-favicon-golden-test-bytes-", 8))
// goldenFaviconHash is the pinned shodan mmh3 hash of goldenFaviconBytes: the python
// base64.encodebytes byte stream (76-char lines + trailing newline) through murmur3-32,
// reinterpreted as a signed int32. if the chunking or signedness regress, this test fails.
const goldenFaviconHash int32 = -1554620260
// goldenHelloHash pins a short single-line case so a regression in the trailing
// newline (which the small case still has) is caught independently.
const goldenHelloHash int32 = 1155597304
func TestFaviconHashGolden(t *testing.T) {
tests := []struct {
name string
in []byte
want int32
}{
{name: "multi-line fixture", in: goldenFaviconBytes, want: goldenFaviconHash},
{name: "single-line hello", in: []byte("hello"), want: goldenHelloHash},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := FaviconHash(tt.in); got != tt.want {
t.Errorf("FaviconHash = %d, want %d", got, tt.want)
}
})
}
}
// TestFaviconBase64Chunking pins the encode step against python's
// base64.encodebytes: a 60-byte input encodes to 80 base64 chars, so it must
// wrap into two newline-terminated lines.
func TestFaviconBase64Chunking(t *testing.T) {
in := []byte(strings.Repeat("A", 60))
got := string(encodeFaviconBase64(in))
lines := strings.Split(strings.TrimRight(got, "\n"), "\n")
if len(lines) != 2 {
t.Fatalf("expected 2 wrapped lines, got %d: %q", len(lines), got)
}
if len(lines[0]) != b64LineLen {
t.Errorf("first line = %d chars, want %d", len(lines[0]), b64LineLen)
}
if !strings.HasSuffix(got, "\n") {
t.Errorf("encoding must end in a trailing newline, got %q", got)
}
}
+258
View File
@@ -0,0 +1,258 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
// Package httpx is the shared http layer every scanner talks through, so a
// single Configure call wires proxy, custom headers, cookies and rate limiting
// into every outbound request without touching scanner signatures.
package httpx
import (
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"sync"
"time"
"golang.org/x/net/proxy"
"golang.org/x/time/rate"
)
// allowed proxy schemes
const (
schemeHTTP = "http"
schemeHTTPS = "https"
schemeSOCKS5 = "socks5"
)
// a header is "Key: Value"; this is the separator between the two halves.
const headerSep = ": "
// burst lets the limiter absorb a small spike before pacing kicks in; a burst
// equal to the per-second rate keeps the cap honest over any one-second window.
const limiterBurstPerRate = 1
// transport pool tuning. go's default transport caps idle conns per host at 2
// and reuse only kicks in once a response body is fully drained, so without
// these a high thread count just thrashes the dialer instead of pooling.
const (
// total idle conns kept warm across every host we hit in a run.
maxIdleConns = 512
// floor for per-host idle conns so a single-target run still pools even
// when the thread count is tiny.
minIdleConnsPerHost = 8
// how long an idle conn lingers before the pool reaps it.
idleConnTimeout = 90 * time.Second
// keepalive probe interval for live conns; mirrors go's default dialer so
// the socks5 branch doesn't silently lose os-level keepalive.
dialKeepAlive = 30 * time.Second
// dial timeout for the socks5 branch; matches go's default dialer.
dialTimeout = 30 * time.Second
)
// drainCap bounds how much of an unread body DrainClose will copy before
// closing; a body larger than this isn't worth slurping just to reuse the
// conn, so we cap the read and let the conn be discarded instead.
const drainCap = 16 << 10
// Options carries the runtime knobs that apply to every outbound request.
// RateLimit is requests/sec (0 = unlimited); Headers are "Key: Value" strings.
type Options struct {
Proxy string
Headers []string
Cookie string
UserAgent string
RateLimit int
// Threads is the scan worker count; it sizes the per-host idle pool so
// concurrent workers hitting one target reuse conns instead of dialing fresh.
Threads int
}
// configured holds the package-level transport built once by Configure. nil
// means Configure was never called, so Client falls back to a plain client.
var (
mu sync.RWMutex
configured http.RoundTripper
)
// Configure builds the shared transport once at startup from opts. Calling it
// again replaces the previous configuration.
//
//nolint:gocritic // signature is the package's stable startup api; called once.
func Configure(opts Options) error {
base, err := buildTransport(opts.Proxy, opts.Threads)
if err != nil {
return err
}
headers, err := parseHeaders(opts.Headers)
if err != nil {
return err
}
var limiter *rate.Limiter
if opts.RateLimit > 0 {
limiter = rate.NewLimiter(rate.Limit(opts.RateLimit), opts.RateLimit*limiterBurstPerRate)
}
rt := &roundTripper{
base: base,
headers: headers,
cookie: opts.Cookie,
userAgent: opts.UserAgent,
limiter: limiter,
}
mu.Lock()
configured = rt
mu.Unlock()
return nil
}
// Client returns an http client wired to the configured transport. It works
// before Configure is ever called (plain transport) so existing code and tests
// behave unchanged. A zero timeout means no timeout, matching http.Client.
func Client(timeout time.Duration) *http.Client {
mu.RLock()
rt := configured
mu.RUnlock()
return &http.Client{Timeout: timeout, Transport: rt}
}
// buildTransport clones the default transport, tunes its pool for the worker
// count and applies the proxy. An empty proxy leaves the default behavior
// (respects HTTP_PROXY env) intact.
func buildTransport(proxyURL string, threads int) (*http.Transport, error) {
tr, ok := http.DefaultTransport.(*http.Transport)
if !ok {
// unreachable in practice, but never trust an assertion silently.
return nil, fmt.Errorf("default transport is not *http.Transport")
}
transport := tr.Clone()
// size the idle pool so every worker can keep its conn warm. per-host idle
// must clear the thread count or workers past the cap re-dial each request;
// MaxConnsPerHost stays 0 (unbounded) so the limiter, not the pool, paces us.
transport.MaxIdleConns = maxIdleConns
transport.MaxIdleConnsPerHost = idlePerHost(threads)
transport.MaxConnsPerHost = 0
transport.IdleConnTimeout = idleConnTimeout
transport.ForceAttemptHTTP2 = true
if proxyURL == "" {
return transport, nil
}
parsed, err := url.Parse(proxyURL)
if err != nil {
return nil, fmt.Errorf("parse proxy url %q: %w", proxyURL, err)
}
switch parsed.Scheme {
case schemeHTTP, schemeHTTPS:
transport.Proxy = http.ProxyURL(parsed)
case schemeSOCKS5:
// socks5 needs a custom dialer. proxy.SOCKS5 takes a forward dialer, so
// hand it our own net.Dialer with keepalive set - the default
// proxy.Direct has none, which would kill os-level conn pooling.
fwd := &net.Dialer{Timeout: dialTimeout, KeepAlive: dialKeepAlive}
dialer, err := proxy.SOCKS5("tcp", parsed.Host, nil, fwd)
if err != nil {
return nil, fmt.Errorf("socks5 proxy %q: %w", proxyURL, err)
}
ctxDialer, ok := dialer.(proxy.ContextDialer)
if !ok {
return nil, fmt.Errorf("socks5 proxy %q: dialer lacks context support", proxyURL)
}
transport.DialContext = ctxDialer.DialContext
default:
return nil, fmt.Errorf("unsupported proxy scheme %q (want http/https/socks5)", parsed.Scheme)
}
return transport, nil
}
// idlePerHost picks the per-host idle pool size: at least the worker count so
// no worker re-dials, never below the floor so a small thread count still pools.
func idlePerHost(threads int) int {
if threads < minIdleConnsPerHost {
return minIdleConnsPerHost
}
return threads
}
// DrainClose fully reads (up to drainCap) and closes resp.Body. go only returns
// a conn to the idle pool when the body is read to EOF, so a caller that only
// closes leaks the conn and forces a fresh dial next time. Call this instead of
// a bare resp.Body.Close() to keep the pool warm. Safe on a nil response.
func DrainClose(resp *http.Response) {
if resp == nil || resp.Body == nil {
return
}
// the read result is intentionally ignored: we're discarding the body and
// about to close it, so a copy error changes nothing we can act on.
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, drainCap))
resp.Body.Close()
}
// parseHeaders splits each "Key: Value" entry on the first ": ". Entries
// without the separator are rejected so a typo fails loud instead of silently.
// The returned map is always non-nil so callers can range it unconditionally.
func parseHeaders(raw []string) (map[string]string, error) {
headers := make(map[string]string, len(raw))
for i := 0; i < len(raw); i++ {
key, value, ok := strings.Cut(raw[i], headerSep)
if !ok {
return nil, fmt.Errorf("invalid header %q (want \"Key: Value\")", raw[i])
}
headers[key] = value
}
return headers, nil
}
// roundTripper paces and decorates each request before delegating to base.
type roundTripper struct {
base *http.Transport
headers map[string]string
cookie string
userAgent string
limiter *rate.Limiter
}
func (rt *roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
if rt.limiter != nil {
if err := rt.limiter.Wait(req.Context()); err != nil {
return nil, fmt.Errorf("rate limiter: %w", err)
}
}
// only set what the caller hasn't already; a scanner that explicitly sets a
// header (e.g. an api key) must win over the global default.
for key, value := range rt.headers {
if req.Header.Get(key) == "" {
req.Header.Set(key, value)
}
}
if rt.cookie != "" && req.Header.Get("Cookie") == "" {
req.Header.Set("Cookie", rt.cookie)
}
if rt.userAgent != "" && req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", rt.userAgent)
}
return rt.base.RoundTrip(req)
}
+491
View File
@@ -0,0 +1,491 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package httpx
import (
"context"
"io"
"net"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
)
// resetConfig clears the package-level transport so each test starts clean.
func resetConfig(t *testing.T) {
t.Helper()
mu.Lock()
configured = nil
mu.Unlock()
}
// captureServer records the headers of the last request it served.
func captureServer(t *testing.T, seen *http.Header) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
*seen = r.Header.Clone()
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
return srv
}
func get(t *testing.T, client *http.Client, url string) {
t.Helper()
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, http.NoBody)
if err != nil {
t.Fatalf("new request: %v", err)
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("do request: %v", err)
}
resp.Body.Close()
}
func TestClientBeforeConfigure(t *testing.T) {
resetConfig(t)
var seen http.Header
srv := captureServer(t, &seen)
// a client must work with no Configure call so existing code is unaffected.
get(t, Client(5*time.Second), srv.URL)
if seen == nil {
t.Fatal("request never reached the server")
}
}
func TestConfigureHeadersAndCookie(t *testing.T) {
tests := []struct {
name string
opts Options
wantKey string
wantValue string
}{
{
name: "custom header injected",
opts: Options{Headers: []string{"X-Test: sif"}},
wantKey: "X-Test",
wantValue: "sif",
},
{
name: "cookie injected",
opts: Options{Cookie: "session=abc"},
wantKey: "Cookie",
wantValue: "session=abc",
},
{
name: "user agent injected",
opts: Options{UserAgent: "sif-scanner"},
wantKey: "User-Agent",
wantValue: "sif-scanner",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resetConfig(t)
if err := Configure(tt.opts); err != nil {
t.Fatalf("Configure: %v", err)
}
var seen http.Header
srv := captureServer(t, &seen)
get(t, Client(5*time.Second), srv.URL)
if got := seen.Get(tt.wantKey); got != tt.wantValue {
t.Errorf("header %q = %q, want %q", tt.wantKey, got, tt.wantValue)
}
})
}
}
func TestConfigureHeaderDoesNotOverride(t *testing.T) {
resetConfig(t)
if err := Configure(Options{Headers: []string{"X-Test: global"}}); err != nil {
t.Fatalf("Configure: %v", err)
}
var seen http.Header
srv := captureServer(t, &seen)
// a caller that sets the header explicitly must win over the global default.
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL, http.NoBody)
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("X-Test", "caller")
resp, err := Client(5 * time.Second).Do(req)
if err != nil {
t.Fatalf("do request: %v", err)
}
resp.Body.Close()
if got := seen.Get("X-Test"); got != "caller" {
t.Errorf("X-Test = %q, want caller (caller value must not be overridden)", got)
}
}
func TestConfigureInvalidHeader(t *testing.T) {
resetConfig(t)
// a header without ": " should fail loud rather than silently dropping.
if err := Configure(Options{Headers: []string{"missing-separator"}}); err == nil {
t.Fatal("expected error for malformed header, got nil")
}
}
func TestConfigureInvalidProxy(t *testing.T) {
tests := []struct {
name string
proxy string
}{
{name: "unsupported scheme", proxy: "ftp://localhost:1080"},
{name: "malformed url", proxy: "://nope"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resetConfig(t)
if err := Configure(Options{Proxy: tt.proxy}); err == nil {
t.Errorf("expected error for proxy %q, got nil", tt.proxy)
}
})
}
}
func TestRateLimit(t *testing.T) {
resetConfig(t)
const ratePerSec = 5
if err := Configure(Options{RateLimit: ratePerSec}); err != nil {
t.Fatalf("Configure: %v", err)
}
var seen http.Header
srv := captureServer(t, &seen)
client := Client(5 * time.Second)
// at 5 req/s the limiter starts with a full burst, so the first batch is
// immediate and the next request must wait roughly one tick. fire burst+1
// requests and assert the extra one forced a measurable delay.
const requests = ratePerSec + 1
start := time.Now()
for i := 0; i < requests; i++ {
get(t, client, srv.URL)
}
elapsed := time.Since(start)
// one request beyond the burst should cost about 1/rate; allow slack but
// require a non-trivial delay so an unthrottled client fails this.
minDelay := time.Second / ratePerSec / 2
if elapsed < minDelay {
t.Errorf("expected rate limiting to add >= %v of delay, got %v", minDelay, elapsed)
}
}
func TestRateLimitUnlimited(t *testing.T) {
resetConfig(t)
// RateLimit 0 means no limiter is installed; requests should fly through.
if err := Configure(Options{RateLimit: 0}); err != nil {
t.Fatalf("Configure: %v", err)
}
mu.RLock()
rt, ok := configured.(*roundTripper)
mu.RUnlock()
if !ok {
t.Fatal("configured transport is not *roundTripper")
}
if rt.limiter != nil {
t.Error("expected no limiter when RateLimit is 0")
}
}
func TestIdlePerHost(t *testing.T) {
tests := []struct {
name string
threads int
want int
}{
{name: "below floor clamps up", threads: 1, want: minIdleConnsPerHost},
{name: "zero clamps up", threads: 0, want: minIdleConnsPerHost},
{name: "at floor", threads: minIdleConnsPerHost, want: minIdleConnsPerHost},
{name: "above floor passes through", threads: 64, want: 64},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := idlePerHost(tt.threads); got != tt.want {
t.Errorf("idlePerHost(%d) = %d, want %d", tt.threads, got, tt.want)
}
})
}
}
func TestBuildTransportTuning(t *testing.T) {
const threads = 32
tr, err := buildTransport("", threads)
if err != nil {
t.Fatalf("buildTransport: %v", err)
}
if tr.MaxIdleConns != maxIdleConns {
t.Errorf("MaxIdleConns = %d, want %d", tr.MaxIdleConns, maxIdleConns)
}
if tr.MaxIdleConnsPerHost != threads {
t.Errorf("MaxIdleConnsPerHost = %d, want %d", tr.MaxIdleConnsPerHost, threads)
}
if tr.MaxConnsPerHost != 0 {
t.Errorf("MaxConnsPerHost = %d, want 0 (unbounded)", tr.MaxConnsPerHost)
}
if tr.IdleConnTimeout != idleConnTimeout {
t.Errorf("IdleConnTimeout = %v, want %v", tr.IdleConnTimeout, idleConnTimeout)
}
if !tr.ForceAttemptHTTP2 {
t.Error("ForceAttemptHTTP2 = false, want true")
}
}
func TestDrainClose(t *testing.T) {
resetConfig(t)
// serve a body the caller never reads; DrainClose must drain it so the conn
// is eligible for reuse rather than abandoned mid-stream.
const body = "sif response body that the caller never reads"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
io.WriteString(w, body)
}))
t.Cleanup(srv.Close)
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL, http.NoBody)
if err != nil {
t.Fatalf("new request: %v", err)
}
resp, err := Client(5 * time.Second).Do(req)
if err != nil {
t.Fatalf("do request: %v", err)
}
DrainClose(resp)
// after DrainClose the body is closed; a further read must fail.
if _, err := resp.Body.Read(make([]byte, 1)); err == nil {
t.Error("expected read after DrainClose to fail on a closed body")
}
}
func TestDrainCloseNil(t *testing.T) {
// a nil response (e.g. an errored request) must not panic.
DrainClose(nil)
DrainClose(&http.Response{})
}
// countConns wraps a test server with a ConnState hook that tallies how many
// distinct tcp conns the server saw. distinct conns == failed reuse.
func countConns(t *testing.T) (*httptest.Server, func() int) {
t.Helper()
var (
mu sync.Mutex
conns = make(map[net.Conn]struct{})
)
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
// always write a body so reuse depends on the caller draining it.
io.WriteString(w, "ok")
}))
srv.Config.ConnState = func(c net.Conn, state http.ConnState) {
if state != http.StateNew {
return
}
mu.Lock()
conns[c] = struct{}{}
mu.Unlock()
}
srv.Start()
t.Cleanup(srv.Close)
return srv, func() int {
mu.Lock()
defer mu.Unlock()
return len(conns)
}
}
func TestTransportReusesConnections(t *testing.T) {
resetConfig(t)
const (
threads = 8
requests = 30
)
if err := Configure(Options{Threads: threads}); err != nil {
t.Fatalf("Configure: %v", err)
}
srv, distinct := countConns(t)
// fire N sequential requests through the tuned client, draining each body so
// the conn returns to the pool. a working pool serves all of them on one conn.
client := Client(5 * time.Second)
for i := 0; i < requests; i++ {
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL, http.NoBody)
if err != nil {
t.Fatalf("new request %d: %v", i, err)
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("do request %d: %v", i, err)
}
DrainClose(resp)
}
// sequential reuse should land on exactly one conn; allow a tiny margin for
// the rare race where a conn is reaped between requests.
const maxReuseConns = 2
if got := distinct(); got > maxReuseConns {
t.Errorf("tuned client opened %d conns for %d requests, want <= %d (pool not reusing)",
got, requests, maxReuseConns)
}
}
func TestBareClientDoesNotReuse(t *testing.T) {
srv, distinct := countConns(t)
// the control: a bare DefaultTransport client whose caller closes but never
// drains the body. go can't reuse a half-read conn, so each request dials
// fresh - this is exactly the pre-tuning behavior we're fixing.
client := &http.Client{
Timeout: 5 * time.Second,
Transport: http.DefaultTransport.(*http.Transport).Clone(),
}
const requests = 30
for i := 0; i < requests; i++ {
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL, http.NoBody)
if err != nil {
t.Fatalf("new request %d: %v", i, err)
}
resp, err := client.Do(req)
if err != nil {
t.Fatalf("do request %d: %v", i, err)
}
// close without draining - the leak that kills reuse.
resp.Body.Close()
}
// most requests should have dialed a fresh conn. don't demand exactly N (the
// scheduler occasionally reuses one), just that it's clearly not pooling.
const minDistinct = requests / 2
if got := distinct(); got < minDistinct {
t.Errorf("bare client opened only %d conns for %d requests, want >= %d "+
"(expected near-zero reuse without draining)", got, requests, minDistinct)
}
}
// BenchmarkConnReuse contrasts the tuned, draining client against a bare client
// that closes without draining. the reported conns/op metric is the distinct
// tcp conns one pass of `requests` opened - tuned≈1, bare≈requests - so the
// README can quote real before/after reuse numbers. the conn map is reset per
// iteration so the metric stays a per-pass count and the bare path doesn't
// accumulate b.N*requests live sockets and exhaust the ephemeral port range.
//
// run the bare sub-bench with a bounded -benchtime (e.g. -benchtime 5x): its
// whole point is that it can't reuse, so a large b.N floods the local port
// space with TIME_WAIT sockets. the tuned sub-bench reuses and runs unbounded.
func BenchmarkConnReuse(b *testing.B) {
const requests = 50
run := func(b *testing.B, drain bool, client *http.Client) {
b.Helper()
var (
mu sync.Mutex
conns = make(map[net.Conn]struct{})
)
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
io.WriteString(w, strings.Repeat("x", 256))
}))
srv.Config.ConnState = func(c net.Conn, state http.ConnState) {
if state != http.StateNew {
return
}
mu.Lock()
conns[c] = struct{}{}
mu.Unlock()
}
srv.Start()
defer srv.Close()
var lastPass int
b.ResetTimer()
for n := 0; n < b.N; n++ {
mu.Lock()
conns = make(map[net.Conn]struct{})
mu.Unlock()
for i := 0; i < requests; i++ {
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, srv.URL, http.NoBody)
resp, err := client.Do(req)
if err != nil {
b.Fatalf("do: %v", err)
}
if drain {
DrainClose(resp)
} else {
resp.Body.Close()
}
}
// close idle conns between passes so the bare client's per-pass
// sockets land in TIME_WAIT and free up before the next pass.
client.CloseIdleConnections()
mu.Lock()
lastPass = len(conns)
mu.Unlock()
}
b.StopTimer()
// distinct conns for a single pass of `requests`.
b.ReportMetric(float64(lastPass), "conns/op")
}
b.Run("tuned-drain", func(b *testing.B) {
resetBench()
tr, err := buildTransport("", 8)
if err != nil {
b.Fatalf("buildTransport: %v", err)
}
run(b, true, &http.Client{Timeout: 5 * time.Second, Transport: tr})
})
b.Run("bare-noDrain", func(b *testing.B) {
run(b, false, &http.Client{
Timeout: 5 * time.Second,
Transport: http.DefaultTransport.(*http.Transport).Clone(),
})
})
}
// resetBench clears the package transport without a *testing.T for benchmarks.
func resetBench() {
mu.Lock()
configured = nil
mu.Unlock()
}
+7 -4
View File
@@ -4,7 +4,7 @@
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2025 vmfunc (vmfunc), xyzeva, :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
@@ -37,7 +37,7 @@ var defaultLogger = &Logger{
// Init creates the log directory if it doesn't exist.
func Init(dir string) error {
if _, err := os.Stat(dir); os.IsNotExist(err) {
if err = os.Mkdir(dir, 0755); err != nil {
if err := os.Mkdir(dir, 0o750); err != nil {
return err
}
}
@@ -62,7 +62,7 @@ func (l *Logger) getWriter(path string) (*bufio.Writer, error) {
return w, nil
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return nil, err
}
@@ -124,7 +124,10 @@ func (l *Logger) Close() error {
// CreateFile initializes a log file for the given URL and writes the header.
func CreateFile(logFiles *[]string, url string, dir string) error {
sanitizedURL := strings.Split(url, "://")[1]
sanitizedURL := url
if _, after, ok := strings.Cut(url, "://"); ok {
sanitizedURL = after
}
path := filepath.Join(dir, sanitizedURL+".log")
header := fmt.Sprintf(" _____________\n__________(_)__ __/\n__ ___/_ /__ /_ \n_(__ )_ / _ __/ \n/____/ /_/ /_/ \n\nsif log file for %s\nhttps://sif.sh\n\n", url)
+1 -1
View File
@@ -4,7 +4,7 @@
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2025 vmfunc (vmfunc), xyzeva, :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
@@ -0,0 +1,164 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runAnalyticsModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func analyticsExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestAnalyticsUIExposureModules(t *testing.T) {
const metabase = "../../modules/recon/metabase-api-exposure.yaml"
const zeppelin = "../../modules/recon/zeppelin-api-exposure.yaml"
const jupyter = "../../modules/recon/jupyter-api-exposure.yaml"
metabaseProps := `{"engines":{"postgres":{"driver-name":"PostgreSQL"}},` +
`"setup-token":"245f5f7c-8f0b-4c20-9a1e-6b2d7e1f0a33","anon-tracking-enabled":true,` +
`"available-locales":[["en","English"]],"password-complexity":{"total":6},` +
`"version":{"date":"2023-10-01","tag":"v0.47.2","branch":"release-x.47.x","hash":"abc1234"}}`
zeppelinVersion := `{"status":"OK","message":"Zeppelin version",` +
`"body":{"version":"0.10.1","git-commit-id":"a1b2c3d4e5","git-timestamp":"2022-01-15 10:00:00"}}`
jupyterStatus := `{"started":"2024-01-01T00:00:00.000000Z",` +
`"last_activity":"2024-01-01T01:23:45.000000Z","connections":2,"kernels":3}`
t.Run("an exposed metabase properties api is flagged and versioned", func(t *testing.T) {
res := runAnalyticsModule(t, metabase, 200, metabaseProps)
if len(res.Findings) == 0 {
t.Fatal("expected a metabase finding")
}
if v := analyticsExtract(res, "metabase_version"); v != "v0.47.2" {
t.Errorf("metabase_version=%q, want v0.47.2", v)
}
})
t.Run("an exposed zeppelin server is flagged and versioned", func(t *testing.T) {
res := runAnalyticsModule(t, zeppelin, 200, zeppelinVersion)
if len(res.Findings) == 0 {
t.Fatal("expected a zeppelin finding")
}
if v := analyticsExtract(res, "zeppelin_version"); v != "0.10.1" {
t.Errorf("zeppelin_version=%q, want 0.10.1", v)
}
})
t.Run("an exposed jupyter status api is flagged with the kernel count", func(t *testing.T) {
res := runAnalyticsModule(t, jupyter, 200, jupyterStatus)
if len(res.Findings) == 0 {
t.Fatal("expected a jupyter finding")
}
if v := analyticsExtract(res, "jupyter_active_kernels"); v != "3" {
t.Errorf("jupyter_active_kernels=%q, want 3", v)
}
})
t.Run("a live metabase token without the tracking setting is not flagged", func(t *testing.T) {
body := `{"setup-token":"245f5f7c-8f0b-4c20-9a1e-6b2d7e1f0a33","name":"app"}`
if res := runAnalyticsModule(t, metabase, 200, body); len(res.Findings) > 0 {
t.Errorf("a setup token alone should not match metabase, got %d findings", len(res.Findings))
}
})
t.Run("a metabase tracking setting without a setup token is not flagged", func(t *testing.T) {
body := `{"anon-tracking-enabled":true,"name":"app"}`
if res := runAnalyticsModule(t, metabase, 200, body); len(res.Findings) > 0 {
t.Errorf("a tracking setting alone should not match metabase, got %d findings", len(res.Findings))
}
})
t.Run("a patched metabase with a null setup token is not flagged", func(t *testing.T) {
body := `{"setup-token":null,"anon-tracking-enabled":true,` +
`"version":{"tag":"v0.47.2"}}`
if res := runAnalyticsModule(t, metabase, 200, body); len(res.Findings) > 0 {
t.Errorf("a null setup token should not match metabase, got %d findings", len(res.Findings))
}
})
t.Run("a zeppelin banner without a git commit id is not flagged", func(t *testing.T) {
body := `{"status":"OK","message":"Zeppelin version","body":{}}`
if res := runAnalyticsModule(t, zeppelin, 200, body); len(res.Findings) > 0 {
t.Errorf("a banner alone should not match zeppelin, got %d findings", len(res.Findings))
}
})
t.Run("a git commit id without the zeppelin banner is not flagged", func(t *testing.T) {
body := `{"git-commit-id":"a1b2c3d","name":"app"}`
if res := runAnalyticsModule(t, zeppelin, 200, body); len(res.Findings) > 0 {
t.Errorf("a commit id alone should not match zeppelin, got %d findings", len(res.Findings))
}
})
t.Run("a jupyter status without a kernels field is not flagged", func(t *testing.T) {
body := `{"started":"2024-01-01T00:00:00Z","last_activity":"2024-01-01T01:00:00Z","connections":2}`
if res := runAnalyticsModule(t, jupyter, 200, body); len(res.Findings) > 0 {
t.Errorf("a status without kernels should not match jupyter, got %d findings", len(res.Findings))
}
})
t.Run("a jupyter status without a connections field is not flagged", func(t *testing.T) {
body := `{"started":"2024-01-01T00:00:00Z","last_activity":"2024-01-01T01:00:00Z","kernels":3}`
if res := runAnalyticsModule(t, jupyter, 200, body); len(res.Findings) > 0 {
t.Errorf("a status without connections should not match jupyter, got %d findings", len(res.Findings))
}
})
t.Run("a generic version json is not an analytics service", func(t *testing.T) {
body := `{"version":"1.0.0","name":"app"}`
for _, file := range []string{metabase, zeppelin, jupyter} {
if res := runAnalyticsModule(t, file, 200, body); len(res.Findings) > 0 {
t.Errorf("%s: a generic version should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{metabase, zeppelin, jupyter} {
if res := runAnalyticsModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{metabase, zeppelin, jupyter} {
if res := runAnalyticsModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
@@ -0,0 +1,173 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runAppCfgModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func appCfgExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestAppConfigExposureModules(t *testing.T) {
const spring = "../../modules/recon/spring-application-config-exposure.yaml"
const appsettings = "../../modules/recon/appsettings-exposure.yaml"
const wpconfig = "../../modules/recon/wp-config-backup-exposure.yaml"
springProps := "spring.application.name=billing\n" +
"spring.datasource.url=jdbc:mysql://db.internal:3306/billing\n" +
"spring.datasource.username=app\nspring.datasource.password=s3cr3tP@ss\n" +
"spring.jpa.hibernate.ddl-auto=update\nserver.port=8080\n"
springYaml := "spring:\n datasource:\n url: jdbc:postgresql://pg.internal:5432/app\n" +
" username: app\n password: hunter2\nserver:\n port: 8443\n"
appSettings := `{` + "\n" +
` "Logging": { "LogLevel": { "Default": "Information" } },` + "\n" +
` "ConnectionStrings": {` + "\n" +
` "DefaultConnection": "Server=db;Database=app;User Id=sa;Password=P@ssw0rd;"` + "\n" +
` },` + "\n" +
` "AllowedHosts": "*"` + "\n}"
wpConfig := "<?php\ndefine( 'DB_NAME', 'wordpress' );\ndefine( 'DB_USER', 'wp' );\n" +
"define( 'DB_PASSWORD', 'Tr0ub4dor&3' );\ndefine( 'DB_HOST', 'localhost' );\n" +
"$table_prefix = 'wp_';\n"
t.Run("a spring properties file leaks the jdbc url", func(t *testing.T) {
res := runAppCfgModule(t, spring, 200, springProps)
if len(res.Findings) == 0 {
t.Fatal("expected a spring config finding")
}
if v := appCfgExtract(res, "jdbc_url"); v != "jdbc:mysql://db.internal:3306/billing" {
t.Errorf("jdbc_url=%q, want the mysql url", v)
}
})
t.Run("a spring yaml file also matches and names the jdbc url", func(t *testing.T) {
res := runAppCfgModule(t, spring, 200, springYaml)
if len(res.Findings) == 0 {
t.Fatal("expected a spring config finding for yaml")
}
if v := appCfgExtract(res, "jdbc_url"); v != "jdbc:postgresql://pg.internal:5432/app" {
t.Errorf("jdbc_url=%q, want the postgres url", v)
}
})
t.Run("an appsettings json leaks the connection string", func(t *testing.T) {
res := runAppCfgModule(t, appsettings, 200, appSettings)
if len(res.Findings) == 0 {
t.Fatal("expected an appsettings finding")
}
want := "Server=db;Database=app;User Id=sa;Password=P@ssw0rd;"
if v := appCfgExtract(res, "connection_string"); v != want {
t.Errorf("connection_string=%q, want %q", v, want)
}
})
t.Run("a wp-config backup leaks the database password", func(t *testing.T) {
res := runAppCfgModule(t, wpconfig, 200, wpConfig)
if len(res.Findings) == 0 {
t.Fatal("expected a wp-config finding")
}
if v := appCfgExtract(res, "db_password"); v != "Tr0ub4dor&3" {
t.Errorf("db_password=%q, want Tr0ub4dor&3", v)
}
})
t.Run("a spring config with no credential is not flagged", func(t *testing.T) {
body := "spring.application.name=app\nserver.port=8080\n"
if res := runAppCfgModule(t, spring, 200, body); len(res.Findings) > 0 {
t.Errorf("a credential-free config should not match, got %d findings", len(res.Findings))
}
})
t.Run("a spring config inside an html page is not flagged", func(t *testing.T) {
body := "<!DOCTYPE html><html><body><pre>spring.datasource.password=x</pre></body></html>"
if res := runAppCfgModule(t, spring, 200, body); len(res.Findings) > 0 {
t.Errorf("an html page should not match, got %d findings", len(res.Findings))
}
})
t.Run("an appsettings without a connection string is not flagged", func(t *testing.T) {
body := `{"Logging":{"LogLevel":{"Default":"Information"}},"AllowedHosts":"*"}`
if res := runAppCfgModule(t, appsettings, 200, body); len(res.Findings) > 0 {
t.Errorf("a config without a connection string should not match, got %d findings", len(res.Findings))
}
})
t.Run("an appsettings with no password is not a credential leak", func(t *testing.T) {
body := `{"ConnectionStrings":{"Db":"Server=db;Database=app;Integrated Security=true;"}}`
if res := runAppCfgModule(t, appsettings, 200, body); len(res.Findings) > 0 {
t.Errorf("a passwordless connection string should not match, got %d findings", len(res.Findings))
}
})
t.Run("an appsettings password outside a connection strings section is not flagged", func(t *testing.T) {
body := `{"Smtp":{"Host":"Server=mail;Password=relaypass;"}}`
if res := runAppCfgModule(t, appsettings, 200, body); len(res.Findings) > 0 {
t.Errorf("a password outside ConnectionStrings should not match, got %d findings", len(res.Findings))
}
})
t.Run("prose that names the wp-config password is not a backup", func(t *testing.T) {
body := "set the DB_PASSWORD env var before running the installer"
if res := runAppCfgModule(t, wpconfig, 200, body); len(res.Findings) > 0 {
t.Errorf("prose naming DB_PASSWORD should not match, got %d findings", len(res.Findings))
}
})
t.Run("a wp-config shown in an html page is not flagged", func(t *testing.T) {
body := "<html><head><title>setup</title></head><body>define( 'DB_PASSWORD', 'x' ); DB_NAME</body></html>"
if res := runAppCfgModule(t, wpconfig, 200, body); len(res.Findings) > 0 {
t.Errorf("an html page should not match, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{spring, appsettings, wpconfig} {
if res := runAppCfgModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{spring, appsettings, wpconfig} {
if res := runAppCfgModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
+92
View File
@@ -0,0 +1,92 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runArgocdModule(t *testing.T, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule("../../modules/recon/argocd-api-exposure.yaml")
if err != nil {
t.Fatalf("parse argocd module: %v", err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute argocd module: %v", err)
}
return res
}
func argocdExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestArgocdExposureModule(t *testing.T) {
argocdVersion := `{"Version":"v2.9.3+a1b2c3d","BuildDate":"2024-01-15T12:00:00Z","GitCommit":"a1b2c3d",` +
`"GitTreeState":"clean","GoVersion":"go1.21.5","Compiler":"gc","Platform":"linux/amd64",` +
`"KustomizeVersion":"v5.2.1 2023-10-19","HelmVersion":"v3.13.2+gadc03ef",` +
`"KubectlVersion":"v0.26.11","JsonnetVersion":"v0.20.0"}`
t.Run("an exposed argocd version endpoint is flagged and versioned", func(t *testing.T) {
res := runArgocdModule(t, 200, argocdVersion)
if len(res.Findings) == 0 {
t.Fatal("expected an argocd finding")
}
if v := argocdExtract(res, "argocd_version"); v != "v2.9.3+a1b2c3d" {
t.Errorf("argocd_version=%q, want v2.9.3+a1b2c3d", v)
}
})
t.Run("an argocd kustomize version without a helm version is not flagged", func(t *testing.T) {
body := `{"Version":"v2.9.3","KustomizeVersion":"v5.2.1 2023-10-19"}`
if res := runArgocdModule(t, 200, body); len(res.Findings) > 0 {
t.Errorf("a kustomize version alone should not match argocd, got %d findings", len(res.Findings))
}
})
t.Run("an argocd helm version without a kustomize version is not flagged", func(t *testing.T) {
body := `{"Version":"v2.9.3","HelmVersion":"v3.13.2+gadc03ef"}`
if res := runArgocdModule(t, 200, body); len(res.Findings) > 0 {
t.Errorf("a helm version alone should not match argocd, got %d findings", len(res.Findings))
}
})
t.Run("a generic version endpoint is not argocd", func(t *testing.T) {
body := `{"Version":"v1.0.0","GitCommit":"abc"}`
if res := runArgocdModule(t, 200, body); len(res.Findings) > 0 {
t.Errorf("a generic version json should not match argocd, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
if res := runArgocdModule(t, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("a plain 200 body should not match, got %d findings", len(res.Findings))
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
if res := runArgocdModule(t, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("a 404 should not match, got %d findings", len(res.Findings))
}
})
}
+148
View File
@@ -0,0 +1,148 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package modules
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"sort"
"sync"
"testing"
"github.com/dropalldatabases/sif/internal/httpx"
)
func reqURLs(reqs []*httpRequest) []string {
urls := make([]string, len(reqs))
for i, r := range reqs {
urls[i] = r.URL
}
sort.Strings(urls)
return urls
}
func TestGenerateHTTPRequestsAttack(t *testing.T) {
const target = "http://t"
paths2 := []string{"{{BaseURL}}/a?x={{payload}}", "{{BaseURL}}/b?x={{payload}}"}
pay2 := []string{"1", "2"}
cross := []string{"http://t/a?x=1", "http://t/a?x=2", "http://t/b?x=1", "http://t/b?x=2"}
paired := []string{"http://t/a?x=1", "http://t/b?x=2"}
tests := []struct {
name string
paths []string
payloads []string
attack string
want []string
}{
{"clusterbomb default crosses all", paths2, pay2, "", cross},
{"clusterbomb explicit crosses all", paths2, pay2, "clusterbomb", cross},
{"pitchfork pairs by index", paths2, pay2, "pitchfork", paired},
{"pitchfork stops at fewer payloads", append(paths2, "{{BaseURL}}/c?x={{payload}}"), pay2, "pitchfork", paired},
{"pitchfork stops at fewer paths", paths2, []string{"1", "2", "3"}, "pitchfork", paired},
{"attack is case insensitive", paths2, pay2, "Pitchfork", paired},
{"no payloads ignores attack", []string{"{{BaseURL}}/a", "{{BaseURL}}/b"}, nil, "pitchfork", []string{"http://t/a", "http://t/b"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := &HTTPConfig{Paths: tt.paths, Payloads: tt.payloads, Attack: tt.attack}
reqs, err := generateHTTPRequests(target, cfg)
if err != nil {
t.Fatalf("generateHTTPRequests: %v", err)
}
got := reqURLs(reqs)
want := append([]string(nil), tt.want...)
sort.Strings(want)
if !reflect.DeepEqual(got, want) {
t.Errorf("attack %q:\n got %v\nwant %v", tt.attack, got, want)
}
})
}
}
func TestValidateAttack(t *testing.T) {
for _, ok := range []string{"", "clusterbomb", "pitchfork", "Pitchfork", "CLUSTERBOMB"} {
if err := validateAttack(ok); err != nil {
t.Errorf("validateAttack(%q) = %v, want nil", ok, err)
}
}
for _, bad := range []string{"sniper", "batteringram", "bogus"} {
if err := validateAttack(bad); err == nil {
t.Errorf("validateAttack(%q) = nil, want error", bad)
}
}
}
func TestParseAttackValidation(t *testing.T) {
dir := t.TempDir()
write := func(name, body string) string {
p := filepath.Join(dir, name)
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
t.Fatal(err)
}
return p
}
good := write("good.yaml", "id: ok\ntype: http\nhttp:\n attack: pitchfork\n paths: [\"{{BaseURL}}/\"]\n")
if _, err := ParseYAMLModule(good); err != nil {
t.Fatalf("valid attack rejected: %v", err)
}
bad := write("bad.yaml", "id: bad\ntype: http\nhttp:\n attack: sniper\n paths: [\"{{BaseURL}}/\"]\n")
if _, err := ParseYAMLModule(bad); err == nil {
t.Fatal("invalid attack accepted")
}
}
// TestExecuteHTTPModulePitchfork drives the executor end to end and confirms
// pitchfork only fires the index-paired requests, not the full cross product.
func TestExecuteHTTPModulePitchfork(t *testing.T) {
var mu sync.Mutex
var hits []string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
hits = append(hits, r.URL.Path+"?"+r.URL.RawQuery)
mu.Unlock()
_, _ = w.Write([]byte("ok"))
}))
defer srv.Close()
def := &YAMLModule{
ID: "pf",
Type: TypeHTTP,
HTTP: &HTTPConfig{
Attack: "pitchfork",
Paths: []string{"{{BaseURL}}/a?x={{payload}}", "{{BaseURL}}/b?x={{payload}}"},
Payloads: []string{"1", "2"},
Matchers: []Matcher{{Type: "word", Part: "body", Words: []string{"ok"}}},
},
}
opts := Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)}
if _, err := ExecuteHTTPModule(context.Background(), srv.URL, def, opts); err != nil {
t.Fatalf("ExecuteHTTPModule: %v", err)
}
mu.Lock()
got := append([]string(nil), hits...)
mu.Unlock()
sort.Strings(got)
want := []string{"/a?x=1", "/b?x=2"}
if !reflect.DeepEqual(got, want) {
t.Errorf("pitchfork hit %v, want %v (clusterbomb would also hit /a?x=2 and /b?x=1)", got, want)
}
}
@@ -0,0 +1,156 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runBigDataModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func bigDataExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestBigDataAPIExposureModules(t *testing.T) {
const solr = "../../modules/recon/solr-api-exposure.yaml"
const spark = "../../modules/recon/spark-api-exposure.yaml"
const hadoop = "../../modules/recon/hadoop-yarn-api-exposure.yaml"
solrSystem := `{"responseHeader":{"status":0,"QTime":15},"mode":"std",` +
`"solr_home":"/var/solr/data","lucene":{"solr-spec-version":"9.4.0",` +
`"solr-impl-version":"9.4.0","lucene-spec-version":"9.8.0","lucene-impl-version":"9.8.0"},` +
`"jvm":{"version":"17.0.9"}}`
sparkState := `{"url":"spark://master:7077","workers":[{"id":"worker-1","host":"10.0.0.5"}],` +
`"aliveworkers":2,"cores":8,"coresused":0,"memory":15360,"activeapps":[],` +
`"completedapps":[],"status":"ALIVE"}`
hadoopInfo := `{"clusterInfo":{"id":1700000000000,"startedOn":1700000000000,"state":"STARTED",` +
`"haState":"ACTIVE","resourceManagerVersion":"3.3.6","resourceManagerBuildVersion":"3.3.6 from abc",` +
`"hadoopVersion":"3.3.6","hadoopBuildVersion":"3.3.6 from abc","hadoopVersionBuiltOn":"2023-06-18"}}`
t.Run("an exposed solr admin api is flagged and versioned", func(t *testing.T) {
res := runBigDataModule(t, solr, 200, solrSystem)
if len(res.Findings) == 0 {
t.Fatal("expected a solr finding")
}
if v := bigDataExtract(res, "solr_version"); v != "9.4.0" {
t.Errorf("solr_version=%q, want 9.4.0", v)
}
})
t.Run("an exposed spark master leaks its url", func(t *testing.T) {
res := runBigDataModule(t, spark, 200, sparkState)
if len(res.Findings) == 0 {
t.Fatal("expected a spark finding")
}
if v := bigDataExtract(res, "spark_master_url"); v != "spark://master:7077" {
t.Errorf("spark_master_url=%q, want spark://master:7077", v)
}
})
t.Run("an exposed hadoop yarn api is flagged and versioned", func(t *testing.T) {
res := runBigDataModule(t, hadoop, 200, hadoopInfo)
if len(res.Findings) == 0 {
t.Fatal("expected a hadoop finding")
}
if v := bigDataExtract(res, "hadoop_version"); v != "3.3.6" {
t.Errorf("hadoop_version=%q, want 3.3.6", v)
}
})
t.Run("a solr spec version without a solr home is not solr", func(t *testing.T) {
body := `{"lucene":{"solr-spec-version":"9.4.0"},"name":"otherservice"}`
if res := runBigDataModule(t, solr, 200, body); len(res.Findings) > 0 {
t.Errorf("spec version alone should not match solr, got %d findings", len(res.Findings))
}
})
t.Run("a solr home without a spec version is not solr", func(t *testing.T) {
body := `{"solr_home":"/var/solr/data","mode":"std"}`
if res := runBigDataModule(t, solr, 200, body); len(res.Findings) > 0 {
t.Errorf("solr home alone should not match solr, got %d findings", len(res.Findings))
}
})
t.Run("a spark url without alive workers is not flagged", func(t *testing.T) {
body := `{"url":"spark://master:7077","workers":[],"status":"ALIVE"}`
if res := runBigDataModule(t, spark, 200, body); len(res.Findings) > 0 {
t.Errorf("a spark url alone should not match, got %d findings", len(res.Findings))
}
})
t.Run("alive workers behind a non spark url is not flagged", func(t *testing.T) {
body := `{"url":"http://internal:8080","aliveworkers":2}`
if res := runBigDataModule(t, spark, 200, body); len(res.Findings) > 0 {
t.Errorf("a non spark url should not match, got %d findings", len(res.Findings))
}
})
t.Run("a cluster info without a resource manager version is not hadoop", func(t *testing.T) {
body := `{"clusterInfo":{"id":1,"state":"STARTED","hadoopVersion":"3.3.6"}}`
if res := runBigDataModule(t, hadoop, 200, body); len(res.Findings) > 0 {
t.Errorf("cluster info alone should not match hadoop, got %d findings", len(res.Findings))
}
})
t.Run("a resource manager version without a cluster info is not hadoop", func(t *testing.T) {
body := `{"resourceManagerVersion":"3.3.6","app":"custom"}`
if res := runBigDataModule(t, hadoop, 200, body); len(res.Findings) > 0 {
t.Errorf("rm version alone should not match hadoop, got %d findings", len(res.Findings))
}
})
t.Run("a generic json endpoint is not a spark master", func(t *testing.T) {
body := `{"url":"http://app","workers":5,"name":"myservice"}`
if res := runBigDataModule(t, spark, 200, body); len(res.Findings) > 0 {
t.Errorf("a generic json should not match spark, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{solr, spark, hadoop} {
if res := runBigDataModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{solr, spark, hadoop} {
if res := runBigDataModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
@@ -0,0 +1,198 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runBuildCredModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func buildCredExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestBuildToolCredentialExposureModules(t *testing.T) {
const maven = "../../modules/recon/maven-settings-exposure.yaml"
const gradle = "../../modules/recon/gradle-properties-exposure.yaml"
const nuget = "../../modules/recon/nuget-config-exposure.yaml"
mavenSettings := "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" +
"<settings xmlns=\"http://maven.apache.org/SETTINGS/1.0.0\">\n" +
" <servers>\n <server>\n <id>nexus-releases</id>\n" +
" <username>deploy</username>\n <password>S3cretDeployPass</password>\n" +
" </server>\n </servers>\n</settings>\n"
gradleProps := "org.gradle.jvmargs=-Xmx2g\nossrhUsername=deployer\n" +
"ossrhPassword=mySonatypeSecret\nsigning.password=mySigningSecret\n"
nugetConfig := "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<configuration>\n" +
" <packageSourceCredentials>\n <MyFeed>\n" +
" <add key=\"Username\" value=\"deploy\" />\n" +
" <add key=\"ClearTextPassword\" value=\"S3cretFeedPass\" />\n" +
" </MyFeed>\n </packageSourceCredentials>\n</configuration>\n"
t.Run("an exposed maven settings leaks the server username", func(t *testing.T) {
res := runBuildCredModule(t, maven, 200, mavenSettings)
if len(res.Findings) == 0 {
t.Fatal("expected a maven finding")
}
if v := buildCredExtract(res, "maven_username"); v != "deploy" {
t.Errorf("maven_username=%q, want deploy", v)
}
})
t.Run("an exposed gradle properties leaks the secret property", func(t *testing.T) {
res := runBuildCredModule(t, gradle, 200, gradleProps)
if len(res.Findings) == 0 {
t.Fatal("expected a gradle finding")
}
if v := buildCredExtract(res, "gradle_secret_property"); v != "ossrhPassword" {
t.Errorf("gradle_secret_property=%q, want ossrhPassword", v)
}
})
t.Run("an exposed nuget config leaks the feed username", func(t *testing.T) {
res := runBuildCredModule(t, nuget, 200, nugetConfig)
if len(res.Findings) == 0 {
t.Fatal("expected a nuget finding")
}
if v := buildCredExtract(res, "nuget_username"); v != "deploy" {
t.Errorf("nuget_username=%q, want deploy", v)
}
})
t.Run("a maven settings with mirrors but no password is not flagged", func(t *testing.T) {
body := "<settings>\n <mirrors>\n <mirror>\n <id>central</id>\n" +
" <url>https://repo.example.com/maven2</url>\n </mirror>\n </mirrors>\n</settings>\n"
if res := runBuildCredModule(t, maven, 200, body); len(res.Findings) > 0 {
t.Errorf("a settings without a password should not match, got %d findings", len(res.Findings))
}
})
t.Run("an html page demonstrating a maven settings is not a leak", func(t *testing.T) {
body := "<!DOCTYPE html><html><body><pre><settings><server><password>x</password></server></settings></pre></body></html>"
if res := runBuildCredModule(t, maven, 200, body); len(res.Findings) > 0 {
t.Errorf("an html maven tutorial should not match, got %d findings", len(res.Findings))
}
})
t.Run("a gradle properties with no credential property is not flagged", func(t *testing.T) {
body := "org.gradle.jvmargs=-Xmx2g\nversion=1.0.0\norg.gradle.daemon=true\n"
if res := runBuildCredModule(t, gradle, 200, body); len(res.Findings) > 0 {
t.Errorf("a non credential properties file should not match, got %d findings", len(res.Findings))
}
})
t.Run("a comment naming a password is not a credential property", func(t *testing.T) {
body := "# set your password=here before building\norg.gradle.daemon=true\n"
if res := runBuildCredModule(t, gradle, 200, body); len(res.Findings) > 0 {
t.Errorf("a comment line should not match, got %d findings", len(res.Findings))
}
})
t.Run("an empty password property is not flagged", func(t *testing.T) {
body := "signing.password=\nsigning.keyId=24875D73\n"
if res := runBuildCredModule(t, gradle, 200, body); len(res.Findings) > 0 {
t.Errorf("an empty value should not match, got %d findings", len(res.Findings))
}
})
t.Run("an html page demonstrating a gradle property is not a leak", func(t *testing.T) {
body := "<!DOCTYPE html>\n<html><body><pre>\nossrhPassword=mySonatypeSecret\n</pre></body></html>\n"
if res := runBuildCredModule(t, gradle, 200, body); len(res.Findings) > 0 {
t.Errorf("an html gradle tutorial should not match, got %d findings", len(res.Findings))
}
})
t.Run("a nuget config without a credentials section is not flagged", func(t *testing.T) {
body := "<configuration>\n <packageSources>\n" +
" <add key=\"nuget.org\" value=\"https://api.nuget.org/v3/index.json\" />\n" +
" </packageSources>\n</configuration>\n"
if res := runBuildCredModule(t, nuget, 200, body); len(res.Findings) > 0 {
t.Errorf("a config without credentials should not match, got %d findings", len(res.Findings))
}
})
t.Run("a nuget credentials section without a password is not flagged", func(t *testing.T) {
body := "<configuration>\n <packageSourceCredentials>\n <MyFeed>\n" +
" <add key=\"Username\" value=\"deploy\" />\n" +
" </MyFeed>\n </packageSourceCredentials>\n</configuration>\n"
if res := runBuildCredModule(t, nuget, 200, body); len(res.Findings) > 0 {
t.Errorf("a credentials section without a password should not match, got %d findings", len(res.Findings))
}
})
t.Run("an appsettings password is not a nuget feed credential", func(t *testing.T) {
body := "<configuration>\n <appSettings>\n" +
" <add key=\"Password\" value=\"appsecret\" />\n" +
" </appSettings>\n</configuration>\n"
if res := runBuildCredModule(t, nuget, 200, body); len(res.Findings) > 0 {
t.Errorf("an appsettings password should not match, got %d findings", len(res.Findings))
}
})
t.Run("an html page demonstrating a nuget config is not a leak", func(t *testing.T) {
body := "<!DOCTYPE html><html><body><pre><packageSourceCredentials><add key=\"ClearTextPassword\" value=\"x\" /></packageSourceCredentials></pre></body></html>"
if res := runBuildCredModule(t, nuget, 200, body); len(res.Findings) > 0 {
t.Errorf("an html nuget tutorial should not match, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{maven, gradle, nuget} {
if res := runBuildCredModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{maven, gradle, nuget} {
if res := runBuildCredModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
@@ -0,0 +1,205 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runCMSCfgModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func cmsCfgExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestCMSConfigExposureModules(t *testing.T) {
const joomla = "../../modules/recon/joomla-config-exposure.yaml"
const drupal = "../../modules/recon/drupal-config-exposure.yaml"
const magento = "../../modules/recon/magento-config-exposure.yaml"
joomlaConfig := "<?php\nclass JConfig {\n\tpublic $offline = '0';\n" +
"\tpublic $host = 'localhost';\n\tpublic $user = 'joomla_user';\n" +
"\tpublic $password = 'S3cretJoomlaPass';\n\tpublic $db = 'joomla_db';\n" +
"\tpublic $dbprefix = 'jos_';\n\tpublic $secret = 'AbCdEfGhIjKlMnOp';\n}\n"
drupalConfig := "<?php\n$databases['default']['default'] = array (\n" +
" 'database' => 'drupal_db',\n 'username' => 'drupal_user',\n" +
" 'password' => 'S3cretDrupalPass',\n 'host' => 'localhost',\n" +
" 'driver' => 'mysql',\n);\n$settings['hash_salt'] = 'longrandomhashsalt';\n"
magentoConfig := "<?php\nreturn [\n 'backend' => ['frontName' => 'admin_x7y'],\n" +
" 'crypt' => ['key' => 'a1b2c3d4e5f6g7h8'],\n 'db' => [\n" +
" 'connection' => ['default' => [\n 'host' => 'localhost',\n" +
" 'dbname' => 'magento',\n 'username' => 'magento_user',\n" +
" 'password' => 'S3cretMagentoPass',\n ]],\n ],\n 'MAGE_MODE' => 'production',\n];\n"
t.Run("an exposed joomla configuration leaks the password", func(t *testing.T) {
res := runCMSCfgModule(t, joomla, 200, joomlaConfig)
if len(res.Findings) == 0 {
t.Fatal("expected a joomla finding")
}
if v := cmsCfgExtract(res, "joomla_password"); v != "S3cretJoomlaPass" {
t.Errorf("joomla_password=%q, want S3cretJoomlaPass", v)
}
})
t.Run("an exposed drupal settings leaks the password", func(t *testing.T) {
res := runCMSCfgModule(t, drupal, 200, drupalConfig)
if len(res.Findings) == 0 {
t.Fatal("expected a drupal finding")
}
if v := cmsCfgExtract(res, "drupal_password"); v != "S3cretDrupalPass" {
t.Errorf("drupal_password=%q, want S3cretDrupalPass", v)
}
})
t.Run("an exposed magento env leaks the crypt key", func(t *testing.T) {
res := runCMSCfgModule(t, magento, 200, magentoConfig)
if len(res.Findings) == 0 {
t.Fatal("expected a magento finding")
}
if v := cmsCfgExtract(res, "magento_crypt_key"); v != "a1b2c3d4e5f6g7h8" {
t.Errorf("magento_crypt_key=%q, want a1b2c3d4e5f6g7h8", v)
}
})
t.Run("a joomla config missing the password is not flagged", func(t *testing.T) {
body := "<?php\nclass JConfig {\n\tpublic $host = 'localhost';\n" +
"\tpublic $db = 'joomla_db';\n\tpublic $dbprefix = 'jos_';\n}\n"
if res := runCMSCfgModule(t, joomla, 200, body); len(res.Findings) > 0 {
t.Errorf("a config without a password should not match, got %d findings", len(res.Findings))
}
})
t.Run("a php class with a public password but no jconfig is not joomla", func(t *testing.T) {
body := "<?php\nclass MyAuth {\n\tpublic $password = 'changeme';\n" +
"\tpublic $username = 'admin';\n}\n"
if res := runCMSCfgModule(t, joomla, 200, body); len(res.Findings) > 0 {
t.Errorf("a generic class should not match joomla, got %d findings", len(res.Findings))
}
})
t.Run("a php array with a password but no databases is not drupal", func(t *testing.T) {
body := "<?php\n$config = array('password' => 'x', 'host' => 'y');\n"
if res := runCMSCfgModule(t, drupal, 200, body); len(res.Findings) > 0 {
t.Errorf("a generic array should not match drupal, got %d findings", len(res.Findings))
}
})
t.Run("a drupal databases array without a password is not flagged", func(t *testing.T) {
body := "<?php\n$databases['default']['default'] = array (\n" +
" 'database' => 'drupal_db',\n 'host' => 'localhost',\n 'driver' => 'mysql',\n);\n"
if res := runCMSCfgModule(t, drupal, 200, body); len(res.Findings) > 0 {
t.Errorf("a databases array without a password should not match, got %d findings", len(res.Findings))
}
})
t.Run("a php return array with a password but no magento markers is not flagged", func(t *testing.T) {
body := "<?php\nreturn ['db' => ['password' => 'secret', 'host' => 'localhost']];\n"
if res := runCMSCfgModule(t, magento, 200, body); len(res.Findings) > 0 {
t.Errorf("a generic return array should not match magento, got %d findings", len(res.Findings))
}
})
t.Run("a magento config without a credential is not flagged", func(t *testing.T) {
body := "<?php\nreturn ['MAGE_MODE' => 'production', 'db' => ['host' => 'localhost']];\n"
if res := runCMSCfgModule(t, magento, 200, body); len(res.Findings) > 0 {
t.Errorf("a magento config without a credential should not match, got %d findings", len(res.Findings))
}
})
t.Run("an html page demonstrating a joomla config is not a leak", func(t *testing.T) {
body := "<!DOCTYPE html><html><body><pre>class JConfig { public $password = 'x'; public $db = 'y'; }</pre></body></html>"
if res := runCMSCfgModule(t, joomla, 200, body); len(res.Findings) > 0 {
t.Errorf("an html joomla tutorial should not match, got %d findings", len(res.Findings))
}
})
t.Run("a drupal settings using env indirection is not a literal password leak", func(t *testing.T) {
body := "<?php\n$databases['default']['default'] = array (\n" +
" 'database' => 'drupal_db',\n 'username' => 'drupal_user',\n" +
" 'password' => getenv('DB_PASS'),\n 'host' => 'localhost',\n);\n"
if res := runCMSCfgModule(t, drupal, 200, body); len(res.Findings) > 0 {
t.Errorf("env indirection should not match, got %d findings", len(res.Findings))
}
})
t.Run("a magento env with a cloud placeholder key is not a literal leak", func(t *testing.T) {
body := "<?php\nreturn ['crypt' => ['key' => '#env.CRYPT_KEY#'], 'MAGE_MODE' => 'production'];\n"
if res := runCMSCfgModule(t, magento, 200, body); len(res.Findings) > 0 {
t.Errorf("a cloud placeholder should not match, got %d findings", len(res.Findings))
}
})
t.Run("a magento env with a placeholder key but a literal password is flagged not mis-extracted", func(t *testing.T) {
body := "<?php\nreturn ['crypt' => ['key' => '#env.CRYPT_KEY#'],\n" +
" 'db' => ['connection' => ['default' => ['password' => 'RealDbPass']]],\n" +
" 'MAGE_MODE' => 'production'];\n"
res := runCMSCfgModule(t, magento, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a magento finding on the literal password")
}
if v := cmsCfgExtract(res, "magento_crypt_key"); v == "#env.CRYPT_KEY#" {
t.Errorf("extractor surfaced the placeholder %q as the crypt key", v)
}
})
t.Run("an html page demonstrating a drupal config is not a leak", func(t *testing.T) {
body := "<!DOCTYPE html><html><body><pre>$databases['default']['default'] = array('password' => 'x');</pre></body></html>"
if res := runCMSCfgModule(t, drupal, 200, body); len(res.Findings) > 0 {
t.Errorf("an html drupal tutorial should not match, got %d findings", len(res.Findings))
}
})
t.Run("an html page demonstrating a magento config is not a leak", func(t *testing.T) {
body := "<!DOCTYPE html><html><body><pre>'crypt' => ['key' => 'x'], 'MAGE_MODE' => 'production'</pre></body></html>"
if res := runCMSCfgModule(t, magento, 200, body); len(res.Findings) > 0 {
t.Errorf("an html magento tutorial should not match, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{joomla, drupal, magento} {
if res := runCMSCfgModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{joomla, drupal, magento} {
if res := runCMSCfgModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
@@ -0,0 +1,113 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runCredModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func credExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestCredentialExposureModules(t *testing.T) {
const aws = "../../modules/recon/aws-credentials-exposure.yaml"
const npmrc = "../../modules/recon/npmrc-exposure.yaml"
const docker = "../../modules/recon/docker-config-exposure.yaml"
t.Run("aws credentials leak the access key id", func(t *testing.T) {
body := "[default]\naws_access_key_id = AKIAIOSFODNN7EXAMPLE\n" +
"aws_secret_access_key = wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY\n"
res := runCredModule(t, aws, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected an aws credentials finding")
}
if v := credExtract(res, "aws_access_key_id"); v != "AKIAIOSFODNN7EXAMPLE" {
t.Errorf("aws_access_key_id=%q, want AKIAIOSFODNN7EXAMPLE", v)
}
})
t.Run("npmrc leaks the registry of an auth token", func(t *testing.T) {
body := "//registry.npmjs.org/:_authToken=npm_AbCdEf0123456789AbCdEf0123456789\n"
res := runCredModule(t, npmrc, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected an npmrc finding")
}
if v := credExtract(res, "npm_registry"); v != "registry.npmjs.org" {
t.Errorf("npm_registry=%q, want registry.npmjs.org", v)
}
})
t.Run("docker config leaks the registry host", func(t *testing.T) {
body := `{"auths":{"registry.example.com":{"auth":"dXNlcm5hbWU6c3VwZXJzZWNyZXRwYXNz"}}}`
res := runCredModule(t, docker, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a docker config finding")
}
if v := credExtract(res, "docker_registry"); v != "registry.example.com" {
t.Errorf("docker_registry=%q, want registry.example.com", v)
}
})
t.Run("html page mentioning the key name is not a leak", func(t *testing.T) {
body := `<html><head><title>Docs</title></head><body>` +
`set your aws_secret_access_key in ~/.aws/credentials</body></html>`
if res := runCredModule(t, aws, 200, body); len(res.Findings) > 0 {
t.Errorf("an html doc mentioning the key should not match, got %d findings", len(res.Findings))
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{aws, npmrc, docker} {
if res := runCredModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{aws, npmrc, docker} {
if res := runCredModule(t, file, 200, "nothing to see here"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a docker auth field holding a jwt is not a leak", func(t *testing.T) {
body := `{"token":"x","auth":"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjMifQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"}`
if res := runCredModule(t, docker, 200, body); len(res.Findings) > 0 {
t.Errorf("a jwt in an auth field should not match, got %d findings", len(res.Findings))
}
})
}
@@ -0,0 +1,151 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runPipelineModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func pipelineExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestDataPipelineAPIExposureModules(t *testing.T) {
const airflow = "../../modules/recon/airflow-api-exposure.yaml"
const flink = "../../modules/recon/flink-api-exposure.yaml"
const kafka = "../../modules/recon/kafka-connect-api-exposure.yaml"
airflowHealth := `{"metadatabase":{"status":"healthy"},"scheduler":{"status":"healthy",` +
`"latest_scheduler_heartbeat":"2023-09-13T09:35:49.123456+00:00"}}`
flinkOverview := `{"taskmanagers":1,"slots-total":4,"slots-available":4,"jobs-running":0,` +
`"jobs-finished":2,"jobs-cancelled":0,"jobs-failed":0,"flink-version":"1.17.1","flink-commit":"2750d5c"}`
kafkaConnect := `{"version":"3.5.0","commit":"c97b88d5db4de28d","kafka_cluster_id":"M_oad8FjQ1eMShri6_jjQg"}`
t.Run("an exposed airflow health endpoint is flagged", func(t *testing.T) {
res := runPipelineModule(t, airflow, 200, airflowHealth)
if len(res.Findings) == 0 {
t.Fatal("expected an airflow finding")
}
if v := pipelineExtract(res, "airflow_scheduler_heartbeat"); v != "2023-09-13T09:35:49.123456+00:00" {
t.Errorf("airflow_scheduler_heartbeat=%q, want the heartbeat timestamp", v)
}
})
t.Run("an exposed flink dashboard is flagged and versioned", func(t *testing.T) {
res := runPipelineModule(t, flink, 200, flinkOverview)
if len(res.Findings) == 0 {
t.Fatal("expected a flink finding")
}
if v := pipelineExtract(res, "flink_version"); v != "1.17.1" {
t.Errorf("flink_version=%q, want 1.17.1", v)
}
})
t.Run("an exposed kafka connect api is flagged and versioned", func(t *testing.T) {
res := runPipelineModule(t, kafka, 200, kafkaConnect)
if len(res.Findings) == 0 {
t.Fatal("expected a kafka connect finding")
}
if v := pipelineExtract(res, "kafka_version"); v != "3.5.0" {
t.Errorf("kafka_version=%q, want 3.5.0", v)
}
})
t.Run("an airflow metadatabase without a scheduler is not flagged", func(t *testing.T) {
body := `{"metadatabase":{"status":"healthy"}}`
if res := runPipelineModule(t, airflow, 200, body); len(res.Findings) > 0 {
t.Errorf("metadatabase alone should not match airflow, got %d findings", len(res.Findings))
}
})
t.Run("an airflow scheduler without a metadatabase is not flagged", func(t *testing.T) {
body := `{"scheduler":{"status":"healthy","latest_scheduler_heartbeat":"2023-09-13T09:35:49.123456+00:00"}}`
if res := runPipelineModule(t, airflow, 200, body); len(res.Findings) > 0 {
t.Errorf("scheduler alone should not match airflow, got %d findings", len(res.Findings))
}
})
t.Run("a flink version without a slot total is not flagged", func(t *testing.T) {
body := `{"flink-version":"1.17.1","taskmanagers":1}`
if res := runPipelineModule(t, flink, 200, body); len(res.Findings) > 0 {
t.Errorf("flink version alone should not match flink, got %d findings", len(res.Findings))
}
})
t.Run("a slot total without a flink version is not flagged", func(t *testing.T) {
body := `{"slots-total":4,"jobs-running":0}`
if res := runPipelineModule(t, flink, 200, body); len(res.Findings) > 0 {
t.Errorf("a slot total alone should not match flink, got %d findings", len(res.Findings))
}
})
t.Run("a kafka cluster id without a version is not flagged", func(t *testing.T) {
body := `{"kafka_cluster_id":"M_oad8FjQ1eMShri6_jjQg","commit":"abc"}`
if res := runPipelineModule(t, kafka, 200, body); len(res.Findings) > 0 {
t.Errorf("a cluster id alone should not match kafka connect, got %d findings", len(res.Findings))
}
})
t.Run("a version without a kafka cluster id is not flagged", func(t *testing.T) {
body := `{"version":"3.5.0","name":"someservice"}`
if res := runPipelineModule(t, kafka, 200, body); len(res.Findings) > 0 {
t.Errorf("a version alone should not match kafka connect, got %d findings", len(res.Findings))
}
})
t.Run("a generic health json is not airflow", func(t *testing.T) {
body := `{"status":"UP","components":{"db":{"status":"UP"}}}`
if res := runPipelineModule(t, airflow, 200, body); len(res.Findings) > 0 {
t.Errorf("a generic health should not match airflow, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{airflow, flink, kafka} {
if res := runPipelineModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{airflow, flink, kafka} {
if res := runPipelineModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
@@ -0,0 +1,166 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runDBFileModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func dbFileExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestDatabaseFileExposureModules(t *testing.T) {
const sqlDump = "../../modules/recon/sql-dump-exposure.yaml"
const sqlite = "../../modules/recon/sqlite-database-exposure.yaml"
const redis = "../../modules/recon/redis-dump-exposure.yaml"
mysqldump := "-- MySQL dump 10.13 Distrib 8.0.32, for Linux (x86_64)\n--\n" +
"-- Host: localhost Database: appdb\n--\n-- Server version\t8.0.32\n\n" +
"DROP TABLE IF EXISTS `users`;\nCREATE TABLE `users` (\n" +
" `id` int NOT NULL AUTO_INCREMENT,\n `email` varchar(255) DEFAULT NULL,\n" +
" PRIMARY KEY (`id`)\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;\n" +
"INSERT INTO `users` VALUES (1,'admin@x.com');\n"
pgdump := "--\n-- PostgreSQL database dump\n--\n\nSET statement_timeout = 0;\n" +
"CREATE TABLE public.accounts (\n id integer NOT NULL,\n email text\n);\n" +
"COPY public.accounts (id, email) FROM stdin;\n1\tadmin@x.com\n\\.\n"
sqliteFile := "SQLite format 3\x00" + strings.Repeat("\x00", 84) +
"\x05\x00CREATE TABLE users(id INTEGER PRIMARY KEY, email TEXT, password TEXT)\x00"
redisDump := "REDIS0011\xfa\x09redis-ver\x055.0.7\xfa\x0aredis-bits\xc0@\xfe\x00\xfb\x02\x00" +
"\x03key\x05value\xff\x00\x00\x00\x00\x00\x00\x00\x00"
t.Run("a mysqldump leaks the dumped table", func(t *testing.T) {
res := runDBFileModule(t, sqlDump, 200, mysqldump)
if len(res.Findings) == 0 {
t.Fatal("expected a sql dump finding")
}
if v := dbFileExtract(res, "dump_table"); v != "users" {
t.Errorf("dump_table=%q, want users", v)
}
})
t.Run("a postgresql dump also matches and names its table", func(t *testing.T) {
res := runDBFileModule(t, sqlDump, 200, pgdump)
if len(res.Findings) == 0 {
t.Fatal("expected a sql dump finding for pg_dump")
}
if v := dbFileExtract(res, "dump_table"); v != "accounts" {
t.Errorf("dump_table=%q, want accounts", v)
}
})
t.Run("a sqlite database file leaks its schema table", func(t *testing.T) {
res := runDBFileModule(t, sqlite, 200, sqliteFile)
if len(res.Findings) == 0 {
t.Fatal("expected a sqlite finding")
}
if v := dbFileExtract(res, "table_name"); v != "users" {
t.Errorf("table_name=%q, want users", v)
}
})
t.Run("a redis rdb snapshot leaks its format version", func(t *testing.T) {
res := runDBFileModule(t, redis, 200, redisDump)
if len(res.Findings) == 0 {
t.Fatal("expected a redis rdb finding")
}
if v := dbFileExtract(res, "rdb_version"); v != "0011" {
t.Errorf("rdb_version=%q, want 0011", v)
}
})
t.Run("sql shown inside an html page is not a dump", func(t *testing.T) {
body := "<!DOCTYPE html><html><head><title>SQL tutorial</title></head><body>" +
"<pre>DROP TABLE IF EXISTS users; CREATE TABLE users (id int); INSERT INTO users VALUES (1);</pre>" +
"</body></html>"
if res := runDBFileModule(t, sqlDump, 200, body); len(res.Findings) > 0 {
t.Errorf("an html tutorial should not match, got %d findings", len(res.Findings))
}
})
t.Run("a sql file with no dump idiom is not flagged", func(t *testing.T) {
body := "-- migration notes\nSELECT id FROM users WHERE active = 1;\n"
if res := runDBFileModule(t, sqlDump, 200, body); len(res.Findings) > 0 {
t.Errorf("a bare select should not match, got %d findings", len(res.Findings))
}
})
t.Run("a page that names the sqlite format is not the file", func(t *testing.T) {
body := "This page documents the SQLite format 3 on-disk structure for readers."
if res := runDBFileModule(t, sqlite, 200, body); len(res.Findings) > 0 {
t.Errorf("prose about sqlite should not match, got %d findings", len(res.Findings))
}
})
t.Run("a page that names redis is not an rdb snapshot", func(t *testing.T) {
body := "redis-server is running on this host as the REDIS cache backend."
if res := runDBFileModule(t, redis, 200, body); len(res.Findings) > 0 {
t.Errorf("prose about redis should not match, got %d findings", len(res.Findings))
}
})
t.Run("the sqlite magic only counts at the start of the file", func(t *testing.T) {
body := "<pre>hexdump of a header: " + sqliteFile + "</pre>"
if res := runDBFileModule(t, sqlite, 200, body); len(res.Findings) > 0 {
t.Errorf("an embedded sqlite header should not match, got %d findings", len(res.Findings))
}
})
t.Run("the rdb magic only counts at the start of the file", func(t *testing.T) {
body := "log line: loaded snapshot " + redisDump
if res := runDBFileModule(t, redis, 200, body); len(res.Findings) > 0 {
t.Errorf("an embedded rdb header should not match, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{sqlDump, sqlite, redis} {
if res := runDBFileModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{sqlDump, sqlite, redis} {
if res := runDBFileModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
+88
View File
@@ -0,0 +1,88 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runDBModule(t *testing.T, file string, status int, headers map[string]string, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for k, v := range headers {
w.Header().Set(k, v)
}
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func dbExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestDBPanelModules(t *testing.T) {
const adminer = "../../modules/info/adminer-panel.yaml"
const phpmyadmin = "../../modules/info/phpmyadmin-panel.yaml"
adminerLogin := `<form action=""><input type="hidden" name="auth[driver]" value="server">` +
`<input name="auth[username]"></form>` +
`<p class="links"><a href="https://www.adminer.org/">Adminer</a> <span class="version">4.8.1</span></p>`
pmaLogin := `<link rel="stylesheet" href="themes/pmahomme/css/theme.css">` +
`<input type="text" name="pma_username"><script>var data = {"PMA_VERSION":"5.2.1"};</script>`
t.Run("adminer login", func(t *testing.T) {
res := runDBModule(t, adminer, 200, nil, adminerLogin)
if len(res.Findings) == 0 {
t.Fatal("expected an adminer finding")
}
if v := dbExtract(res, "adminer_version"); v != "4.8.1" {
t.Errorf("adminer_version=%q, want 4.8.1", v)
}
})
t.Run("adminer unrelated page", func(t *testing.T) {
if res := runDBModule(t, adminer, 200, nil, "<html><body>nothing</body></html>"); len(res.Findings) > 0 {
t.Errorf("unrelated page should not match, got %d findings", len(res.Findings))
}
})
t.Run("phpmyadmin login", func(t *testing.T) {
res := runDBModule(t, phpmyadmin, 200, map[string]string{"Set-Cookie": "phpMyAdmin=abc123; path=/"}, pmaLogin)
if len(res.Findings) == 0 {
t.Fatal("expected a phpmyadmin finding")
}
if v := dbExtract(res, "phpmyadmin_version"); v != "5.2.1" {
t.Errorf("phpmyadmin_version=%q, want 5.2.1", v)
}
})
t.Run("phpmyadmin unrelated page", func(t *testing.T) {
if res := runDBModule(t, phpmyadmin, 200, nil, "<html><body>nothing</body></html>"); len(res.Findings) > 0 {
t.Errorf("unrelated page should not match, got %d findings", len(res.Findings))
}
})
}
+121
View File
@@ -0,0 +1,121 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runDebugModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func debugExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestDebugExposureModules(t *testing.T) {
const ignition = "../../modules/recon/laravel-ignition-exposure.yaml"
const profiler = "../../modules/recon/symfony-profiler-exposure.yaml"
const heapdump = "../../modules/recon/spring-heapdump-exposure.yaml"
t.Run("ignition health check exposes command execution", func(t *testing.T) {
res := runDebugModule(t, ignition, 200, `{"can_execute_commands":true,"config":{}}`)
if len(res.Findings) == 0 {
t.Fatal("expected an ignition finding")
}
if v := debugExtract(res, "can_execute_commands"); v != "true" {
t.Errorf("can_execute_commands=%q, want true", v)
}
})
t.Run("ignition exposed with debug off still flags and extracts false", func(t *testing.T) {
res := runDebugModule(t, ignition, 200, `{"can_execute_commands":false}`)
if len(res.Findings) == 0 {
t.Fatal("expected an ignition finding even when command execution is off")
}
if v := debugExtract(res, "can_execute_commands"); v != "false" {
t.Errorf("can_execute_commands=%q, want false", v)
}
})
t.Run("symfony profiler exposes a request token", func(t *testing.T) {
body := `<html><head><title>Symfony Profiler</title></head><body>` +
`<a href="/_profiler/5f3a2b">GET /</a></body></html>`
res := runDebugModule(t, profiler, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a symfony profiler finding")
}
if v := debugExtract(res, "profiler_token"); v != "5f3a2b" {
t.Errorf("profiler_token=%q, want 5f3a2b", v)
}
})
t.Run("spring heap dump exposes the hprof magic", func(t *testing.T) {
body := "JAVA PROFILE 1.0.2\x00\x00\x00\x08heap bytes follow"
res := runDebugModule(t, heapdump, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a heap dump finding")
}
if v := debugExtract(res, "hprof_version"); v != "1.0.2" {
t.Errorf("hprof_version=%q, want 1.0.2", v)
}
})
t.Run("the hprof magic must be at the start not merely present", func(t *testing.T) {
body := "<html><body>docs about the JAVA PROFILE 1.0.2 hprof header</body></html>"
if res := runDebugModule(t, heapdump, 200, body); len(res.Findings) > 0 {
t.Errorf("the magic away from the start should not match, got %d findings", len(res.Findings))
}
})
t.Run("a page that only names ignition is not the endpoint", func(t *testing.T) {
body := `<html><body>we use ignition to render errors in development</body></html>`
if res := runDebugModule(t, ignition, 200, body); len(res.Findings) > 0 {
t.Errorf("a prose mention should not match, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{ignition, profiler, heapdump} {
if res := runDebugModule(t, file, 200, "<html><body>plain</body></html>"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{ignition, profiler, heapdump} {
if res := runDebugModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
@@ -0,0 +1,134 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runDeployModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func deployExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestDeployConfigExposureModules(t *testing.T) {
const vscode = "../../modules/recon/vscode-sftp-exposure.yaml"
const sublime = "../../modules/recon/sublime-sftp-exposure.yaml"
const ftpconfig = "../../modules/recon/ftpconfig-exposure.yaml"
t.Run("vscode sftp config leaks the deploy host", func(t *testing.T) {
body := `{"name":"prod","host":"deploy.example.com","protocol":"sftp",` +
`"username":"root","password":"s3cr3t","remotePath":"/var/www","uploadOnSave":true}`
res := runDeployModule(t, vscode, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a vscode sftp finding")
}
if v := deployExtract(res, "remote_host"); v != "deploy.example.com" {
t.Errorf("remote_host=%q, want deploy.example.com", v)
}
})
t.Run("vscode sftp config with key auth still flags and extracts the host", func(t *testing.T) {
body := `{"host":"key.example.com","protocol":"sftp",` +
`"username":"deploy","privateKeyPath":"~/.ssh/id_rsa","uploadOnSave":true}`
res := runDeployModule(t, vscode, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a vscode sftp finding for a key-auth config")
}
if v := deployExtract(res, "remote_host"); v != "key.example.com" {
t.Errorf("remote_host=%q, want key.example.com", v)
}
})
t.Run("sublime sftp config leaks the deploy host", func(t *testing.T) {
body := `{"type":"sftp","host":"sftp.example.org","user":"www","password":"hunter2",` +
`"remote_path":"/srv","upload_on_save":true,"sync_down_on_open":false}`
res := runDeployModule(t, sublime, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a sublime sftp finding")
}
if v := deployExtract(res, "remote_host"); v != "sftp.example.org" {
t.Errorf("remote_host=%q, want sftp.example.org", v)
}
})
t.Run("atom remote-ftp config leaks the deploy host", func(t *testing.T) {
body := `{"protocol":"ftp","host":"ftp.example.net","port":21,"user":"upload",` +
`"pass":"letmein","remote":"/","connTimeout":10000,"pasvTimeout":10000}`
res := runDeployModule(t, ftpconfig, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected an atom remote-ftp finding")
}
if v := deployExtract(res, "remote_host"); v != "ftp.example.net" {
t.Errorf("remote_host=%q, want ftp.example.net", v)
}
})
t.Run("an html login page carrying the same keys is not a leak", func(t *testing.T) {
body := `<html><head><title>Sign in</title></head><body>` +
`config keys "remotePath" "password" "host":"evil.example.com"</body></html>`
if res := runDeployModule(t, vscode, 200, body); len(res.Findings) > 0 {
t.Errorf("an html page should not match, got %d findings", len(res.Findings))
}
})
t.Run("a plain json config without the tool keys is not a leak", func(t *testing.T) {
body := `{"host":"db.internal","username":"admin","user":"admin","pass":"x","password":"hunter2"}`
for _, file := range []string{vscode, sublime, ftpconfig} {
if res := runDeployModule(t, file, 200, body); len(res.Findings) > 0 {
t.Errorf("%s: a config without the tool keys should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a tool config with a host but no credential field is not a leak", func(t *testing.T) {
bodies := map[string]string{
vscode: `{"host":"h.example.com","remotePath":"/var/www","uploadOnSave":true}`,
sublime: `{"type":"sftp","host":"h.example.com","upload_on_save":true}`,
ftpconfig: `{"protocol":"ftp","host":"h.example.com","connTimeout":10000,"pasvTimeout":10000}`,
}
for file, body := range bodies {
if res := runDeployModule(t, file, 200, body); len(res.Findings) > 0 {
t.Errorf("%s: a config with no credential field should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{vscode, sublime, ftpconfig} {
if res := runDeployModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
@@ -0,0 +1,159 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runDistDBModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func distDBExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestDistributedDBExposureModules(t *testing.T) {
const riak = "../../modules/recon/riak-api-exposure.yaml"
const couchbase = "../../modules/recon/couchbase-api-exposure.yaml"
const druid = "../../modules/recon/druid-api-exposure.yaml"
riakStats := `{"riak_kv_version":"3.0.16","riak_core_version":"3.0.99","riak_pipe_version":"3.0.16",` +
`"sys_otp_release":"22","ring_members":["riak@10.0.0.1"],"ring_num_partitions":64,` +
`"storage_backend":"riak_kv_bitcask_backend"}`
couchbasePools := `{"pools":[{"name":"default","uri":"/pools/default?uuid=abc",` +
`"streamingUri":"/poolsStreaming/default?uuid=abc"}],"isAdminCreds":false,"isEnterprise":true,` +
`"implementationVersion":"7.2.0-6053-enterprise","uuid":"abc",` +
`"componentsVersion":{"ns_server":"7.2.0-6053","couchdb":"3.1.1"}}`
druidStatus := `{"version":"0.22.1","modules":[{"name":"org.apache.druid.server.initialization.jetty.JettyServerModule",` +
`"artifact":"druid-server","version":"0.22.1"},{"name":"org.apache.druid.guice.AnnouncerModule",` +
`"artifact":"druid-server","version":"0.22.1"}],"memory":{"maxMemory":1037959168,` +
`"totalMemory":1037959168,"freeMemory":900000000,"directMemory":134217728}}`
t.Run("an exposed riak http api is flagged and versioned", func(t *testing.T) {
res := runDistDBModule(t, riak, 200, riakStats)
if len(res.Findings) == 0 {
t.Fatal("expected a riak finding")
}
if v := distDBExtract(res, "riak_version"); v != "3.0.16" {
t.Errorf("riak_version=%q, want 3.0.16", v)
}
})
t.Run("an exposed couchbase cluster api is flagged and versioned", func(t *testing.T) {
res := runDistDBModule(t, couchbase, 200, couchbasePools)
if len(res.Findings) == 0 {
t.Fatal("expected a couchbase finding")
}
if v := distDBExtract(res, "couchbase_version"); v != "7.2.0-6053-enterprise" {
t.Errorf("couchbase_version=%q, want 7.2.0-6053-enterprise", v)
}
})
t.Run("an exposed druid process is flagged and versioned", func(t *testing.T) {
res := runDistDBModule(t, druid, 200, druidStatus)
if len(res.Findings) == 0 {
t.Fatal("expected a druid finding")
}
if v := distDBExtract(res, "druid_version"); v != "0.22.1" {
t.Errorf("druid_version=%q, want 0.22.1", v)
}
})
t.Run("a riak kv version without a core version is not flagged", func(t *testing.T) {
body := `{"riak_kv_version":"3.0.16","name":"app"}`
if res := runDistDBModule(t, riak, 200, body); len(res.Findings) > 0 {
t.Errorf("a kv version alone should not match riak, got %d findings", len(res.Findings))
}
})
t.Run("a riak core version without a kv version is not flagged", func(t *testing.T) {
body := `{"riak_core_version":"3.0.16","name":"app"}`
if res := runDistDBModule(t, riak, 200, body); len(res.Findings) > 0 {
t.Errorf("a core version alone should not match riak, got %d findings", len(res.Findings))
}
})
t.Run("a couchbase impl version without a components version is not flagged", func(t *testing.T) {
body := `{"implementationVersion":"7.2.0","name":"app"}`
if res := runDistDBModule(t, couchbase, 200, body); len(res.Findings) > 0 {
t.Errorf("an impl version alone should not match couchbase, got %d findings", len(res.Findings))
}
})
t.Run("a couchbase components version without an impl version is not flagged", func(t *testing.T) {
body := `{"componentsVersion":{"ns_server":"7.2.0"},"name":"app"}`
if res := runDistDBModule(t, couchbase, 200, body); len(res.Findings) > 0 {
t.Errorf("a components version alone should not match couchbase, got %d findings", len(res.Findings))
}
})
t.Run("a druid package without a memory block is not flagged", func(t *testing.T) {
body := `{"modules":[{"name":"org.apache.druid.cli.Main"}],"app":"x"}`
if res := runDistDBModule(t, druid, 200, body); len(res.Findings) > 0 {
t.Errorf("a druid package alone should not match druid, got %d findings", len(res.Findings))
}
})
t.Run("a memory block without a druid package is not flagged", func(t *testing.T) {
body := `{"memory":{"maxMemory":123},"app":"x"}`
if res := runDistDBModule(t, druid, 200, body); len(res.Findings) > 0 {
t.Errorf("a memory block alone should not match druid, got %d findings", len(res.Findings))
}
})
t.Run("a generic version json is not a distributed db", func(t *testing.T) {
body := `{"version":"1.0.0","name":"app"}`
for _, file := range []string{riak, couchbase, druid} {
if res := runDistDBModule(t, file, 200, body); len(res.Findings) > 0 {
t.Errorf("%s: a generic version should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{riak, couchbase, druid} {
if res := runDistDBModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{riak, couchbase, druid} {
if res := runDistDBModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
@@ -0,0 +1,164 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runDotfileModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func dotfileExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestDotfileCredentialExposureModules(t *testing.T) {
const netrc = "../../modules/recon/netrc-exposure.yaml"
const pgpass = "../../modules/recon/pgpass-exposure.yaml"
const mycnf = "../../modules/recon/mysql-client-config-exposure.yaml"
netrcBody := "machine api.example.com\n login deploy\n password s3cr3tP@ss\n" +
"machine ftp.example.com\n login anon\n password anon@site\n"
pgpassBody := "db.example.com:5432:appdb:appuser:Sup3rSecret\n*:*:*:replication:replpass\n"
mycnfBody := "[client]\nuser=root\npassword=R00tPass!\nhost=127.0.0.1\nport=3306\n"
t.Run("an exposed netrc leaks the machine host", func(t *testing.T) {
res := runDotfileModule(t, netrc, 200, netrcBody)
if len(res.Findings) == 0 {
t.Fatal("expected a netrc finding")
}
if v := dotfileExtract(res, "netrc_machine"); v != "api.example.com" {
t.Errorf("netrc_machine=%q, want api.example.com", v)
}
})
t.Run("an exposed pgpass leaks the host", func(t *testing.T) {
res := runDotfileModule(t, pgpass, 200, pgpassBody)
if len(res.Findings) == 0 {
t.Fatal("expected a pgpass finding")
}
if v := dotfileExtract(res, "pgpass_host"); v != "db.example.com" {
t.Errorf("pgpass_host=%q, want db.example.com", v)
}
})
t.Run("an exposed my.cnf leaks the client user", func(t *testing.T) {
res := runDotfileModule(t, mycnf, 200, mycnfBody)
if len(res.Findings) == 0 {
t.Fatal("expected a my.cnf finding")
}
if v := dotfileExtract(res, "mysql_user"); v != "root" {
t.Errorf("mysql_user=%q, want root", v)
}
})
t.Run("prose that names machine login and password out of order is not a netrc", func(t *testing.T) {
body := "this machine requires a login; store the password securely"
if res := runDotfileModule(t, netrc, 200, body); len(res.Findings) > 0 {
t.Errorf("out of order prose should not match, got %d findings", len(res.Findings))
}
})
t.Run("an html page demonstrating a netrc is not a leak", func(t *testing.T) {
body := "<!DOCTYPE html><html><body><pre>machine api.example.com login deploy password s3cret</pre></body></html>"
if res := runDotfileModule(t, netrc, 200, body); len(res.Findings) > 0 {
t.Errorf("an html netrc tutorial should not match, got %d findings", len(res.Findings))
}
})
t.Run("a yaml db config with colon keys is not a pgpass", func(t *testing.T) {
body := "database:\n host: db.example.com\n port: 5432\n user: appuser\n password: secret\n"
if res := runDotfileModule(t, pgpass, 200, body); len(res.Findings) > 0 {
t.Errorf("a yaml db config should not match, got %d findings", len(res.Findings))
}
})
t.Run("a pgpass shaped line with a non numeric port is not flagged", func(t *testing.T) {
body := "db.example.com:default:appdb:appuser:Sup3rSecret\n"
if res := runDotfileModule(t, pgpass, 200, body); len(res.Findings) > 0 {
t.Errorf("a non numeric port should not match, got %d findings", len(res.Findings))
}
})
t.Run("a multi line config with a number field does not match across lines", func(t *testing.T) {
body := "timeout:30:seconds configured\nsee http://docs.example.com:8080 for details\n"
if res := runDotfileModule(t, pgpass, 200, body); len(res.Findings) > 0 {
t.Errorf("fields must stay on one line, got %d findings", len(res.Findings))
}
})
t.Run("an html page demonstrating a pgpass is not a leak", func(t *testing.T) {
body := "<!DOCTYPE html>\n<html><body><pre>\ndb.example.com:5432:appdb:appuser:secret\n</pre></body></html>\n"
if res := runDotfileModule(t, pgpass, 200, body); len(res.Findings) > 0 {
t.Errorf("an html pgpass tutorial should not match, got %d findings", len(res.Findings))
}
})
t.Run("a my.cnf client section without a password is not flagged", func(t *testing.T) {
body := "[client]\nuser=root\nhost=localhost\nport=3306\n"
if res := runDotfileModule(t, mycnf, 200, body); len(res.Findings) > 0 {
t.Errorf("a section without a password should not match, got %d findings", len(res.Findings))
}
})
t.Run("a password line without a my.cnf section is not flagged", func(t *testing.T) {
body := "password=hunter2\nfoo=bar\n"
if res := runDotfileModule(t, mycnf, 200, body); len(res.Findings) > 0 {
t.Errorf("a password without a section should not match, got %d findings", len(res.Findings))
}
})
t.Run("an html page demonstrating a my.cnf is not a leak", func(t *testing.T) {
body := "<!DOCTYPE html><html><body><pre>[client]\npassword=secret</pre></body></html>"
if res := runDotfileModule(t, mycnf, 200, body); len(res.Findings) > 0 {
t.Errorf("an html my.cnf tutorial should not match, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{netrc, pgpass, mycnf} {
if res := runDotfileModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{netrc, pgpass, mycnf} {
if res := runDotfileModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
+84
View File
@@ -0,0 +1,84 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
// runEnvModule runs the env exposure module end to end against a server that
// returns the same status and body for every path it requests.
func runEnvModule(t *testing.T, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule("../../modules/recon/env-file-exposure.yaml")
if err != nil {
t.Fatalf("parse: %v", err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute: %v", err)
}
return res
}
func envLeakedKey(res *modules.Result) string {
for _, f := range res.Findings {
if v := f.Extracted["leaked_key"]; v != "" {
return v
}
}
return ""
}
func TestEnvFileExposureModule(t *testing.T) {
realEnv := "APP_NAME=Acme\nAPP_KEY=base64:Zm9vYmFy\nDB_PASSWORD=s3cr3t\nMAIL_PASSWORD=hunter2\n"
htmlMentionsSecret := "<!DOCTYPE html>\n<html><head><title>Docs</title></head><body>" +
"<code>APP_KEY=base64:...</code> put DB_PASSWORD= in your .env</body></html>"
t.Run("real env body leaks", func(t *testing.T) {
res := runEnvModule(t, 200, realEnv)
if len(res.Findings) == 0 {
t.Fatal("expected a finding for a real .env body")
}
if key := envLeakedKey(res); key != "APP_KEY" {
t.Errorf("leaked_key=%q, want APP_KEY", key)
}
})
t.Run("html page mentioning a key is not a leak", func(t *testing.T) {
if res := runEnvModule(t, 200, htmlMentionsSecret); len(res.Findings) > 0 {
t.Errorf("html page should not match, got %d findings", len(res.Findings))
}
})
t.Run("secrets behind a 404 are not a leak", func(t *testing.T) {
if res := runEnvModule(t, 404, realEnv); len(res.Findings) > 0 {
t.Errorf("404 should not match, got %d findings", len(res.Findings))
}
})
}
+191 -52
View File
@@ -4,7 +4,7 @@
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2025 vmfunc (vmfunc), xyzeva, :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
@@ -13,19 +13,29 @@
package modules
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"regexp"
"strings"
"sync"
"time"
"github.com/tidwall/gjson"
)
// MaxBodySize limits response body to prevent memory exhaustion.
const MaxBodySize = 5 * 1024 * 1024
// ErrUnsupportedModuleType signals an executor for a module type that is not
// yet implemented. Returning it (rather than an empty result) keeps callers
// from mistaking "not implemented" for "scanned, found nothing".
var ErrUnsupportedModuleType = errors.New("unsupported module type")
// httpRequest represents a generated HTTP request.
type httpRequest struct {
Method string
@@ -63,7 +73,10 @@ func ExecuteHTTPModule(ctx context.Context, target string, def *YAMLModule, opts
}
// Generate requests based on paths and payloads
requests := generateHTTPRequests(target, cfg)
requests, err := generateHTTPRequests(target, cfg)
if err != nil {
return nil, err
}
// Determine thread count
threads := cfg.Threads
@@ -117,9 +130,14 @@ func ExecuteHTTPModule(ctx context.Context, target string, def *YAMLModule, opts
}
// generateHTTPRequests creates all requests based on paths and payloads.
func generateHTTPRequests(target string, cfg *HTTPConfig) []*httpRequest {
func generateHTTPRequests(target string, cfg *HTTPConfig) ([]*httpRequest, error) {
var requests []*httpRequest
paths, err := resolvePaths(cfg)
if err != nil {
return nil, err
}
// Ensure target has no trailing slash
target = strings.TrimSuffix(target, "/")
@@ -130,7 +148,7 @@ func generateHTTPRequests(target string, cfg *HTTPConfig) []*httpRequest {
// If no payloads, just use paths directly
if len(cfg.Payloads) == 0 {
for _, path := range cfg.Paths {
for _, path := range paths {
url := substituteVariables(path, target, "")
requests = append(requests, &httpRequest{
Method: method,
@@ -140,26 +158,106 @@ func generateHTTPRequests(target string, cfg *HTTPConfig) []*httpRequest {
Original: path,
})
}
return requests
return requests, nil
}
// Generate requests with payloads
for _, path := range cfg.Paths {
// pitchfork pairs path[i] with payload[i] and stops at the shorter list;
// clusterbomb (default) crosses every path with every payload.
if strings.EqualFold(cfg.Attack, "pitchfork") {
n := len(paths)
if len(cfg.Payloads) < n {
n = len(cfg.Payloads)
}
for i := 0; i < n; i++ {
requests = append(requests, newPayloadRequest(method, target, paths[i], cfg.Payloads[i], cfg))
}
return requests, nil
}
for _, path := range paths {
for _, payload := range cfg.Payloads {
url := substituteVariables(path, target, payload)
body := substituteVariables(cfg.Body, target, payload)
requests = append(requests, &httpRequest{
Method: method,
URL: url,
Headers: cfg.Headers,
Body: body,
Payload: payload,
Original: path,
})
requests = append(requests, newPayloadRequest(method, target, path, payload, cfg))
}
}
return requests
return requests, nil
}
// resolvePaths expands a wordlist over any {{word}} path templates so one
// "{{BaseURL}}/{{word}}" path fuzzes the whole list; paths without {{word}}
// pass through literally. no wordlist leaves cfg.Paths untouched.
func resolvePaths(cfg *HTTPConfig) ([]string, error) {
if cfg.Wordlist == "" {
return cfg.Paths, nil
}
words, err := loadWordlist(cfg.Wordlist)
if err != nil {
return nil, err
}
var paths []string
for _, path := range cfg.Paths {
if !strings.Contains(path, "{{word}}") && !strings.Contains(path, "{{Word}}") {
paths = append(paths, path)
continue
}
for _, word := range words {
expanded := strings.ReplaceAll(path, "{{word}}", word)
expanded = strings.ReplaceAll(expanded, "{{Word}}", word)
paths = append(paths, expanded)
}
}
return paths, nil
}
// loadWordlist reads non-empty lines from a local wordlist file, mirroring the
// dirlist scanner's scanLines so a converted module fuzzes the identical words.
func loadWordlist(path string) ([]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open wordlist %q: %w", path, err)
}
defer f.Close()
var words []string
scanner := bufio.NewScanner(f)
scanner.Split(bufio.ScanLines)
for scanner.Scan() {
if line := scanner.Text(); line != "" {
words = append(words, line)
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("read wordlist %q: %w", path, err)
}
return words, nil
}
// newPayloadRequest builds one request with the path and body templates
// substituted for the given payload.
func newPayloadRequest(method, target, path, payload string, cfg *HTTPConfig) *httpRequest {
return &httpRequest{
Method: method,
URL: substituteVariables(path, target, payload),
Headers: cfg.Headers,
Body: substituteVariables(cfg.Body, target, payload),
Payload: payload,
Original: path,
}
}
// validateAttack rejects an attack mode that is not "", "clusterbomb", or
// "pitchfork"; an empty value defaults to clusterbomb.
func validateAttack(attack string) error {
switch strings.ToLower(attack) {
case "", "clusterbomb", "pitchfork":
return nil
default:
return fmt.Errorf("invalid attack %q (want \"clusterbomb\" or \"pitchfork\")", attack)
}
}
// substituteVariables replaces template variables in a string.
@@ -206,45 +304,63 @@ func executeHTTPRequest(ctx context.Context, client *http.Client, r *httpRequest
bodyStr := string(respBody)
// Check matchers
if !checkMatchers(cfg.Matchers, resp, bodyStr) {
if !checkMatchers(cfg.Matchers, cfg.MatchersCondition, resp, bodyStr) {
return Finding{}, false
}
// Extract data
extracted := runExtractors(cfg.Extractors, resp, bodyStr)
// favicon-only matches fire on binary icon bytes; report the hash, not the body.
evidence := truncateEvidence(bodyStr)
if fav, ok := faviconEvidence(cfg.Matchers, bodyStr); ok {
evidence = fav
}
return Finding{
URL: r.URL,
Severity: severity,
Evidence: truncateEvidence(bodyStr),
Evidence: evidence,
Extracted: extracted,
}, true
}
// checkMatchers evaluates all matchers against the response.
func checkMatchers(matchers []Matcher, resp *http.Response, body string) bool {
// checkMatchers combines matchers with condition "and" (default, all match) or "or" (any).
func checkMatchers(matchers []Matcher, condition string, resp *http.Response, body string) bool {
if len(matchers) == 0 {
return false
}
// Default to AND condition across matchers
for _, m := range matchers {
matched := checkMatcher(m, resp, body)
if m.Negative {
or := strings.EqualFold(condition, "or")
for i := range matchers {
matched := checkMatcher(&matchers[i], resp, body)
if matchers[i].Negative {
matched = !matched
}
if !matched {
return false // AND logic
if or && matched {
return true
}
if !or && !matched {
return false
}
}
return true
// and: all matched; or: none matched.
return !or
}
// validateMatchersCondition rejects a matchers-condition that is not "", "and", or "or".
func validateMatchersCondition(condition string) error {
switch strings.ToLower(condition) {
case "", "and", "or":
return nil
default:
return fmt.Errorf("invalid matchers-condition %q (want \"and\" or \"or\")", condition)
}
}
// checkMatcher evaluates a single matcher.
func checkMatcher(m Matcher, resp *http.Response, body string) bool {
part := getPart(m.Part, resp, body)
func checkMatcher(m *Matcher, resp *http.Response, body string) bool {
switch m.Type {
case "status":
for _, status := range m.Status {
@@ -255,10 +371,22 @@ func checkMatcher(m Matcher, resp *http.Response, body string) bool {
return false
case "word":
return checkWords(part, m.Words, m.Condition)
return checkWords(getPart(m.Part, resp, body), m.Words, m.Condition)
case "regex":
return checkRegex(part, m.Regex, m.Condition)
return checkRegex(getPart(m.Part, resp, body), m.Regex, m.Condition)
case "favicon":
return checkFaviconHash(body, m.Hash)
case "size":
// size matches the response body length against any listed value.
for _, n := range m.Size {
if len(body) == n {
return true
}
}
return false
default:
return false
@@ -350,10 +478,9 @@ func runExtractors(extractors []Extractor, resp *http.Response, body string) map
result := make(map[string]string)
for _, e := range extractors {
part := getPart(e.Part, resp, body)
switch e.Type {
case "regex":
part := getPart(e.Part, resp, body)
for _, pattern := range e.Regex {
re, err := regexp.Compile(pattern)
if err != nil {
@@ -365,6 +492,24 @@ func runExtractors(extractors []Extractor, resp *http.Response, body string) map
break
}
}
case "kv":
// kv records response header key/values, namespaced by the extractor
// name when set (e.g. a headers module surfacing every header).
for k, v := range resp.Header {
key := k
if e.Name != "" {
key = e.Name + "." + k
}
result[key] = strings.Join(v, ", ")
}
case "json":
part := getPart(e.Part, resp, body)
for _, path := range e.JSON {
if r := gjson.Get(part, path); r.Exists() {
result[e.Name] = r.String()
break
}
}
}
}
@@ -380,22 +525,16 @@ func truncateEvidence(s string) string {
return s
}
// ExecuteDNSModule runs a DNS-based module (stub for now).
func ExecuteDNSModule(ctx context.Context, target string, def *YAMLModule, opts Options) (*Result, error) {
// TODO: Implement DNS module execution
return &Result{
ModuleID: def.ID,
Target: target,
Findings: []Finding{},
}, nil
// ExecuteDNSModule runs a DNS-based module (not yet implemented).
// returns ErrUnsupportedModuleType so the caller logs a clear failure rather
// than reporting an empty (but successful-looking) result.
func ExecuteDNSModule(_ context.Context, _ string, def *YAMLModule, _ Options) (*Result, error) {
return nil, fmt.Errorf("dns module %q: %w", def.ID, ErrUnsupportedModuleType)
}
// ExecuteTCPModule runs a TCP-based module (stub for now).
func ExecuteTCPModule(ctx context.Context, target string, def *YAMLModule, opts Options) (*Result, error) {
// TODO: Implement TCP module execution
return &Result{
ModuleID: def.ID,
Target: target,
Findings: []Finding{},
}, nil
// ExecuteTCPModule runs a TCP-based module (not yet implemented).
// returns ErrUnsupportedModuleType so the caller logs a clear failure rather
// than reporting an empty (but successful-looking) result.
func ExecuteTCPModule(_ context.Context, _ string, def *YAMLModule, _ Options) (*Result, error) {
return nil, fmt.Errorf("tcp module %q: %w", def.ID, ErrUnsupportedModuleType)
}
+387
View File
@@ -0,0 +1,387 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package modules
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/httpx"
)
const testTimeout = 5 * time.Second
// TestExecuteHTTPModuleMatchAndExtract drives the full executor against a live
// httptest server: a request hits a path, a matcher fires, an extractor captures.
func TestExecuteHTTPModuleMatchAndExtract(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/admin" {
w.Header().Set("X-App", "demo")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`flag{found-it} session=sess-4242`))
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
def := &YAMLModule{
ID: "test-http-hit",
Type: TypeHTTP,
Info: YAMLModuleInfo{Severity: "high"},
HTTP: &HTTPConfig{
Method: "GET",
Paths: []string{"{{BaseURL}}/admin", "{{BaseURL}}/missing"},
Matchers: []Matcher{
{Type: "status", Status: []int{200}},
{Type: "word", Part: "body", Words: []string{"flag{found-it}"}},
},
Extractors: []Extractor{
{Type: "regex", Name: "session", Part: "body", Regex: []string{`session=(\S+)`}, Group: 1},
},
},
}
// route through the shared httpx client so proxy/-H/-rate-limit would apply.
opts := Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)}
result, err := ExecuteHTTPModule(context.Background(), srv.URL, def, opts)
if err != nil {
t.Fatalf("ExecuteHTTPModule: %v", err)
}
// only /admin satisfies status+word, /missing returns 404.
if len(result.Findings) != 1 {
t.Fatalf("got %d findings, want 1", len(result.Findings))
}
f := result.Findings[0]
if f.Severity != "high" {
t.Errorf("severity = %q, want high (carried from Info)", f.Severity)
}
if f.Extracted["session"] != "sess-4242" {
t.Errorf("extracted session = %q, want sess-4242", f.Extracted["session"])
}
if f.URL != srv.URL+"/admin" {
t.Errorf("finding url = %q, want %q", f.URL, srv.URL+"/admin")
}
}
// TestExecuteHTTPModuleNoMatch confirms a module that matches nothing reports
// zero findings without erroring.
func TestExecuteHTTPModuleNoMatch(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("nothing interesting"))
}))
defer srv.Close()
def := &YAMLModule{
ID: "test-http-miss",
Type: TypeHTTP,
HTTP: &HTTPConfig{
Paths: []string{"{{BaseURL}}/"},
Matchers: []Matcher{
{Type: "word", Part: "body", Words: []string{"never-present"}},
},
},
}
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) != 0 {
t.Fatalf("got %d findings, want 0", len(result.Findings))
}
}
// TestExecuteHTTPModulePayloadExpansion verifies payload templates reach the
// server and the matching response is captured.
func TestExecuteHTTPModulePayloadExpansion(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// only the "boom" payload triggers the vulnerable branch.
if r.URL.Query().Get("q") == "boom" {
_, _ = w.Write([]byte("error: sql syntax near boom"))
return
}
_, _ = w.Write([]byte("ok"))
}))
defer srv.Close()
def := &YAMLModule{
ID: "test-http-payload",
Type: TypeHTTP,
HTTP: &HTTPConfig{
Paths: []string{"{{BaseURL}}/search?q={{payload}}"},
Payloads: []string{"safe", "boom"},
Matchers: []Matcher{
{Type: "word", Part: "body", Words: []string{"sql syntax"}},
},
},
}
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 (only boom payload)", len(result.Findings))
}
}
// 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 {
t.Fatal("expected error when HTTP config is nil")
}
}
// TestExecuteHTTPModuleContextCancel pins the cancellation path. The dispatch
// loop selects between ctx.Done() and the concurrency semaphore, so a cancelled
// context can either short-circuit with ctx.Err() or let the in-flight request
// fail on the dead context. Both are correct: the contract is "never hang, never
// invent a finding", which is what we assert here rather than forcing one race
// winner (that made this test flaky under -count).
func TestExecuteHTTPModuleContextCancel(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
ctx, cancel := context.WithCancel(context.Background())
cancel()
def := &YAMLModule{
ID: "test-http-cancel",
Type: TypeHTTP,
HTTP: &HTTPConfig{
Paths: []string{"{{BaseURL}}/a"},
Matchers: []Matcher{{Type: "status", Status: []int{200}}},
},
}
result, err := ExecuteHTTPModule(ctx, srv.URL, def, Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)})
if err != nil {
if !errors.Is(err, context.Canceled) {
t.Fatalf("err = %v, want context.Canceled or nil", err)
}
return
}
// no error means the request was dispatched but failed on the dead context;
// either way a cancelled scan must not surface findings.
if len(result.Findings) != 0 {
t.Fatalf("cancelled scan produced %d findings, want 0", len(result.Findings))
}
}
// TestExecuteDNSModuleUnsupported pins the current behavior: DNS execution is
// not implemented and must signal it via ErrUnsupportedModuleType, not by
// quietly returning an empty (successful-looking) result.
func TestExecuteDNSModuleUnsupported(t *testing.T) {
def := &YAMLModule{ID: "dns-mod", Type: TypeDNS, DNS: &DNSConfig{Type: "A"}}
result, err := ExecuteDNSModule(context.Background(), "example.com", def, Options{})
if result != nil {
t.Errorf("result = %v, want nil for unsupported type", result)
}
if !errors.Is(err, ErrUnsupportedModuleType) {
t.Fatalf("err = %v, want ErrUnsupportedModuleType", err)
}
}
func TestExecuteTCPModuleUnsupported(t *testing.T) {
def := &YAMLModule{ID: "tcp-mod", Type: TypeTCP, TCP: &TCPConfig{Port: 22}}
result, err := ExecuteTCPModule(context.Background(), "example.com", def, Options{})
if result != nil {
t.Errorf("result = %v, want nil for unsupported type", result)
}
if !errors.Is(err, ErrUnsupportedModuleType) {
t.Fatalf("err = %v, want ErrUnsupportedModuleType", err)
}
}
// TestWrapperExecuteRoutesByType confirms the Module wrapper dispatches each
// type to the right executor and propagates the unsupported-type sentinel.
func TestWrapperExecuteRoutesByType(t *testing.T) {
t.Run("dns routes to unsupported", func(t *testing.T) {
def := &YAMLModule{ID: "d", Type: TypeDNS, DNS: &DNSConfig{}}
w := newYAMLModuleWrapper(def, "d.yaml")
if _, err := w.Execute(context.Background(), "t", Options{}); !errors.Is(err, ErrUnsupportedModuleType) {
t.Fatalf("err = %v, want ErrUnsupportedModuleType", err)
}
})
t.Run("tcp routes to unsupported", func(t *testing.T) {
def := &YAMLModule{ID: "t", Type: TypeTCP, TCP: &TCPConfig{}}
w := newYAMLModuleWrapper(def, "t.yaml")
if _, err := w.Execute(context.Background(), "t", Options{}); !errors.Is(err, ErrUnsupportedModuleType) {
t.Fatalf("err = %v, want ErrUnsupportedModuleType", err)
}
})
t.Run("missing http config errors", func(t *testing.T) {
def := &YAMLModule{ID: "h", Type: TypeHTTP}
w := newYAMLModuleWrapper(def, "h.yaml")
if _, err := w.Execute(context.Background(), "t", Options{}); err == nil {
t.Fatal("expected error for missing http config")
}
})
t.Run("unknown type errors", func(t *testing.T) {
def := &YAMLModule{ID: "z", Type: ModuleType("bogus")}
w := newYAMLModuleWrapper(def, "z.yaml")
if _, err := w.Execute(context.Background(), "t", Options{}); err == nil {
t.Fatal("expected error for unknown module type")
}
})
}
// TestExecuteHTTPModuleWordlist proves a {{word}} path templated against a local
// wordlist drives one real request per word, and only the path that exists fires.
func TestExecuteHTTPModuleWordlist(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/admin" {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
list := filepath.Join(t.TempDir(), "words.txt")
if err := os.WriteFile(list, []byte("login\nadmin\nbackup\n"), 0o600); err != nil {
t.Fatal(err)
}
def := &YAMLModule{
ID: "test-wordlist",
Type: TypeHTTP,
Info: YAMLModuleInfo{Severity: "low"},
HTTP: &HTTPConfig{
Method: "GET",
Wordlist: list,
Paths: []string{"{{BaseURL}}/{{word}}"},
Matchers: []Matcher{{Type: "status", Status: []int{200}}},
},
}
opts := Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)}
res, err := ExecuteHTTPModule(context.Background(), srv.URL, def, opts)
if err != nil {
t.Fatalf("execute: %v", err)
}
if len(res.Findings) != 1 {
t.Fatalf("got %d findings, want 1 (only /admin exists)", len(res.Findings))
}
if got := res.Findings[0].URL; got != srv.URL+"/admin" {
t.Errorf("finding url = %q, want %q", got, srv.URL+"/admin")
}
}
func TestTruncateEvidence(t *testing.T) {
short := "short evidence"
if got := truncateEvidence(short); got != short {
t.Errorf("short evidence changed: %q", got)
}
long := make([]byte, 600)
for i := range long {
long[i] = 'a'
}
got := truncateEvidence(string(long))
// 500 chars of content plus the ellipsis marker.
if len(got) != 503 {
t.Errorf("truncated len = %d, want 503", len(got))
}
if got[len(got)-3:] != "..." {
t.Errorf("truncated evidence missing ellipsis: %q", got[len(got)-3:])
}
}
+82
View File
@@ -0,0 +1,82 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package modules
import (
"fmt"
"math"
"github.com/dropalldatabases/sif/internal/fingerprint"
)
// checkFaviconHash reports whether the body's shodan mmh3 hash matches any
// configured value. only the body (the icon) is hashed; part is ignored.
func checkFaviconHash(body string, want []int64) bool {
if len(want) == 0 {
return false
}
got := fingerprint.FaviconHash([]byte(body))
for _, w := range want {
if n, ok := normalizeFaviconHash(w); ok && n == got {
return true
}
}
return false
}
// normalizeFaviconHash folds a hash to the signed int32 shodan stores, accepting
// either 32-bit form so a signed or unsigned value pastes in as-is. out-of-range
// values are rejected so a stray number can't wrap into a false match.
func normalizeFaviconHash(v int64) (int32, bool) {
if v < math.MinInt32 || v > math.MaxUint32 {
return 0, false
}
return int32(uint32(v)), true //nolint:gosec // intentional 32-bit fold to shodan's signed form
}
// faviconEvidence gives the hash as evidence for a favicon-only finding, and
// nothing when a word/regex matcher is present so its body evidence stands.
func faviconEvidence(matchers []Matcher, body string) (string, bool) {
favicon := false
for i := range matchers {
switch matchers[i].Type {
case "word", "regex":
return "", false
case "favicon":
favicon = true
}
}
if !favicon {
return "", false
}
return fmt.Sprintf("favicon mmh3=%d", fingerprint.FaviconHash([]byte(body))), true
}
// validateMatchers fails favicon matchers that would silently never fire (no
// hash, or one out of 32-bit range) at load rather than at match time.
func validateMatchers(matchers []Matcher) error {
for i := range matchers {
if matchers[i].Type != "favicon" {
continue
}
if len(matchers[i].Hash) == 0 {
return fmt.Errorf("favicon matcher requires at least one hash")
}
for _, h := range matchers[i].Hash {
if _, ok := normalizeFaviconHash(h); !ok {
return fmt.Errorf("favicon hash %d out of range (use a signed int32 or unsigned uint32 value)", h)
}
}
}
return nil
}
+191
View File
@@ -0,0 +1,191 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package modules
import (
"context"
"fmt"
"math"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/dropalldatabases/sif/internal/fingerprint"
"github.com/dropalldatabases/sif/internal/httpx"
)
// faviconFixture hashes to a negative int32, so its signed and unsigned forms
// differ and the unsigned-match case below actually exercises the fold.
var faviconFixture = []byte(strings.Repeat("sif-favicon-golden-test-bytes-", 8))
func TestCheckMatcherFavicon(t *testing.T) {
body := string(faviconFixture)
signed := int64(fingerprint.FaviconHash(faviconFixture))
if signed >= 0 {
t.Fatalf("fixture must hash to a negative int32 for the unsigned case to be meaningful, got %d", signed)
}
unsigned := int64(uint32(fingerprint.FaviconHash(faviconFixture)))
tests := []struct {
name string
hashes []int64
expect bool
}{
{name: "signed match", hashes: []int64{signed}, expect: true},
{name: "unsigned match", hashes: []int64{unsigned}, expect: true},
{name: "one of many", hashes: []int64{1, 2, signed}, expect: true},
{name: "no match", hashes: []int64{1, 2, 3}, expect: false},
{name: "empty list", hashes: nil, expect: false},
{name: "out-of-range ignored", hashes: []int64{1 << 40}, expect: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := &Matcher{Type: "favicon", Hash: tt.hashes}
resp := fakeResponse(t, 200, nil)
if got := checkMatcher(m, resp, body); got != tt.expect {
t.Errorf("checkMatcher favicon = %v, want %v", got, tt.expect)
}
})
}
}
func TestNormalizeFaviconHash(t *testing.T) {
tests := []struct {
name string
in int64
want int32
wantOK bool
}{
{name: "signed passthrough", in: -235701012, want: -235701012, wantOK: true},
{name: "unsigned folds to signed", in: 4059266284, want: -235701012, wantOK: true},
{name: "positive in range", in: 116323821, want: 116323821, wantOK: true},
{name: "min int32", in: math.MinInt32, want: math.MinInt32, wantOK: true},
{name: "max uint32 folds to -1", in: math.MaxUint32, want: -1, wantOK: true},
{name: "above uint32 rejected", in: math.MaxUint32 + 1, wantOK: false},
{name: "below int32 rejected", in: math.MinInt32 - 1, wantOK: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := normalizeFaviconHash(tt.in)
if ok != tt.wantOK {
t.Fatalf("ok = %v, want %v", ok, tt.wantOK)
}
if ok && got != tt.want {
t.Errorf("normalizeFaviconHash(%d) = %d, want %d", tt.in, got, tt.want)
}
})
}
}
func TestFaviconEvidence(t *testing.T) {
body := string(faviconFixture)
hashLine := fmt.Sprintf("favicon mmh3=%d", fingerprint.FaviconHash(faviconFixture))
tests := []struct {
name string
matchers []Matcher
want string
wantOK bool
}{
{name: "favicon only", matchers: []Matcher{{Type: "favicon"}}, want: hashLine, wantOK: true},
{name: "favicon with status", matchers: []Matcher{{Type: "status"}, {Type: "favicon"}}, want: hashLine, wantOK: true},
{name: "favicon with word keeps body", matchers: []Matcher{{Type: "word"}, {Type: "favicon"}}, wantOK: false},
{name: "favicon with regex keeps body", matchers: []Matcher{{Type: "regex"}, {Type: "favicon"}}, wantOK: false},
{name: "no favicon matcher", matchers: []Matcher{{Type: "status"}}, wantOK: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, ok := faviconEvidence(tt.matchers, body)
if ok != tt.wantOK {
t.Fatalf("ok = %v, want %v", ok, tt.wantOK)
}
if ok && got != tt.want {
t.Errorf("evidence = %q, want %q", got, tt.want)
}
})
}
}
func TestValidateMatchers(t *testing.T) {
tests := []struct {
name string
matchers []Matcher
wantErr bool
}{
{name: "valid signed", matchers: []Matcher{{Type: "favicon", Hash: []int64{-235701012}}}, wantErr: false},
{name: "valid unsigned", matchers: []Matcher{{Type: "favicon", Hash: []int64{4059266284}}}, wantErr: false},
{name: "favicon with no hash", matchers: []Matcher{{Type: "favicon"}}, wantErr: true},
{name: "out-of-range hash", matchers: []Matcher{{Type: "favicon", Hash: []int64{99999999999}}}, wantErr: true},
{name: "non-favicon ignored", matchers: []Matcher{{Type: "word", Words: []string{"x"}}}, wantErr: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := validateMatchers(tt.matchers); (err != nil) != tt.wantErr {
t.Errorf("validateMatchers err = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
// favicon composes with the negative flag like any other matcher.
func TestCheckMatcherFaviconNegative(t *testing.T) {
signed := int64(fingerprint.FaviconHash(faviconFixture))
matchers := []Matcher{{Type: "favicon", Hash: []int64{signed}, Negative: true}}
resp := fakeResponse(t, 200, nil)
if checkMatchers(matchers, "", resp, string(faviconFixture)) {
t.Error("negative favicon matcher should not match its own hash")
}
}
// drives the full executor: fetch favicon, match on its hash, report the hash.
func TestExecuteHTTPModuleFavicon(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/favicon.ico" {
w.Header().Set("Content-Type", "image/x-icon")
_, _ = w.Write(faviconFixture)
return
}
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
// unsigned form must still match end to end
unsigned := int64(uint32(fingerprint.FaviconHash(faviconFixture)))
def := &YAMLModule{
ID: "favicon-fp",
Type: TypeHTTP,
Info: YAMLModuleInfo{Severity: "info"},
HTTP: &HTTPConfig{
Method: "GET",
Paths: []string{"{{BaseURL}}/favicon.ico"},
Matchers: []Matcher{
{Type: "status", Status: []int{200}},
{Type: "favicon", Hash: []int64{unsigned}},
},
},
}
opts := Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)}
result, err := ExecuteHTTPModule(context.Background(), srv.URL, def, opts)
if err != nil {
t.Fatalf("ExecuteHTTPModule: %v", err)
}
if len(result.Findings) != 1 {
t.Fatalf("got %d findings, want 1", len(result.Findings))
}
wantEvidence := fmt.Sprintf("favicon mmh3=%d", fingerprint.FaviconHash(faviconFixture))
if got := result.Findings[0].Evidence; got != wantEvidence {
t.Errorf("evidence = %q, want %q", got, wantEvidence)
}
}
@@ -0,0 +1,168 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runHTTPDBModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func httpdbExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestHTTPDatabaseExposureModules(t *testing.T) {
const influxdb = "../../modules/recon/influxdb-api-exposure.yaml"
const arangodb = "../../modules/recon/arangodb-api-exposure.yaml"
const neo4j = "../../modules/recon/neo4j-api-exposure.yaml"
influxHealth := `{"name":"influxdb","message":"ready for queries and writes","status":"pass",` +
`"checks":[],"version":"2.9.1","commit":"a1b2c3d4"}`
arangoVersion := `{"server":"arango","version":"3.11.5","license":"community"}`
neo4jDiscovery := `{"bolt_routing":"neo4j://localhost:7687","transaction":"http://localhost:7474/db/{databaseName}/tx",` +
`"bolt_direct":"bolt://localhost:7687","neo4j_version":"5.13.0","neo4j_edition":"community"}`
t.Run("an exposed influxdb health endpoint is flagged and versioned", func(t *testing.T) {
res := runHTTPDBModule(t, influxdb, 200, influxHealth)
if len(res.Findings) == 0 {
t.Fatal("expected an influxdb finding")
}
if v := httpdbExtract(res, "influxdb_version"); v != "2.9.1" {
t.Errorf("influxdb_version=%q, want 2.9.1", v)
}
})
t.Run("an anonymous arangodb version endpoint is flagged and versioned", func(t *testing.T) {
res := runHTTPDBModule(t, arangodb, 200, arangoVersion)
if len(res.Findings) == 0 {
t.Fatal("expected an arangodb finding")
}
if v := httpdbExtract(res, "arangodb_version"); v != "3.11.5" {
t.Errorf("arangodb_version=%q, want 3.11.5", v)
}
})
t.Run("an exposed neo4j discovery endpoint is flagged and versioned", func(t *testing.T) {
res := runHTTPDBModule(t, neo4j, 200, neo4jDiscovery)
if len(res.Findings) == 0 {
t.Fatal("expected a neo4j finding")
}
if v := httpdbExtract(res, "neo4j_version"); v != "5.13.0" {
t.Errorf("neo4j_version=%q, want 5.13.0", v)
}
})
t.Run("an influxdb name without the health message is not flagged", func(t *testing.T) {
body := `{"name":"influxdb","status":"pass"}`
if res := runHTTPDBModule(t, influxdb, 200, body); len(res.Findings) > 0 {
t.Errorf("an influxdb name alone should not match, got %d findings", len(res.Findings))
}
})
t.Run("a health message without the influxdb name is not flagged", func(t *testing.T) {
body := `{"name":"telegraf","message":"ready for queries and writes"}`
if res := runHTTPDBModule(t, influxdb, 200, body); len(res.Findings) > 0 {
t.Errorf("the message alone should not match influxdb, got %d findings", len(res.Findings))
}
})
t.Run("an arango without a license field is still flagged", func(t *testing.T) {
body := `{"server":"arango","version":"3.11.5"}`
res := runHTTPDBModule(t, arangodb, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected an arangodb finding without a license field (pre-3.12)")
}
if v := httpdbExtract(res, "arangodb_version"); v != "3.11.5" {
t.Errorf("arangodb_version=%q, want 3.11.5", v)
}
})
t.Run("a non-arango version response is not flagged", func(t *testing.T) {
body := `{"server":"foundationdb","version":"1.0.0"}`
if res := runHTTPDBModule(t, arangodb, 200, body); len(res.Findings) > 0 {
t.Errorf("a non-arango server should not match, got %d findings", len(res.Findings))
}
})
t.Run("an arango response without a version is not flagged", func(t *testing.T) {
body := `{"server":"arango"}`
if res := runHTTPDBModule(t, arangodb, 200, body); len(res.Findings) > 0 {
t.Errorf("an arango without a version should not match, got %d findings", len(res.Findings))
}
})
t.Run("an arango that requires auth is not flagged", func(t *testing.T) {
if res := runHTTPDBModule(t, arangodb, 401, arangoVersion); len(res.Findings) > 0 {
t.Errorf("a 401 arango should not match, got %d findings", len(res.Findings))
}
})
t.Run("a neo4j version without an edition is not flagged", func(t *testing.T) {
body := `{"neo4j_version":"5.13.0","transaction":"http://localhost:7474/db/neo4j/tx"}`
if res := runHTTPDBModule(t, neo4j, 200, body); len(res.Findings) > 0 {
t.Errorf("a neo4j version alone should not match, got %d findings", len(res.Findings))
}
})
t.Run("a neo4j edition without a version is not flagged", func(t *testing.T) {
body := `{"neo4j_edition":"community","bolt_routing":"neo4j://localhost:7687"}`
if res := runHTTPDBModule(t, neo4j, 200, body); len(res.Findings) > 0 {
t.Errorf("a neo4j edition alone should not match, got %d findings", len(res.Findings))
}
})
t.Run("a generic health json is not influxdb", func(t *testing.T) {
body := `{"status":"UP","components":{"db":{"status":"UP"}}}`
if res := runHTTPDBModule(t, influxdb, 200, body); len(res.Findings) > 0 {
t.Errorf("a generic health should not match influxdb, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{influxdb, arangodb, neo4j} {
if res := runHTTPDBModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{influxdb, arangodb, neo4j} {
if res := runHTTPDBModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
@@ -0,0 +1,151 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runInfraModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func infraExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestInfraConfigExposureModules(t *testing.T) {
const terraform = "../../modules/recon/terraform-state-exposure.yaml"
const kubeconfig = "../../modules/recon/kubeconfig-exposure.yaml"
const compose = "../../modules/recon/docker-compose-exposure.yaml"
t.Run("terraform state leaks the terraform version", func(t *testing.T) {
body := `{"version":4,"terraform_version":"1.5.7","serial":12,"lineage":"a1b2",` +
`"outputs":{},"resources":[{"type":"aws_db_instance","name":"main"}]}`
res := runInfraModule(t, terraform, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a terraform state finding")
}
if v := infraExtract(res, "terraform_version"); v != "1.5.7" {
t.Errorf("terraform_version=%q, want 1.5.7", v)
}
})
t.Run("terraform state with a pre-release version still extracts the number", func(t *testing.T) {
body := `{"version":4,"terraform_version":"0.12.0-beta1","serial":1,"lineage":"x","resources":[]}`
res := runInfraModule(t, terraform, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a terraform state finding")
}
if v := infraExtract(res, "terraform_version"); v != "0.12.0" {
t.Errorf("terraform_version=%q, want 0.12.0", v)
}
})
t.Run("kubeconfig leaks the cluster server", func(t *testing.T) {
body := "apiVersion: v1\nkind: Config\nclusters:\n- cluster:\n" +
" server: https://10.0.0.1:6443\n name: prod\ncurrent-context: prod\n"
res := runInfraModule(t, kubeconfig, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a kubeconfig finding")
}
if v := infraExtract(res, "cluster_server"); v != "https://10.0.0.1:6443" {
t.Errorf("cluster_server=%q, want https://10.0.0.1:6443", v)
}
})
t.Run("docker compose leaks the image version", func(t *testing.T) {
body := "version: \"3.8\"\nservices:\n web:\n image: nginx:1.25\n ports:\n" +
" - \"80:80\"\n db:\n image: postgres:15\n"
res := runInfraModule(t, compose, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a docker compose finding")
}
if v := infraExtract(res, "compose_image"); v != "nginx:1.25" {
t.Errorf("compose_image=%q, want nginx:1.25", v)
}
})
t.Run("a terraform_version mention without the state structure is not a leak", func(t *testing.T) {
body := `{"terraform_version":"1.5.7"}`
if res := runInfraModule(t, terraform, 200, body); len(res.Findings) > 0 {
t.Errorf("a bare version mention should not match, got %d findings", len(res.Findings))
}
})
t.Run("a kind Config mention without the kubeconfig structure is not a leak", func(t *testing.T) {
body := "kind: Config\ndescription: an unrelated document\n"
if res := runInfraModule(t, kubeconfig, 200, body); len(res.Findings) > 0 {
t.Errorf("a bare kind mention should not match, got %d findings", len(res.Findings))
}
})
t.Run("a services key without a service definition is not a leak", func(t *testing.T) {
body := "services: enabled\nnote: not a compose file\n"
if res := runInfraModule(t, compose, 200, body); len(res.Findings) > 0 {
t.Errorf("a bare services key should not match, got %d findings", len(res.Findings))
}
})
t.Run("an html page carrying the markers is not a leak", func(t *testing.T) {
body := `<html><head><title>x</title></head><body>"terraform_version":"1.5.7" "lineage":"a1b2"</body></html>`
if res := runInfraModule(t, terraform, 200, body); len(res.Findings) > 0 {
t.Errorf("an html page should not match, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{terraform, kubeconfig, compose} {
if res := runInfraModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{terraform, kubeconfig, compose} {
if res := runInfraModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
+86
View File
@@ -0,0 +1,86 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package modules
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/dropalldatabases/sif/internal/httpx"
)
func TestRunExtractorsJSON(t *testing.T) {
const body = `{"version":"1.2.3","app":{"name":"sif"},"items":[{"id":7}]}`
resp := fakeResponse(t, 200, nil)
tests := []struct {
name string
paths []string
want string // "" means the extractor should set nothing
}{
{"top level", []string{"version"}, "1.2.3"},
{"nested", []string{"app.name"}, "sif"},
{"array index", []string{"items.0.id"}, "7"},
{"first existing wins", []string{"missing", "version"}, "1.2.3"},
{"no match", []string{"nope"}, ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ex := []Extractor{{Type: "json", Name: "v", Part: "body", JSON: tt.paths}}
got := runExtractors(ex, resp, body)
if tt.want == "" {
if v, ok := got["v"]; ok {
t.Errorf("expected no extraction, got %q", v)
}
return
}
if got["v"] != tt.want {
t.Errorf("got %q, want %q", got["v"], tt.want)
}
})
}
}
func TestExecuteHTTPModuleJSONExtractor(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":"9.9.9"}`))
}))
defer srv.Close()
def := &YAMLModule{
ID: "j",
Type: TypeHTTP,
Info: YAMLModuleInfo{Severity: "info"},
HTTP: &HTTPConfig{
Method: "GET",
Paths: []string{"{{BaseURL}}/"},
Matchers: []Matcher{{Type: "status", Status: []int{200}}},
Extractors: []Extractor{{Type: "json", Name: "version", Part: "body", JSON: []string{"version"}}},
},
}
opts := Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)}
res, err := ExecuteHTTPModule(context.Background(), srv.URL, def, opts)
if err != nil {
t.Fatal(err)
}
if len(res.Findings) != 1 {
t.Fatalf("got %d findings, want 1", len(res.Findings))
}
if got := res.Findings[0].Extracted["version"]; got != "9.9.9" {
t.Errorf("extracted version = %q, want 9.9.9", got)
}
}
+1 -1
View File
@@ -4,7 +4,7 @@
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2025 vmfunc (vmfunc), xyzeva, :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
+269
View File
@@ -0,0 +1,269 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package modules
import (
"os"
"path/filepath"
"testing"
)
// writeModule drops a yaml file into a temp dir and returns its path.
func writeModule(t *testing.T, dir, name, content string) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatalf("write module: %v", err)
}
return path
}
func TestParseYAMLModuleValid(t *testing.T) {
const doc = `id: example-http
type: http
info:
name: Example
author: azzie
severity: medium
description: a test module
tags: [test, demo]
http:
method: GET
paths:
- "{{BaseURL}}/admin"
matchers:
- type: status
status: [200]
- type: word
part: body
words: ["admin"]
condition: and
extractors:
- type: regex
name: token
part: body
regex: ["token=(\\w+)"]
group: 1
`
dir := t.TempDir()
path := writeModule(t, dir, "ok.yaml", doc)
def, err := ParseYAMLModule(path)
if err != nil {
t.Fatalf("ParseYAMLModule: %v", err)
}
if def.ID != "example-http" {
t.Errorf("id = %q, want example-http", def.ID)
}
if def.Type != TypeHTTP {
t.Errorf("type = %q, want http", def.Type)
}
if def.Info.Severity != "medium" {
t.Errorf("severity = %q, want medium", def.Info.Severity)
}
if def.HTTP == nil {
t.Fatal("http config not parsed")
}
if len(def.HTTP.Matchers) != 2 {
t.Errorf("got %d matchers, want 2", len(def.HTTP.Matchers))
}
if len(def.HTTP.Extractors) != 1 || def.HTTP.Extractors[0].Group != 1 {
t.Errorf("extractor not parsed correctly: %+v", def.HTTP.Extractors)
}
if len(def.Info.Tags) != 2 {
t.Errorf("got %d tags, want 2", len(def.Info.Tags))
}
}
func TestParseYAMLModuleErrors(t *testing.T) {
dir := t.TempDir()
tests := []struct {
name string
content string
}{
{
name: "missing id",
content: "type: http\nhttp:\n paths: [\"/\"]\n",
},
{
name: "missing type",
content: "id: no-type\nhttp:\n paths: [\"/\"]\n",
},
{
name: "malformed yaml",
content: "id: bad\ntype: http\n paths: [unbalanced\n : nope\n",
},
{
// a scalar where a mapping is expected must fail to unmarshal.
name: "type mismatch",
content: "id: bad-shape\ntype: http\nhttp: \"should-be-a-map\"\n",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
path := writeModule(t, dir, tt.name+".yaml", tt.content)
if _, err := ParseYAMLModule(path); err == nil {
t.Fatalf("expected error for %s", tt.name)
}
})
}
}
func TestParseYAMLModuleMissingFile(t *testing.T) {
if _, err := ParseYAMLModule(filepath.Join(t.TempDir(), "does-not-exist.yaml")); err == nil {
t.Fatal("expected error for missing file")
}
}
func TestYAMLModuleWrapperInfoAndType(t *testing.T) {
def := &YAMLModule{
ID: "wrap-test",
Type: TypeHTTP,
Info: YAMLModuleInfo{
Name: "Wrapped",
Author: "azzie",
Severity: "low",
Description: "desc",
Tags: []string{"a", "b"},
},
}
w := newYAMLModuleWrapper(def, "wrap.yaml")
if w.Type() != TypeHTTP {
t.Errorf("Type() = %q, want http", w.Type())
}
info := w.Info()
if info.ID != "wrap-test" || info.Name != "Wrapped" || info.Severity != "low" {
t.Errorf("Info() mismatch: %+v", info)
}
if len(info.Tags) != 2 {
t.Errorf("Info().Tags = %v, want 2 entries", info.Tags)
}
}
// TestLoaderLoadAll exercises the directory walk: a valid module registers, a
// malformed one is skipped without aborting the walk.
func TestLoaderLoadAll(t *testing.T) {
Clear()
t.Cleanup(Clear)
dir := t.TempDir()
writeModule(t, dir, "good.yaml", "id: good-mod\ntype: http\nhttp:\n paths: [\"{{BaseURL}}/\"]\n matchers:\n - type: status\n status: [200]\n")
writeModule(t, dir, "bad.yml", "id: bad-mod\n") // missing type -> skipped
writeModule(t, dir, "ignore.txt", "not a module")
l := &Loader{builtinDir: dir, userDir: filepath.Join(dir, "nonexistent-user")}
if err := l.LoadAll(); err != nil {
t.Fatalf("LoadAll: %v", err)
}
// only the good module loads; the malformed one is logged and skipped.
if l.Loaded() != 1 {
t.Errorf("Loaded() = %d, want 1", l.Loaded())
}
if _, ok := Get("good-mod"); !ok {
t.Error("good-mod not registered")
}
if _, ok := Get("bad-mod"); ok {
t.Error("bad-mod should not have registered")
}
}
func TestNewLoaderDirs(t *testing.T) {
l, err := NewLoader()
if err != nil {
t.Fatalf("NewLoader: %v", err)
}
if l.BuiltinDir() == "" {
t.Error("BuiltinDir is empty")
}
if l.UserDir() == "" {
t.Error("UserDir is empty")
}
}
// TestRegistry exercises the package-level registry: register, get, dedupe by
// id, filter by tag and type, count and clear.
func TestRegistry(t *testing.T) {
Clear()
t.Cleanup(Clear)
http1 := newYAMLModuleWrapper(&YAMLModule{ID: "h1", Type: TypeHTTP, Info: YAMLModuleInfo{Tags: []string{"web", "cve"}}}, "h1")
http2 := newYAMLModuleWrapper(&YAMLModule{ID: "h2", Type: TypeHTTP, Info: YAMLModuleInfo{Tags: []string{"web"}}}, "h2")
dns1 := newYAMLModuleWrapper(&YAMLModule{ID: "d1", Type: TypeDNS, Info: YAMLModuleInfo{Tags: []string{"dns"}}}, "d1")
Register(http1)
Register(http2)
Register(dns1)
if Count() != 3 {
t.Fatalf("Count() = %d, want 3", Count())
}
got, ok := Get("h1")
if !ok || got.Info().ID != "h1" {
t.Errorf("Get(h1) = %v, %v", got, ok)
}
if _, ok := Get("missing"); ok {
t.Error("Get(missing) should report not found")
}
if n := len(ByType(TypeHTTP)); n != 2 {
t.Errorf("ByType(http) = %d, want 2", n)
}
if n := len(ByType(TypeDNS)); n != 1 {
t.Errorf("ByType(dns) = %d, want 1", n)
}
if n := len(ByTag("web")); n != 2 {
t.Errorf("ByTag(web) = %d, want 2", n)
}
if n := len(ByTag("cve")); n != 1 {
t.Errorf("ByTag(cve) = %d, want 1", n)
}
if n := len(ByTag("none")); n != 0 {
t.Errorf("ByTag(none) = %d, want 0", n)
}
if n := len(All()); n != 3 {
t.Errorf("All() = %d, want 3", n)
}
// re-registering the same id overwrites rather than duplicating.
Register(newYAMLModuleWrapper(&YAMLModule{ID: "h1", Type: TypeHTTP}, "h1-v2"))
if Count() != 3 {
t.Errorf("Count() after re-register = %d, want 3", Count())
}
Clear()
if Count() != 0 {
t.Errorf("Count() after Clear = %d, want 0", Count())
}
}
// TestResultType pins the ScanResult interface bridge.
func TestResultType(t *testing.T) {
r := &Result{ModuleID: "abc"}
if r.ResultType() != "abc" {
t.Errorf("ResultType() = %q, want abc", r.ResultType())
}
}
// TestLoaderScriptStubNoop confirms the go-script loader is currently a no-op
// that registers nothing and returns no error.
func TestLoaderScriptStubNoop(t *testing.T) {
l := &Loader{}
if err := l.loadScript("anything.go"); err != nil {
t.Errorf("loadScript stub returned error: %v", err)
}
}
+106
View File
@@ -0,0 +1,106 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runLoginModule(t *testing.T, file string, status int, headers map[string]string, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for k, v := range headers {
w.Header().Set(k, v)
}
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func loginExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestLoginPanelModules(t *testing.T) {
const grafana = "../../modules/info/grafana-panel.yaml"
const kibana = "../../modules/info/kibana-panel.yaml"
const jenkins = "../../modules/info/jenkins-panel.yaml"
grafanaBody := `<body class="app-grafana"><grafana-app></grafana-app>` +
`<script>window.grafanaBootData = {"settings":{"buildInfo":{"version":"10.4.2","commit":"abc"}}};</script></body>`
kibanaBody := `<div data-test-subj="kibanaChrome"><kbn-injected-metadata data="x"></kbn-injected-metadata></div>`
t.Run("grafana login", func(t *testing.T) {
res := runLoginModule(t, grafana, 200, nil, grafanaBody)
if len(res.Findings) == 0 {
t.Fatal("expected a grafana finding")
}
if v := loginExtract(res, "grafana_version"); v != "10.4.2" {
t.Errorf("grafana_version=%q, want 10.4.2", v)
}
})
t.Run("kibana via response headers", func(t *testing.T) {
res := runLoginModule(t, kibana, 200, map[string]string{"kbn-version": "8.13.0", "kbn-name": "node-1"}, kibanaBody)
if len(res.Findings) == 0 {
t.Fatal("expected a kibana finding")
}
if v := loginExtract(res, "kibana_version"); v != "8.13.0" {
t.Errorf("kibana_version=%q, want 8.13.0", v)
}
})
t.Run("jenkins via X-Jenkins header on a 403", func(t *testing.T) {
res := runLoginModule(t, jenkins, 403, map[string]string{"X-Jenkins": "2.426.1"},
`<html><head><title>Authentication required</title></head></html>`)
if len(res.Findings) == 0 {
t.Fatal("expected a jenkins finding")
}
if v := loginExtract(res, "jenkins_version"); v != "2.426.1" {
t.Errorf("jenkins_version=%q, want 2.426.1", v)
}
})
t.Run("unrelated page is not a panel", func(t *testing.T) {
for _, file := range []string{grafana, kibana, jenkins} {
if res := runLoginModule(t, file, 200, nil, "<html><body>plain</body></html>"); len(res.Findings) > 0 {
t.Errorf("%s: unrelated page should not match, got %d findings", file, len(res.Findings))
}
}
})
}
@@ -0,0 +1,155 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runMgmtModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func mgmtExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestManagementAPIExposureModules(t *testing.T) {
const kong = "../../modules/recon/kong-api-exposure.yaml"
const jolokia = "../../modules/recon/jolokia-api-exposure.yaml"
const nats = "../../modules/recon/nats-api-exposure.yaml"
kongRoot := `{"version":"3.4.0","tagline":"Welcome to kong","hostname":"kong-node","node_id":"abc",` +
`"lua_version":"LuaJIT 2.1.0","plugins":{"available_on_server":{}},` +
`"configuration":{"database":"postgres","admin_listen":["0.0.0.0:8001"]}}`
jolokiaVersion := `{"request":{"type":"version"},"value":{"agent":"1.7.2","protocol":"7.2",` +
`"config":{"agentType":"servlet"},"info":{"product":"tomcat"}},"status":200,"timestamp":1694598949}`
natsVarz := `{"server_id":"NDABC","server_name":"NDABC","version":"2.10.1","proto":1,"go":"go1.21.1",` +
`"host":"0.0.0.0","port":4222,"max_connections":65536,"max_payload":1048576,"connections":3,"total_connections":10}`
t.Run("an exposed kong admin api is flagged and versioned", func(t *testing.T) {
res := runMgmtModule(t, kong, 200, kongRoot)
if len(res.Findings) == 0 {
t.Fatal("expected a kong finding")
}
if v := mgmtExtract(res, "kong_version"); v != "3.4.0" {
t.Errorf("kong_version=%q, want 3.4.0", v)
}
})
t.Run("an exposed jolokia agent is flagged and versioned", func(t *testing.T) {
res := runMgmtModule(t, jolokia, 200, jolokiaVersion)
if len(res.Findings) == 0 {
t.Fatal("expected a jolokia finding")
}
if v := mgmtExtract(res, "jolokia_agent_version"); v != "1.7.2" {
t.Errorf("jolokia_agent_version=%q, want 1.7.2", v)
}
})
t.Run("an exposed nats monitor is flagged and versioned", func(t *testing.T) {
res := runMgmtModule(t, nats, 200, natsVarz)
if len(res.Findings) == 0 {
t.Fatal("expected a nats finding")
}
if v := mgmtExtract(res, "nats_version"); v != "2.10.1" {
t.Errorf("nats_version=%q, want 2.10.1", v)
}
})
t.Run("an available plugins map without an admin listen is not flagged", func(t *testing.T) {
body := `{"plugins":{"available_on_server":{}},"version":"3.4.0"}`
if res := runMgmtModule(t, kong, 200, body); len(res.Findings) > 0 {
t.Errorf("an available plugins map alone should not match kong, got %d findings", len(res.Findings))
}
})
t.Run("an admin listen without an available plugins map is not flagged", func(t *testing.T) {
body := `{"configuration":{"admin_listen":["0.0.0.0:8001"]},"version":"1.0"}`
if res := runMgmtModule(t, kong, 200, body); len(res.Findings) > 0 {
t.Errorf("an admin listen alone should not match kong, got %d findings", len(res.Findings))
}
})
t.Run("a jolokia agent without a protocol is not flagged", func(t *testing.T) {
body := `{"value":{"agent":"1.7.2"}}`
if res := runMgmtModule(t, jolokia, 200, body); len(res.Findings) > 0 {
t.Errorf("an agent alone should not match jolokia, got %d findings", len(res.Findings))
}
})
t.Run("a jolokia protocol without an agent is not flagged", func(t *testing.T) {
body := `{"value":{"protocol":"7.2"},"info":{}}`
if res := runMgmtModule(t, jolokia, 200, body); len(res.Findings) > 0 {
t.Errorf("a protocol alone should not match jolokia, got %d findings", len(res.Findings))
}
})
t.Run("a nats server id without a max payload is not flagged", func(t *testing.T) {
body := `{"server_id":"NDABC","version":"2.10.1"}`
if res := runMgmtModule(t, nats, 200, body); len(res.Findings) > 0 {
t.Errorf("a server id alone should not match nats, got %d findings", len(res.Findings))
}
})
t.Run("a max payload without a nats server id is not flagged", func(t *testing.T) {
body := `{"max_payload":1048576,"port":4222}`
if res := runMgmtModule(t, nats, 200, body); len(res.Findings) > 0 {
t.Errorf("a max payload alone should not match nats, got %d findings", len(res.Findings))
}
})
t.Run("a generic version json is not a management api", func(t *testing.T) {
body := `{"version":"1.0.0","name":"app"}`
for _, file := range []string{kong, jolokia, nats} {
if res := runMgmtModule(t, file, 200, body); len(res.Findings) > 0 {
t.Errorf("%s: a generic version should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{kong, jolokia, nats} {
if res := runMgmtModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{kong, jolokia, nats} {
if res := runMgmtModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
+132
View File
@@ -0,0 +1,132 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package modules
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/dropalldatabases/sif/internal/httpx"
)
func TestCheckMatchersCondition(t *testing.T) {
const body = "hello world"
resp := fakeResponse(t, 200, nil)
status200 := Matcher{Type: "status", Status: []int{200}}
status500 := Matcher{Type: "status", Status: []int{500}}
wordHit := Matcher{Type: "word", Part: "body", Words: []string{"hello"}}
wordMiss := Matcher{Type: "word", Part: "body", Words: []string{"absent"}}
tests := []struct {
name string
condition string
matchers []Matcher
expect bool
}{
{"and both match", "and", []Matcher{status200, wordHit}, true},
{"and one fails", "and", []Matcher{status200, wordMiss}, false},
{"empty defaults to and", "", []Matcher{status200, wordMiss}, false},
{"or one matches", "or", []Matcher{status500, wordHit}, true},
{"or none match", "or", []Matcher{status500, wordMiss}, false},
{"or all match", "or", []Matcher{status200, wordHit}, true},
{"or is case-insensitive", "OR", []Matcher{status500, wordHit}, true},
{"and is case-insensitive", "AND", []Matcher{status200, wordMiss}, false},
{"or with negative pass", "or", []Matcher{{Type: "word", Part: "body", Words: []string{"absent"}, Negative: true}}, true},
{"or all fail with negative", "or", []Matcher{{Type: "word", Part: "body", Words: []string{"hello"}, Negative: true}, wordMiss}, false},
{"empty matcher list", "or", nil, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := checkMatchers(tt.matchers, tt.condition, resp, body); got != tt.expect {
t.Errorf("checkMatchers(%q) = %v, want %v", tt.condition, got, tt.expect)
}
})
}
}
func TestValidateMatchersCondition(t *testing.T) {
for _, ok := range []string{"", "and", "or", "AND", "Or"} {
if err := validateMatchersCondition(ok); err != nil {
t.Errorf("%q should be valid: %v", ok, err)
}
}
for _, bad := range []string{"xor", "nand", "any", "&&"} {
if err := validateMatchersCondition(bad); err == nil {
t.Errorf("%q should be rejected", bad)
}
}
}
func TestParseMatchersConditionValidation(t *testing.T) {
write := func(cond string) string {
p := filepath.Join(t.TempDir(), "m.yaml")
body := fmt.Sprintf("id: mc\ntype: http\nhttp:\n method: GET\n paths: [\"{{BaseURL}}\"]\n matchers-condition: %s\n matchers:\n - type: status\n status: [200]\n", cond)
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
return p
}
if _, err := ParseYAMLModule(write("or")); err != nil {
t.Errorf("matchers-condition: or should parse: %v", err)
}
if _, err := ParseYAMLModule(write("xor")); err == nil {
t.Error("matchers-condition: xor should be rejected at load")
}
}
// or fires on the word match alone; and does not (status:500 fails).
func TestExecuteHTTPModuleMatchersConditionOr(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("hello"))
}))
defer srv.Close()
def := &YAMLModule{
ID: "mc",
Type: TypeHTTP,
Info: YAMLModuleInfo{Severity: "info"},
HTTP: &HTTPConfig{
Method: "GET",
Paths: []string{"{{BaseURL}}/"},
Matchers: []Matcher{
{Type: "status", Status: []int{500}},
{Type: "word", Part: "body", Words: []string{"hello"}},
},
},
}
opts := Options{Timeout: testTimeout, Client: httpx.Client(testTimeout)}
def.HTTP.MatchersCondition = "or"
res, err := ExecuteHTTPModule(context.Background(), srv.URL, def, opts)
if err != nil {
t.Fatalf("or: %v", err)
}
if len(res.Findings) != 1 {
t.Fatalf("or: got %d findings, want 1", len(res.Findings))
}
def.HTTP.MatchersCondition = ""
res, err = ExecuteHTTPModule(context.Background(), srv.URL, def, opts)
if err != nil {
t.Fatalf("and: %v", err)
}
if len(res.Findings) != 0 {
t.Fatalf("and: got %d findings, want 0 (status:500 fails)", len(res.Findings))
}
}
+531
View File
@@ -0,0 +1,531 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package modules
import (
"net/http"
"os"
"path/filepath"
"strings"
"testing"
)
// fakeResponse builds a minimal *http.Response for matcher/extractor tests.
// it carries no real socket (Body is http.NoBody), so there is nothing to
// close; bodyclose is excluded for test files in .golangci.yml. header drives
// the header/all parts without a live server; matchers read the body string
// argument, not resp.Body.
func fakeResponse(t *testing.T, status int, header http.Header) *http.Response {
t.Helper()
if header == nil {
header = http.Header{}
}
return &http.Response{StatusCode: status, Header: header, Body: http.NoBody}
}
func TestCheckMatcherStatus(t *testing.T) {
tests := []struct {
name string
status int
want []int
expect bool
}{
{name: "single match", status: 200, want: []int{200}, expect: true},
{name: "one of many", status: 404, want: []int{200, 301, 404}, expect: true},
{name: "no match", status: 500, want: []int{200, 404}, expect: false},
{name: "empty status list", status: 200, want: nil, expect: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := &Matcher{Type: "status", Status: tt.want}
resp := fakeResponse(t, tt.status, nil)
if got := checkMatcher(m, resp, ""); got != tt.expect {
t.Errorf("checkMatcher status = %v, want %v", got, tt.expect)
}
})
}
}
func TestCheckMatcherWord(t *testing.T) {
const body = "welcome admin dashboard"
tests := []struct {
name string
words []string
condition string
expect bool
}{
{name: "and all present", words: []string{"admin", "dashboard"}, condition: "and", expect: true},
{name: "and one missing", words: []string{"admin", "missing"}, condition: "and", expect: false},
{name: "default is and", words: []string{"admin", "missing"}, condition: "", expect: false},
{name: "or one present", words: []string{"missing", "admin"}, condition: "or", expect: true},
{name: "or none present", words: []string{"missing", "absent"}, condition: "or", expect: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := &Matcher{Type: "word", Part: "body", Words: tt.words, Condition: tt.condition}
resp := fakeResponse(t, 200, nil)
if got := checkMatcher(m, resp, body); got != tt.expect {
t.Errorf("checkMatcher word = %v, want %v", got, tt.expect)
}
})
}
}
func TestCheckMatcherRegex(t *testing.T) {
const body = "version 1.2.3 build 99"
tests := []struct {
name string
patterns []string
condition string
expect bool
}{
{name: "and all match", patterns: []string{`version \d`, `build \d+`}, condition: "and", expect: true},
{name: "and one fails", patterns: []string{`version \d`, `nope\d`}, condition: "and", expect: false},
{name: "or one matches", patterns: []string{`nope`, `build \d+`}, condition: "or", expect: true},
{name: "or none match", patterns: []string{`nope`, `zilch`}, condition: "or", expect: false},
// an invalid pattern under AND must fail closed, not panic.
{name: "and invalid pattern fails closed", patterns: []string{`version \d`, `(`}, condition: "and", expect: false},
// under OR an invalid pattern is skipped, a later valid one can still hit.
{name: "or invalid pattern skipped", patterns: []string{`(`, `build \d+`}, condition: "or", expect: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := &Matcher{Type: "regex", Part: "body", Regex: tt.patterns, Condition: tt.condition}
resp := fakeResponse(t, 200, nil)
if got := checkMatcher(m, resp, body); got != tt.expect {
t.Errorf("checkMatcher regex = %v, want %v", got, tt.expect)
}
})
}
}
func TestCheckMatcherHeaderPart(t *testing.T) {
header := http.Header{"X-Powered-By": []string{"PHP/8.1"}}
resp := fakeResponse(t, 200, header)
m := &Matcher{Type: "word", Part: "header", Words: []string{"PHP/8.1"}}
if !checkMatcher(m, resp, "body-content") {
t.Error("expected header-part word matcher to hit on header value")
}
// the same word lives only in the header, so a body-part matcher must miss.
mBody := &Matcher{Type: "word", Part: "body", Words: []string{"PHP/8.1"}}
if checkMatcher(mBody, resp, "body-content") {
t.Error("body-part matcher should not see header-only value")
}
}
func TestCheckMatcherUnknownType(t *testing.T) {
m := &Matcher{Type: "size", Part: "body"}
resp := fakeResponse(t, 200, nil)
if checkMatcher(m, resp, "anything") {
t.Error("unknown matcher type should not match")
}
}
func TestCheckMatchers(t *testing.T) {
resp := fakeResponse(t, 200, http.Header{"Server": []string{"nginx"}})
const body = "secret token here"
tests := []struct {
name string
matchers []Matcher
expect bool
}{
{
name: "empty matchers never match",
matchers: nil,
expect: false,
},
{
name: "all matchers pass (AND across matchers)",
matchers: []Matcher{
{Type: "status", Status: []int{200}},
{Type: "word", Part: "body", Words: []string{"secret"}},
},
expect: true,
},
{
name: "one matcher fails breaks AND",
matchers: []Matcher{
{Type: "status", Status: []int{200}},
{Type: "word", Part: "body", Words: []string{"absent"}},
},
expect: false,
},
{
name: "negative inverts a non-match into a pass",
matchers: []Matcher{
{Type: "word", Part: "body", Words: []string{"absent"}, Negative: true},
},
expect: true,
},
{
name: "negative inverts a match into a fail",
matchers: []Matcher{
{Type: "word", Part: "body", Words: []string{"secret"}, Negative: true},
},
expect: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := checkMatchers(tt.matchers, "", resp, body); got != tt.expect {
t.Errorf("checkMatchers = %v, want %v", got, tt.expect)
}
})
}
}
func TestCheckWords(t *testing.T) {
const content = "alpha beta gamma"
tests := []struct {
name string
words []string
condition string
expect bool
}{
{name: "and all present", words: []string{"alpha", "gamma"}, condition: "and", expect: true},
{name: "and missing", words: []string{"alpha", "delta"}, condition: "and", expect: false},
{name: "or present", words: []string{"delta", "beta"}, condition: "or", expect: true},
{name: "or absent", words: []string{"delta", "epsilon"}, condition: "or", expect: false},
{name: "empty under and matches vacuously", words: nil, condition: "and", expect: true},
{name: "empty under or matches nothing", words: nil, condition: "or", expect: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := checkWords(content, tt.words, tt.condition); got != tt.expect {
t.Errorf("checkWords = %v, want %v", got, tt.expect)
}
})
}
}
func TestCheckRegex(t *testing.T) {
const content = "id=42 name=root"
tests := []struct {
name string
patterns []string
condition string
expect bool
}{
{name: "and all match", patterns: []string{`id=\d+`, `name=\w+`}, condition: "and", expect: true},
{name: "and one fails", patterns: []string{`id=\d+`, `zzz`}, condition: "and", expect: false},
{name: "or first matches", patterns: []string{`id=\d+`, `zzz`}, condition: "or", expect: true},
{name: "or none match", patterns: []string{`xxx`, `zzz`}, condition: "or", expect: false},
{name: "and bad regex fails closed", patterns: []string{`(`}, condition: "and", expect: false},
{name: "or bad regex skipped then match", patterns: []string{`(`, `name=\w+`}, condition: "or", expect: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := checkRegex(content, tt.patterns, tt.condition); got != tt.expect {
t.Errorf("checkRegex = %v, want %v", got, tt.expect)
}
})
}
}
func TestGetPart(t *testing.T) {
header := http.Header{"Server": []string{"nginx"}}
resp := fakeResponse(t, 200, header)
const body = "page body"
if got := getPart("body", resp, body); got != body {
t.Errorf("getPart body = %q, want %q", got, body)
}
headerPart := getPart("header", resp, body)
if !strings.Contains(headerPart, "Server") || !strings.Contains(headerPart, "nginx") {
t.Errorf("getPart header = %q, want it to include the header", headerPart)
}
if strings.Contains(headerPart, body) {
t.Errorf("getPart header should not include body, got %q", headerPart)
}
all := getPart("all", resp, body)
if !strings.Contains(all, "nginx") || !strings.Contains(all, body) {
t.Errorf("getPart all = %q, want both header and body", all)
}
// an unrecognised part falls back to the body.
if got := getPart("weird", resp, body); got != body {
t.Errorf("getPart fallback = %q, want body %q", got, body)
}
// empty part behaves like "all".
if got := getPart("", resp, body); !strings.Contains(got, "nginx") || !strings.Contains(got, body) {
t.Errorf("getPart empty = %q, want both header and body", got)
}
}
func TestRunExtractors(t *testing.T) {
resp := fakeResponse(t, 200, http.Header{"X-Token": []string{"abc123"}})
const body = `{"session":"sess-7788","role":"admin"}`
tests := []struct {
name string
extractors []Extractor
wantKey string
wantVal string
wantNil bool
}{
{
name: "no extractors yields nil",
extractors: nil,
wantNil: true,
},
{
name: "regex capture group on body",
extractors: []Extractor{
{Type: "regex", Name: "session", Part: "body", Regex: []string{`"session":"([^"]+)"`}, Group: 1},
},
wantKey: "session",
wantVal: "sess-7788",
},
{
name: "group zero is the whole match",
extractors: []Extractor{
{Type: "regex", Name: "role", Part: "body", Regex: []string{`role":"admin`}, Group: 0},
},
wantKey: "role",
wantVal: `role":"admin`,
},
{
name: "extract from header part",
extractors: []Extractor{
{Type: "regex", Name: "token", Part: "header", Regex: []string{`X-Token: (\S+)`}, Group: 1},
},
wantKey: "token",
wantVal: "abc123",
},
{
name: "first matching pattern wins",
extractors: []Extractor{
{Type: "regex", Name: "session", Part: "body", Regex: []string{`nomatch(\d+)`, `"session":"([^"]+)"`}, Group: 1},
},
wantKey: "session",
wantVal: "sess-7788",
},
{
name: "group index out of range is skipped",
extractors: []Extractor{
{Type: "regex", Name: "session", Part: "body", Regex: []string{`"session":"([^"]+)"`}, Group: 5},
},
wantNil: true,
},
{
name: "invalid pattern is skipped, no capture",
extractors: []Extractor{
{Type: "regex", Name: "session", Part: "body", Regex: []string{`(`}, Group: 1},
},
wantNil: true,
},
{
name: "unknown extractor type is ignored",
extractors: []Extractor{
{Type: "bogus", Name: "session", Part: "body", Regex: []string{`"session":"([^"]+)"`}, Group: 1},
},
wantNil: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := runExtractors(tt.extractors, resp, body)
if tt.wantNil {
if len(got) != 0 {
t.Errorf("runExtractors = %v, want empty", got)
}
return
}
if got[tt.wantKey] != tt.wantVal {
t.Errorf("runExtractors[%q] = %q, want %q", tt.wantKey, got[tt.wantKey], tt.wantVal)
}
})
}
}
func TestSubstituteVariables(t *testing.T) {
tests := []struct {
name string
template string
baseURL string
payload string
want string
}{
{
name: "baseurl both cases",
template: "{{BaseURL}}/x and {{baseurl}}/y",
baseURL: "http://h",
want: "http://h/x and http://h/y",
},
{
name: "payload both cases",
template: "q={{payload}}&r={{Payload}}",
payload: "<script>",
want: "q=<script>&r=<script>",
},
{
name: "combined base and payload",
template: "{{BaseURL}}/search?q={{payload}}",
baseURL: "http://h",
payload: "x",
want: "http://h/search?q=x",
},
{
name: "no placeholders untouched",
template: "/static/path",
baseURL: "http://h",
want: "/static/path",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := substituteVariables(tt.template, tt.baseURL, tt.payload); got != tt.want {
t.Errorf("substituteVariables = %q, want %q", got, tt.want)
}
})
}
}
func TestGenerateHTTPRequests(t *testing.T) {
t.Run("paths without payloads", func(t *testing.T) {
cfg := &HTTPConfig{
Paths: []string{"{{BaseURL}}/a", "{{BaseURL}}/b"},
}
// trailing slash on the target must be trimmed before substitution.
got, err := generateHTTPRequests("http://h/", cfg)
if err != nil {
t.Fatalf("generate: %v", err)
}
if len(got) != 2 {
t.Fatalf("got %d requests, want 2", len(got))
}
if got[0].Method != "GET" {
t.Errorf("default method = %q, want GET", got[0].Method)
}
if got[0].URL != "http://h/a" || got[1].URL != "http://h/b" {
t.Errorf("urls = %q,%q", got[0].URL, got[1].URL)
}
})
t.Run("payload expansion is path x payload", func(t *testing.T) {
cfg := &HTTPConfig{
Method: "POST",
Paths: []string{"{{BaseURL}}/q?x={{payload}}"},
Payloads: []string{"1", "2", "3"},
Body: "data={{payload}}",
}
got, err := generateHTTPRequests("http://h", cfg)
if err != nil {
t.Fatalf("generate: %v", err)
}
if len(got) != 3 {
t.Fatalf("got %d requests, want 3", len(got))
}
for i, want := range []string{"1", "2", "3"} {
if got[i].Payload != want {
t.Errorf("req %d payload = %q, want %q", i, got[i].Payload, want)
}
if got[i].URL != "http://h/q?x="+want {
t.Errorf("req %d url = %q", i, got[i].URL)
}
if got[i].Body != "data="+want {
t.Errorf("req %d body = %q", i, got[i].Body)
}
if got[i].Method != "POST" {
t.Errorf("req %d method = %q, want POST", i, got[i].Method)
}
}
})
t.Run("multiple paths times multiple payloads", func(t *testing.T) {
cfg := &HTTPConfig{
Paths: []string{"{{BaseURL}}/a", "{{BaseURL}}/b"},
Payloads: []string{"x", "y"},
}
got, err := generateHTTPRequests("http://h", cfg)
if err != nil {
t.Fatalf("generate: %v", err)
}
if len(got) != 4 {
t.Fatalf("got %d requests, want 4 (2 paths x 2 payloads)", len(got))
}
})
t.Run("wordlist expands {{word}} paths", func(t *testing.T) {
list := filepath.Join(t.TempDir(), "words.txt")
if err := os.WriteFile(list, []byte("admin\n\nconfig\nbackup\n"), 0o600); err != nil {
t.Fatal(err)
}
cfg := &HTTPConfig{
Paths: []string{"{{BaseURL}}/{{word}}", "{{BaseURL}}/.git/HEAD"},
Wordlist: list,
}
got, err := generateHTTPRequests("http://h", cfg)
if err != nil {
t.Fatalf("generate: %v", err)
}
// 3 words (the blank line is skipped) fuzz the templated path, then the
// literal path passes through untouched.
want := []string{"http://h/admin", "http://h/config", "http://h/backup", "http://h/.git/HEAD"}
if len(got) != len(want) {
t.Fatalf("got %d requests, want %d", len(got), len(want))
}
for i, w := range want {
if got[i].URL != w {
t.Errorf("req %d url = %q, want %q", i, got[i].URL, w)
}
}
})
t.Run("wordlist crosses with payloads", func(t *testing.T) {
list := filepath.Join(t.TempDir(), "words.txt")
if err := os.WriteFile(list, []byte("a\nb\n"), 0o600); err != nil {
t.Fatal(err)
}
cfg := &HTTPConfig{
Paths: []string{"{{BaseURL}}/{{word}}?q={{payload}}"},
Wordlist: list,
Payloads: []string{"1", "2", "3"},
}
got, err := generateHTTPRequests("http://h", cfg)
if err != nil {
t.Fatalf("generate: %v", err)
}
if len(got) != 6 {
t.Fatalf("got %d requests, want 6 (2 words x 3 payloads)", len(got))
}
})
t.Run("missing wordlist errors", func(t *testing.T) {
cfg := &HTTPConfig{
Paths: []string{"{{BaseURL}}/{{word}}"},
Wordlist: filepath.Join(t.TempDir(), "nope.txt"),
}
if _, err := generateHTTPRequests("http://h", cfg); err == nil {
t.Fatal("want error for missing wordlist, got nil")
}
})
}
+138
View File
@@ -0,0 +1,138 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runMetricsModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func metricsExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestMetricsExposureModules(t *testing.T) {
const netdata = "../../modules/recon/netdata-api-exposure.yaml"
const cadvisor = "../../modules/recon/cadvisor-api-exposure.yaml"
netdataInfo := `{"version":"v1.44.0","uid":"6c5c8a3f","mirrored_hosts":["localhost"],` +
`"mirrored_hosts_status":[{"guid":"6c5c8a3f","reachable":true}],"os_name":"Debian GNU/Linux",` +
`"cores_total":"8","total_disk_space":"512000000000"}`
cadvisorMachine := `{"num_cores":8,"num_physical_cores":4,"num_sockets":1,"cpu_frequency_khz":2904000,` +
`"memory_capacity":16777216000,"machine_id":"a1b2c3d4e5f60718293a4b5c6d7e8f90",` +
`"system_uuid":"4C4C4544-0042-3110-8044-B7C04F564432","boot_id":"f0e1d2c3"}`
t.Run("an exposed netdata info endpoint is flagged and versioned", func(t *testing.T) {
res := runMetricsModule(t, netdata, 200, netdataInfo)
if len(res.Findings) == 0 {
t.Fatal("expected a netdata finding")
}
if v := metricsExtract(res, "netdata_version"); v != "v1.44.0" {
t.Errorf("netdata_version=%q, want v1.44.0", v)
}
})
t.Run("an exposed cadvisor machine endpoint is flagged with the machine id", func(t *testing.T) {
res := runMetricsModule(t, cadvisor, 200, cadvisorMachine)
if len(res.Findings) == 0 {
t.Fatal("expected a cadvisor finding")
}
if v := metricsExtract(res, "cadvisor_machine_id"); v != "a1b2c3d4e5f60718293a4b5c6d7e8f90" {
t.Errorf("cadvisor_machine_id=%q, want the machine id", v)
}
})
t.Run("netdata mirrored hosts without cores total is not flagged", func(t *testing.T) {
body := `{"version":"v1.44.0","mirrored_hosts":["localhost"]}`
if res := runMetricsModule(t, netdata, 200, body); len(res.Findings) > 0 {
t.Errorf("mirrored hosts alone should not match netdata, got %d findings", len(res.Findings))
}
})
t.Run("netdata cores total without mirrored hosts is not flagged", func(t *testing.T) {
body := `{"version":"v1.44.0","cores_total":"8"}`
if res := runMetricsModule(t, netdata, 200, body); len(res.Findings) > 0 {
t.Errorf("cores total alone should not match netdata, got %d findings", len(res.Findings))
}
})
t.Run("cadvisor machine id without a cpu frequency is not flagged", func(t *testing.T) {
body := `{"machine_id":"a1b2c3d4e5f60718293a4b5c6d7e8f90","num_cores":8}`
if res := runMetricsModule(t, cadvisor, 200, body); len(res.Findings) > 0 {
t.Errorf("a machine id alone should not match cadvisor, got %d findings", len(res.Findings))
}
})
t.Run("cadvisor cpu frequency without a machine id is not flagged", func(t *testing.T) {
body := `{"cpu_frequency_khz":2904000,"num_cores":8}`
if res := runMetricsModule(t, cadvisor, 200, body); len(res.Findings) > 0 {
t.Errorf("a cpu frequency alone should not match cadvisor, got %d findings", len(res.Findings))
}
})
t.Run("a generic metrics json is not netdata", func(t *testing.T) {
body := `{"status":"ok","data":{"result":[]}}`
if res := runMetricsModule(t, netdata, 200, body); len(res.Findings) > 0 {
t.Errorf("a generic json should not match netdata, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{netdata, cadvisor} {
if res := runMetricsModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{netdata, cadvisor} {
if res := runMetricsModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
+6 -3
View File
@@ -4,7 +4,7 @@
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2025 vmfunc (vmfunc), xyzeva, :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
@@ -86,11 +86,13 @@ type Finding struct {
// Matcher defines matching logic for module responses.
// Matchers are used to determine if a response indicates a vulnerability.
type Matcher struct {
Type string `yaml:"type"` // regex, status, word, size
Type string `yaml:"type"` // regex, status, word, favicon
Part string `yaml:"part"` // body, header, all
Regex []string `yaml:"regex,omitempty"`
Words []string `yaml:"words,omitempty"`
Status []int `yaml:"status,omitempty"`
Size []int `yaml:"size,omitempty"`
Hash []int64 `yaml:"hash,omitempty"` // favicon: shodan mmh3 hashes (signed or unsigned)
Condition string `yaml:"condition"` // and, or
Negative bool `yaml:"negative"`
}
@@ -98,9 +100,10 @@ type Matcher struct {
// Extractor defines data extraction from responses.
// Extractors pull specific data from matched responses for reporting.
type Extractor struct {
Type string `yaml:"type"` // regex, kval, json
Type string `yaml:"type"` // regex, kv, json
Name string `yaml:"name"`
Part string `yaml:"part"`
Regex []string `yaml:"regex,omitempty"`
JSON []string `yaml:"json,omitempty"`
Group int `yaml:"group"`
}
+112
View File
@@ -0,0 +1,112 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
// runOpsModule runs a shipped module end to end against a server that returns
// the same status and body for every path it requests.
func runOpsModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func opsExtracted(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v, ok := f.Extracted[key]; ok {
return v
}
}
return ""
}
func TestOpsPanelModules(t *testing.T) {
cases := []struct {
name string
file string
status int
body string
wantFinding bool
versionKey string
versionVal string
}{
{
name: "portainer status api", file: "../../modules/info/portainer-panel.yaml", status: 200,
body: `{"Edition":"CE","Version":"2.19.4","InstanceID":"a1b2c3"}`,
wantFinding: true, versionKey: "portainer_version", versionVal: "2.19.4",
},
{
name: "portainer version-only json is not a match", file: "../../modules/info/portainer-panel.yaml", status: 200,
body: `{"Version":"1.0.0"}`, wantFinding: false,
},
{
name: "portainer real body behind a 404 is not a match", file: "../../modules/info/portainer-panel.yaml", status: 404,
body: `{"Edition":"CE","Version":"2.19.4","InstanceID":"a1b2c3"}`, wantFinding: false,
},
{
name: "traefik version api", file: "../../modules/info/traefik-panel.yaml", status: 200,
body: `{"Version":"2.10.4","Codename":"saintnectaire","startDate":"2024-01-01T00:00:00Z"}`,
wantFinding: true, versionKey: "traefik_version", versionVal: "2.10.4",
},
{
name: "traefik without codename is not a match", file: "../../modules/info/traefik-panel.yaml", status: 200,
body: `{"Version":"2.10.4"}`, wantFinding: false,
},
{
name: "keycloak realm endpoint", file: "../../modules/info/keycloak-panel.yaml", status: 200,
body: `{"realm":"master","public_key":"MIIBIjAN","token-service":"https://h/realms/master/protocol/openid-connect","account-service":"https://h/realms/master/account"}`,
wantFinding: true, versionKey: "keycloak_realm", versionVal: "master",
},
{
name: "keycloak partial realm json is not a match", file: "../../modules/info/keycloak-panel.yaml", status: 200,
body: `{"realm":"master","public_key":"MIIBIjAN"}`, wantFinding: false,
},
{
name: "rabbitmq management ui", file: "../../modules/info/rabbitmq-panel.yaml", status: 200,
body: `<!DOCTYPE html><html><head><title>RabbitMQ Management</title></head><body><img src="img/rabbitmqlogo.svg"></body></html>`,
wantFinding: true,
},
{
name: "rabbitmq unrelated page is not a match", file: "../../modules/info/rabbitmq-panel.yaml", status: 200,
body: `<html><body>nothing to see</body></html>`, wantFinding: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
res := runOpsModule(t, tc.file, tc.status, tc.body)
got := len(res.Findings) > 0
if got != tc.wantFinding {
t.Fatalf("findings=%d, want match=%v", len(res.Findings), tc.wantFinding)
}
if tc.versionKey != "" {
if v := opsExtracted(res, tc.versionKey); v != tc.versionVal {
t.Errorf("extracted[%q]=%q, want %q", tc.versionKey, v, tc.versionVal)
}
}
})
}
}
@@ -0,0 +1,132 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runOrchModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func orchExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestOrchestrationAPIExposureModules(t *testing.T) {
const vault = "../../modules/recon/vault-api-exposure.yaml"
const consul = "../../modules/recon/consul-api-exposure.yaml"
const etcd = "../../modules/recon/etcd-api-exposure.yaml"
vaultSeal := `{"type":"shamir","initialized":true,"sealed":false,"t":3,"n":5,` +
`"progress":0,"nonce":"","version":"1.15.2","build_date":"2023-11-06T11:33:49Z",` +
`"migration":false,"cluster_name":"vault-cluster-9d52b1f1","recovery_seal":false,` +
`"storage_type":"raft"}`
consulSelf := `{"Config":{"Datacenter":"dc1","NodeName":"consul-server-1","Server":true,` +
`"Version":"1.17.0"},"Member":{"Name":"consul-server-1","Addr":"10.0.0.5","Port":8301}}`
etcdVersion := `{"etcdserver":"3.5.9","etcdcluster":"3.5.0"}`
t.Run("an exposed vault seal-status is flagged and versioned", func(t *testing.T) {
res := runOrchModule(t, vault, 200, vaultSeal)
if len(res.Findings) == 0 {
t.Fatal("expected a vault finding")
}
if v := orchExtract(res, "vault_version"); v != "1.15.2" {
t.Errorf("vault_version=%q, want 1.15.2", v)
}
})
t.Run("an exposed consul agent self leaks the datacenter", func(t *testing.T) {
res := runOrchModule(t, consul, 200, consulSelf)
if len(res.Findings) == 0 {
t.Fatal("expected a consul finding")
}
if v := orchExtract(res, "consul_datacenter"); v != "dc1" {
t.Errorf("consul_datacenter=%q, want dc1", v)
}
})
t.Run("an exposed etcd version endpoint is flagged and versioned", func(t *testing.T) {
res := runOrchModule(t, etcd, 200, etcdVersion)
if len(res.Findings) == 0 {
t.Fatal("expected an etcd finding")
}
if v := orchExtract(res, "etcd_version"); v != "3.5.9" {
t.Errorf("etcd_version=%q, want 3.5.9", v)
}
})
t.Run("a sealed flag without the other vault keys is not vault", func(t *testing.T) {
body := `{"sealed":"yes","status":"ok"}`
if res := runOrchModule(t, vault, 200, body); len(res.Findings) > 0 {
t.Errorf("a bare sealed flag should not match, got %d findings", len(res.Findings))
}
})
t.Run("a datacenter field alone is not consul", func(t *testing.T) {
body := `{"Datacenter":"dc1"}`
if res := runOrchModule(t, consul, 200, body); len(res.Findings) > 0 {
t.Errorf("a bare datacenter field should not match, got %d findings", len(res.Findings))
}
})
t.Run("a version response from another service is not etcd", func(t *testing.T) {
body := `{"version":"1.2.3","service":"myapp"}`
if res := runOrchModule(t, etcd, 200, body); len(res.Findings) > 0 {
t.Errorf("another service version should not match, got %d findings", len(res.Findings))
}
})
t.Run("an etcdserver without an etcdcluster is not flagged", func(t *testing.T) {
body := `{"etcdserver":"3.5.9"}`
if res := runOrchModule(t, etcd, 200, body); len(res.Findings) > 0 {
t.Errorf("a partial etcd response should not match, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{vault, consul, etcd} {
if res := runOrchModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{vault, consul, etcd} {
if res := runOrchModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
@@ -0,0 +1,131 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runRailsModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func railsExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestRailsSecretExposureModules(t *testing.T) {
const database = "../../modules/recon/rails-database-yml-exposure.yaml"
const secrets = "../../modules/recon/rails-secrets-yml-exposure.yaml"
const masterKey = "../../modules/recon/rails-master-key-exposure.yaml"
const keyBase = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
const masterKeyValue = "0123456789abcdef0123456789abcdef"
t.Run("database config leaks the database name and credentials", func(t *testing.T) {
body := "default: &default\n adapter: postgresql\n encoding: unicode\n pool: 5\n" +
" username: app_user\n password: s3cr3tdbpass\n host: db.internal\n\n" +
"production:\n <<: *default\n database: myapp_production\n"
res := runRailsModule(t, database, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a database config finding")
}
if v := railsExtract(res, "database"); v != "myapp_production" {
t.Errorf("database=%q, want myapp_production", v)
}
})
t.Run("a credential free sqlite database config is not a leak", func(t *testing.T) {
body := "production:\n adapter: sqlite3\n database: db/production.sqlite3\n pool: 5\n"
if res := runRailsModule(t, database, 200, body); len(res.Findings) > 0 {
t.Errorf("a sqlite config without credentials should not match, got %d findings", len(res.Findings))
}
})
t.Run("secrets config leaks the secret key base", func(t *testing.T) {
body := "development:\n secret_key_base: " + keyBase + "\n"
res := runRailsModule(t, secrets, 200, body)
if len(res.Findings) == 0 {
t.Fatal("expected a secrets config finding")
}
if v := railsExtract(res, "secret_key_base"); v != keyBase {
t.Errorf("secret_key_base=%q, want %q", v, keyBase)
}
})
t.Run("master key file leaks the key", func(t *testing.T) {
res := runRailsModule(t, masterKey, 200, masterKeyValue)
if len(res.Findings) == 0 {
t.Fatal("expected a master key finding")
}
if v := railsExtract(res, "master_key"); v != masterKeyValue {
t.Errorf("master_key=%q, want %q", v, masterKeyValue)
}
})
t.Run("a longer hex digest is not the master key", func(t *testing.T) {
body := masterKeyValue + masterKeyValue
if res := runRailsModule(t, masterKey, 200, body); len(res.Findings) > 0 {
t.Errorf("a 64 char digest should not match the 32 char key, got %d findings", len(res.Findings))
}
})
t.Run("a hex value not at the body start is not the master key", func(t *testing.T) {
body := "key=" + masterKeyValue
if res := runRailsModule(t, masterKey, 200, body); len(res.Findings) > 0 {
t.Errorf("a hex value away from the start should not match, got %d findings", len(res.Findings))
}
})
t.Run("an html page naming the rails markers is not a leak", func(t *testing.T) {
body := "<html><head><title>Error</title></head><body>secret_key_base: " + keyBase + "</body></html>"
if res := runRailsModule(t, secrets, 200, body); len(res.Findings) > 0 {
t.Errorf("an html page should not match, got %d findings", len(res.Findings))
}
})
t.Run("a config without the rails markers is not a leak", func(t *testing.T) {
body := "password: hunter2\nusername: admin\nhost: db.internal\n"
for _, file := range []string{database, secrets, masterKey} {
if res := runRailsModule(t, file, 200, body); len(res.Findings) > 0 {
t.Errorf("%s: a config without the rails markers should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{database, secrets, masterKey} {
if res := runRailsModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
+1 -1
View File
@@ -4,7 +4,7 @@
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2025 vmfunc (vmfunc), xyzeva, :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
+118
View File
@@ -0,0 +1,118 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runRegistryModule(t *testing.T, file string, status int, headers map[string]string, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
for k, v := range headers {
w.Header().Set(k, v)
}
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func registryExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestRegistryExposureModules(t *testing.T) {
const dockerRegistry = "../../modules/recon/docker-registry-api-exposure.yaml"
const harbor = "../../modules/recon/harbor-api-exposure.yaml"
registryHeader := map[string]string{"Docker-Distribution-Api-Version": "registry/2.0"}
harborInfo := `{"auth_mode":"db_auth","registry_url":"harbor.example.com",` +
`"external_url":"https://harbor.example.com","harbor_version":"v2.9.1-1f4a3c9d",` +
`"self_registration":true,"has_ca_root":false,"read_only":false}`
t.Run("an anonymous docker registry is flagged with its api version", func(t *testing.T) {
res := runRegistryModule(t, dockerRegistry, 200, registryHeader, "{}")
if len(res.Findings) == 0 {
t.Fatal("expected a docker registry finding")
}
if v := registryExtract(res, "docker_registry_api_version"); v != "registry/2.0" {
t.Errorf("docker_registry_api_version=%q, want registry/2.0", v)
}
})
t.Run("a plain 200 without the registry header is not flagged", func(t *testing.T) {
if res := runRegistryModule(t, dockerRegistry, 200, nil, "{}"); len(res.Findings) > 0 {
t.Errorf("a 200 without the api-version header should not match, got %d findings", len(res.Findings))
}
})
t.Run("a registry that requires auth is not flagged", func(t *testing.T) {
if res := runRegistryModule(t, dockerRegistry, 401, registryHeader, ""); len(res.Findings) > 0 {
t.Errorf("a 401 registry should not match, got %d findings", len(res.Findings))
}
})
t.Run("an exposed harbor systeminfo is flagged and versioned", func(t *testing.T) {
res := runRegistryModule(t, harbor, 200, nil, harborInfo)
if len(res.Findings) == 0 {
t.Fatal("expected a harbor finding")
}
if v := registryExtract(res, "harbor_version"); v != "v2.9.1-1f4a3c9d" {
t.Errorf("harbor_version=%q, want v2.9.1-1f4a3c9d", v)
}
})
t.Run("a harbor version without an auth mode is not flagged", func(t *testing.T) {
body := `{"harbor_version":"v2.9.1","registry_url":"harbor.example.com"}`
if res := runRegistryModule(t, harbor, 200, nil, body); len(res.Findings) > 0 {
t.Errorf("a harbor version alone should not match, got %d findings", len(res.Findings))
}
})
t.Run("an auth mode without a harbor version is not flagged", func(t *testing.T) {
body := `{"auth_mode":"db_auth","self_registration":true}`
if res := runRegistryModule(t, harbor, 200, nil, body); len(res.Findings) > 0 {
t.Errorf("an auth mode alone should not match, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{dockerRegistry, harbor} {
if res := runRegistryModule(t, file, 200, nil, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{dockerRegistry, harbor} {
if res := runRegistryModule(t, file, 404, nil, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
@@ -0,0 +1,175 @@
/*
··
: :
: · Blazing-fast pentesting suite :
: · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
··
*/
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runRuntimeModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func runtimeExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestRuntimeAPIExposureModules(t *testing.T) {
const docker = "../../modules/recon/docker-api-exposure.yaml"
const k8s = "../../modules/recon/kubernetes-api-exposure.yaml"
const kubelet = "../../modules/recon/kubelet-api-exposure.yaml"
dockerVersion := `{"Platform":{"Name":"Docker Engine - Community"},"Components":[` +
`{"Name":"Engine","Version":"24.0.7","Details":{"ApiVersion":"1.43"}},` +
`{"Name":"containerd","Version":"1.6.24"},{"Name":"runc","Version":"1.1.9"}],` +
`"Version":"24.0.7","ApiVersion":"1.43","MinAPIVersion":"1.12","GitCommit":"311b9ff",` +
`"GoVersion":"go1.20.10","Os":"linux","Arch":"amd64"}`
k8sVersion := `{"major":"1","minor":"28","gitVersion":"v1.28.2","gitCommit":"abc123",` +
`"gitTreeState":"clean","buildDate":"2023-09-13T09:35:49Z","goVersion":"go1.20.8",` +
`"compiler":"gc","platform":"linux/amd64"}`
kubeletPods := `{"kind":"PodList","apiVersion":"v1","metadata":{},"items":[{"metadata":` +
`{"name":"etcd-master","namespace":"kube-system"},"spec":{"containers":[{"name":"etcd"}]}}]}`
t.Run("an exposed docker api is flagged and versioned", func(t *testing.T) {
res := runRuntimeModule(t, docker, 200, dockerVersion)
if len(res.Findings) == 0 {
t.Fatal("expected a docker finding")
}
if v := runtimeExtract(res, "docker_version"); v != "24.0.7" {
t.Errorf("docker_version=%q, want 24.0.7", v)
}
})
t.Run("an exposed kubernetes api is flagged and versioned", func(t *testing.T) {
res := runRuntimeModule(t, k8s, 200, k8sVersion)
if len(res.Findings) == 0 {
t.Fatal("expected a kubernetes finding")
}
if v := runtimeExtract(res, "k8s_version"); v != "v1.28.2" {
t.Errorf("k8s_version=%q, want v1.28.2", v)
}
})
t.Run("an exposed kubelet leaks a pod namespace", func(t *testing.T) {
res := runRuntimeModule(t, kubelet, 200, kubeletPods)
if len(res.Findings) == 0 {
t.Fatal("expected a kubelet finding")
}
if v := runtimeExtract(res, "kubelet_namespace"); v != "kube-system" {
t.Errorf("kubelet_namespace=%q, want kube-system", v)
}
})
t.Run("a generic version json without the docker fields is not docker", func(t *testing.T) {
body := `{"version":"1.0.0","name":"myapp"}`
if res := runRuntimeModule(t, docker, 200, body); len(res.Findings) > 0 {
t.Errorf("a generic version should not match docker, got %d findings", len(res.Findings))
}
})
t.Run("an apiversion without a min api version is not docker", func(t *testing.T) {
body := `{"ApiVersion":"2.0","name":"otherservice"}`
if res := runRuntimeModule(t, docker, 200, body); len(res.Findings) > 0 {
t.Errorf("apiversion alone should not match docker, got %d findings", len(res.Findings))
}
})
t.Run("a min api version without an api version is not docker", func(t *testing.T) {
body := `{"MinAPIVersion":"1.12","Os":"linux"}`
if res := runRuntimeModule(t, docker, 200, body); len(res.Findings) > 0 {
t.Errorf("min api version alone should not match docker, got %d findings", len(res.Findings))
}
})
t.Run("a generic version json without the git fields is not kubernetes", func(t *testing.T) {
body := `{"version":"1.2.3","build":"xyz"}`
if res := runRuntimeModule(t, k8s, 200, body); len(res.Findings) > 0 {
t.Errorf("a generic version should not match kubernetes, got %d findings", len(res.Findings))
}
})
t.Run("a gitversion without a git tree state is not kubernetes", func(t *testing.T) {
body := `{"gitVersion":"v1.0.0","app":"custom"}`
if res := runRuntimeModule(t, k8s, 200, body); len(res.Findings) > 0 {
t.Errorf("gitversion alone should not match kubernetes, got %d findings", len(res.Findings))
}
})
t.Run("a build date without a gitversion is not kubernetes", func(t *testing.T) {
body := `{"buildDate":"2023-01-01T00:00:00Z","app":"custom"}`
if res := runRuntimeModule(t, k8s, 200, body); len(res.Findings) > 0 {
t.Errorf("build date alone should not match kubernetes, got %d findings", len(res.Findings))
}
})
t.Run("a service list is not a kubelet pod list", func(t *testing.T) {
body := `{"kind":"ServiceList","apiVersion":"v1","items":[]}`
if res := runRuntimeModule(t, kubelet, 200, body); len(res.Findings) > 0 {
t.Errorf("a service list should not match kubelet, got %d findings", len(res.Findings))
}
})
t.Run("a pod list without an api version is not flagged", func(t *testing.T) {
body := `{"kind":"PodList","items":[]}`
if res := runRuntimeModule(t, kubelet, 200, body); len(res.Findings) > 0 {
t.Errorf("a pod list without apiversion should not match, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{docker, k8s, kubelet} {
if res := runRuntimeModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{docker, k8s, kubelet} {
if res := runRuntimeModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
@@ -0,0 +1,162 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runSecretModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func secretExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestSecretFileExposureModules(t *testing.T) {
const privkey = "../../modules/recon/private-key-exposure.yaml"
const gitcred = "../../modules/recon/git-credentials-exposure.yaml"
const pypirc = "../../modules/recon/pypirc-exposure.yaml"
opensshKey := "-----BEGIN OPENSSH PRIVATE KEY-----\n" +
"b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZWQy\n" +
"NTUxOQAAACD1aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa\n" +
"-----END OPENSSH PRIVATE KEY-----\n"
rsaKey := "-----BEGIN RSA PRIVATE KEY-----\n" +
"MIIEpAIBAAKCAQEArandombase64payloadthatstandsinforakeybodyhere1234567890\n" +
"-----END RSA PRIVATE KEY-----\n"
gitCreds := "https://octocat:ghp_AbCdEf0123456789AbCdEf0123456789@github.com\n" +
"https://deploy:s3cr3t@gitlab.example.com\n"
pypiConfig := "[distutils]\nindex-servers =\n pypi\n\n[pypi]\n" +
"username = __token__\npassword = pypi-AgEIcHlwaS5vcmcCJDQ2Y2Q\n"
t.Run("an openssh private key is flagged and typed", func(t *testing.T) {
res := runSecretModule(t, privkey, 200, opensshKey)
if len(res.Findings) == 0 {
t.Fatal("expected a private key finding")
}
if v := secretExtract(res, "key_type"); v != "OPENSSH" {
t.Errorf("key_type=%q, want OPENSSH", v)
}
})
t.Run("an rsa private key is flagged and typed", func(t *testing.T) {
res := runSecretModule(t, privkey, 200, rsaKey)
if len(res.Findings) == 0 {
t.Fatal("expected a private key finding")
}
if v := secretExtract(res, "key_type"); v != "RSA" {
t.Errorf("key_type=%q, want RSA", v)
}
})
t.Run("a git credential store leaks its host", func(t *testing.T) {
res := runSecretModule(t, gitcred, 200, gitCreds)
if len(res.Findings) == 0 {
t.Fatal("expected a git credential finding")
}
if v := secretExtract(res, "git_host"); v != "github.com" {
t.Errorf("git_host=%q, want github.com", v)
}
})
t.Run("a pypirc leaks the upload token", func(t *testing.T) {
res := runSecretModule(t, pypirc, 200, pypiConfig)
if len(res.Findings) == 0 {
t.Fatal("expected a pypirc finding")
}
if v := secretExtract(res, "pypi_token"); v != "pypi-AgEIcHlwaS5vcmcCJDQ2Y2Q" {
t.Errorf("pypi_token=%q, want the pypi- token", v)
}
})
t.Run("a public key is not a private key", func(t *testing.T) {
body := "-----BEGIN PUBLIC KEY-----\nMFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAK\n" +
"-----END PUBLIC KEY-----\nssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAB user@host\n"
if res := runSecretModule(t, privkey, 200, body); len(res.Findings) > 0 {
t.Errorf("a public key should not match, got %d findings", len(res.Findings))
}
})
t.Run("prose that names a private key is not the key", func(t *testing.T) {
body := "Generate your private key with ssh-keygen and keep id_rsa secret."
if res := runSecretModule(t, privkey, 200, body); len(res.Findings) > 0 {
t.Errorf("prose about keys should not match, got %d findings", len(res.Findings))
}
})
t.Run("a git remote url without a password is not a credential store", func(t *testing.T) {
body := "https://github.com/octocat/hello-world.git\n"
if res := runSecretModule(t, gitcred, 200, body); len(res.Findings) > 0 {
t.Errorf("a bare remote url should not match, got %d findings", len(res.Findings))
}
})
t.Run("a pypi section without a credential is not a leak", func(t *testing.T) {
body := "[distutils]\nindex-servers =\n pypi\n"
if res := runSecretModule(t, pypirc, 200, body); len(res.Findings) > 0 {
t.Errorf("a section with no credential should not match, got %d findings", len(res.Findings))
}
})
t.Run("credentials shown in an html page are not a store", func(t *testing.T) {
body := "<!DOCTYPE html><html><body>clone with https://user:pass@host.example</body></html>"
if res := runSecretModule(t, gitcred, 200, body); len(res.Findings) > 0 {
t.Errorf("an html page should not match, got %d findings", len(res.Findings))
}
})
t.Run("a pypi config inside an html page is not a leak", func(t *testing.T) {
body := "<html><head><title>docs</title></head><body><pre>[pypi]\npassword = pypi-x</pre></body></html>"
if res := runSecretModule(t, pypirc, 200, body); len(res.Findings) > 0 {
t.Errorf("an html page should not match, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{privkey, gitcred, pypirc} {
if res := runSecretModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{privkey, gitcred, pypirc} {
if res := runSecretModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}
@@ -0,0 +1,155 @@
package modules_test
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/modules"
)
func runVCSModule(t *testing.T, file string, status int, body string) *modules.Result {
t.Helper()
def, err := modules.ParseYAMLModule(file)
if err != nil {
t.Fatalf("parse %s: %v", file, err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
defer srv.Close()
res, err := modules.ExecuteHTTPModule(context.Background(), srv.URL, def, modules.Options{
Timeout: 5 * time.Second,
Threads: 2,
})
if err != nil {
t.Fatalf("execute %s: %v", file, err)
}
return res
}
func vcsExtract(res *modules.Result, key string) string {
for _, f := range res.Findings {
if v := f.Extracted[key]; v != "" {
return v
}
}
return ""
}
func TestVCSMetadataExposureModules(t *testing.T) {
const svn = "../../modules/recon/svn-exposure.yaml"
const hg = "../../modules/recon/mercurial-exposure.yaml"
const bzr = "../../modules/recon/bazaar-exposure.yaml"
svnWcDb := "SQLite format 3\x00" + strings.Repeat("\x00", 80) +
"CREATE TABLE WCROOT (id INTEGER PRIMARY KEY);" +
"CREATE TABLE REPOSITORY (root TEXT, uuid TEXT);" +
"\x01root\x00https://svn.example.com/myrepo/trunk\x00"
hgRequires := "revlogv1\nstore\nfncache\ndotencode\ngeneraldelta\nsparserevlog\n"
bzrFormat := "Bazaar-NG meta directory, format 1\n"
t.Run("an exposed svn wc.db leaks the repository url", func(t *testing.T) {
res := runVCSModule(t, svn, 200, svnWcDb)
if len(res.Findings) == 0 {
t.Fatal("expected an svn finding")
}
if v := vcsExtract(res, "svn_repository"); v != "https://svn.example.com/myrepo/trunk" {
t.Errorf("svn_repository=%q, want https://svn.example.com/myrepo/trunk", v)
}
})
t.Run("an exposed mercurial requires is flagged", func(t *testing.T) {
res := runVCSModule(t, hg, 200, hgRequires)
if len(res.Findings) == 0 {
t.Fatal("expected a mercurial finding")
}
if v := vcsExtract(res, "hg_requirement"); v != "revlogv1" {
t.Errorf("hg_requirement=%q, want revlogv1", v)
}
})
t.Run("an exposed bazaar branch-format is flagged", func(t *testing.T) {
res := runVCSModule(t, bzr, 200, bzrFormat)
if len(res.Findings) == 0 {
t.Fatal("expected a bazaar finding")
}
if v := vcsExtract(res, "bzr_format"); v != "Bazaar-NG meta directory, format 1" {
t.Errorf("bzr_format=%q, want the meta directory signature", v)
}
})
t.Run("a generic sqlite database without svn tables is not flagged", func(t *testing.T) {
body := "SQLite format 3\x00" + strings.Repeat("\x00", 80) +
"CREATE TABLE users (id INTEGER, name TEXT, email TEXT);"
if res := runVCSModule(t, svn, 200, body); len(res.Findings) > 0 {
t.Errorf("a generic sqlite db should not match svn, got %d findings", len(res.Findings))
}
})
t.Run("a generic sqlite with a nodes table is not an svn working copy", func(t *testing.T) {
body := "SQLite format 3\x00" + strings.Repeat("\x00", 80) +
"CREATE TABLE NODES (id INTEGER PRIMARY KEY, parent INTEGER, label TEXT);"
if res := runVCSModule(t, svn, 200, body); len(res.Findings) > 0 {
t.Errorf("a generic nodes table should not match svn, got %d findings", len(res.Findings))
}
})
t.Run("an svn magic that is not at byte zero is not flagged", func(t *testing.T) {
body := "<!DOCTYPE html><html><body><pre>SQLite format 3 WCROOT REPOSITORY</pre></body></html>"
if res := runVCSModule(t, svn, 200, body); len(res.Findings) > 0 {
t.Errorf("an unanchored magic should not match, got %d findings", len(res.Findings))
}
})
t.Run("a page naming mercurial without the requires format is not flagged", func(t *testing.T) {
body := "this project uses mercurial for distributed version control"
if res := runVCSModule(t, hg, 200, body); len(res.Findings) > 0 {
t.Errorf("prose naming mercurial should not match, got %d findings", len(res.Findings))
}
})
t.Run("an html page demonstrating hg requires is not a leak", func(t *testing.T) {
body := "<!DOCTYPE html><html><body><pre>revlogv1\nstore\ndotencode</pre></body></html>"
if res := runVCSModule(t, hg, 200, body); len(res.Findings) > 0 {
t.Errorf("an html hg tutorial should not match, got %d findings", len(res.Findings))
}
})
t.Run("a bazaar marketplace page is not a repository", func(t *testing.T) {
body := "Welcome to the Bazaar, the finest open air marketplace in town"
if res := runVCSModule(t, bzr, 200, body); len(res.Findings) > 0 {
t.Errorf("a marketplace page should not match, got %d findings", len(res.Findings))
}
})
t.Run("an html page demonstrating bzr branch-format is not a leak", func(t *testing.T) {
body := "<!DOCTYPE html><html><body><pre>Bazaar-NG meta directory, format 1</pre></body></html>"
if res := runVCSModule(t, bzr, 200, body); len(res.Findings) > 0 {
t.Errorf("an html bzr tutorial should not match, got %d findings", len(res.Findings))
}
})
t.Run("a plain 200 body is not a leak", func(t *testing.T) {
for _, file := range []string{svn, hg, bzr} {
if res := runVCSModule(t, file, 200, "ok"); len(res.Findings) > 0 {
t.Errorf("%s: a plain 200 body should not match, got %d findings", file, len(res.Findings))
}
}
})
t.Run("a 404 is not a leak", func(t *testing.T) {
for _, file := range []string{svn, hg, bzr} {
if res := runVCSModule(t, file, 404, "not found"); len(res.Findings) > 0 {
t.Errorf("%s: a 404 should not match, got %d findings", file, len(res.Findings))
}
}
})
}

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