Compare commits

..

300 Commits

Author SHA1 Message Date
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
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
vmfunc 1379dd9952 test: add basic unit tests for scan package
adds tests for subdomain takeover detection, robots.txt fetching,
and result struct validation using httptest mock servers.
2026-01-02 17:27:50 -08:00
vmfunc 925b84d22b chore: add golangci-lint configuration
enables errcheck, govet, staticcheck, unused, gosimple,
ineffassign, and misspell linters
2026-01-02 17:21:58 -08:00
vmfunc ecb2e147c2 docs: update minimum go version to 1.23 in contributing guide 2026-01-02 17:21:38 -08:00
vmfunc 5c92b6ae4d fix: handle errors instead of ignoring them
- dork.go: log and skip on googlesearch.Search error
- nuclei.go: return error on os.Getwd and reporting.New failures
- subdomaintakeover.go: return early on io.ReadAll error
2026-01-02 17:21:21 -08:00
vmfunc 75350458c1 chore: update github actions to latest versions
- update actions/checkout from v2/v3 to v4 across all workflows
- update reviewdog actions to latest versions
- update jetbrains/qodana-action to v2024.3
- update actions/dependency-review-action to v4
- replace deprecated actions/create-release and upload-release-asset
  with softprops/action-gh-release@v2
2026-01-02 17:20:01 -08:00
vmfunc 21c85974cd chore: upgrade to go 1.25 and ignore claude files
- update go.mod to use go 1.23 with toolchain go1.25.5
- add CLAUDE.md and .claude/ to .gitignore
2026-01-02 17:13:16 -08:00
vmfunc 3d2e75b525 Merge pull request #41 from vmfunc/dependabot/go_modules/go_modules-dd59f798d0
build(deps): bump github.com/quic-go/quic-go from 0.42.0 to 0.48.2 in the go_modules group
2026-01-02 17:11:27 -08:00
vmfunc bc07d1ad4e fix: update go version check to support go 1.20+
the makefile was checking for go 1.23 specifically, which breaks builds
on newer go versions (1.24, 1.25, etc). this updates the regex to allow
any go version 1.20 or higher.
2026-01-02 17:10:05 -08:00
vmfunc ca5c79b44c Merge pull request #43 from ag-wnl/agwnl/update-makefile-go
Update Makefile to support latest version of Go
2025-10-26 17:22:41 +01:00
vmfunc 450fcb8efd Update README.md 2025-04-18 16:41:37 +02:00
ag-wnl 34d190731a chore: update to be compatible with all minor Go updates 2025-03-15 15:26:09 +05:30
ag-wnl cfe681d793 chore: update makefile to latest go version 2025-03-15 15:19:54 +05:30
dependabot[bot] 1d4673c078 build(deps): bump github.com/quic-go/quic-go in the go_modules group
Bumps the go_modules group with 1 update: [github.com/quic-go/quic-go](https://github.com/quic-go/quic-go).


Updates `github.com/quic-go/quic-go` from 0.42.0 to 0.48.2
- [Release notes](https://github.com/quic-go/quic-go/releases)
- [Changelog](https://github.com/quic-go/quic-go/blob/master/Changelog.md)
- [Commits](https://github.com/quic-go/quic-go/compare/v0.42.0...v0.48.2)

---
updated-dependencies:
- dependency-name: github.com/quic-go/quic-go
  dependency-type: indirect
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-12-02 17:51:08 +00:00
vmfunc 1253515f0b actions<breaking>: remove PR-specific actions
(needs to be fixed)
2024-11-22 03:28:17 -05:00
vmfunc 5b4b43011b design: readme fixes 2024-11-14 09:09:35 +01:00
vmfunc ebdba0721c design: update product banner 2024-11-14 06:53:41 +01:00
vmfunc 806e8b0970 design: update banner 2024-11-14 06:51:54 +01:00
vmfunc 1a0245840e Merge pull request #38 from lunchcat/dependabot/go_modules/go_modules-403cefacee
build(deps): bump github.com/golang-jwt/jwt/v4 from 4.5.0 to 4.5.1 in the go_modules group
2024-11-05 00:36:18 +01:00
dependabot[bot] 8a0ed28bd5 build(deps): bump github.com/golang-jwt/jwt/v4 in the go_modules group
Bumps the go_modules group with 1 update: [github.com/golang-jwt/jwt/v4](https://github.com/golang-jwt/jwt).


Updates `github.com/golang-jwt/jwt/v4` from 4.5.0 to 4.5.1
- [Release notes](https://github.com/golang-jwt/jwt/releases)
- [Changelog](https://github.com/golang-jwt/jwt/blob/main/VERSION_HISTORY.md)
- [Commits](https://github.com/golang-jwt/jwt/compare/v4.5.0...v4.5.1)

---
updated-dependencies:
- dependency-name: github.com/golang-jwt/jwt/v4
  dependency-type: indirect
  dependency-group: go_modules
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-11-04 23:33:18 +00:00
vmfunc c3805c7aee fix<dork>: properly process feature flag 2024-10-22 09:15:36 +02:00
vmfunc ceb8712204 ci: various improvements to workflow 2024-10-15 02:51:52 +02:00
vmfunc b335a45a82 chore<format>: gofmt whitespace removal 2024-10-15 02:32:48 +02:00
vmfunc 1048a97355 feat<sif>: log scan overview 2024-10-15 00:14:59 +02:00
vmfunc cceb60a423 fix<contrib>: fix contributor file 2024-10-13 00:56:23 +02:00
vmfunc bf5fd7c566 fix: replace modules with features 2024-10-13 00:49:12 +02:00
vmfunc 16a73f274b feat: improve readme 2024-10-13 00:44:50 +02:00
vmfunc fcc0ba0ea4 Merge pull request #36 from lunchcat/all-contributors/add-projectdiscovery
docs: add projectdiscovery as a contributor for platform
2024-10-13 00:32:48 +02:00
vmfunc b619ed026a Merge branch 'main' into all-contributors/add-projectdiscovery 2024-10-13 00:32:41 +02:00
vmfunc 26b35c1cbc Merge pull request #35 from lunchcat/all-contributors/add-macdoos
docs: add macdoos as a contributor for code
2024-10-13 00:31:55 +02:00
vmfunc 4a51ddda95 Merge branch 'main' into all-contributors/add-macdoos 2024-10-13 00:31:50 +02:00
vmfunc 3eae11695d Merge pull request #34 from lunchcat/all-contributors/add-D3adPlays
docs: add D3adPlays as a contributor for ideas
2024-10-13 00:31:10 +02:00
vmfunc c29227d26b Merge branch 'main' into all-contributors/add-D3adPlays 2024-10-13 00:31:04 +02:00
vmfunc 0279ea9b0a Merge pull request #33 from lunchcat/all-contributors/add-tessa-u-k
docs: add tessa-u-k as a contributor for infra, question, and userTesting
2024-10-13 00:30:16 +02:00
vmfunc dfe8004544 Merge branch 'main' into all-contributors/add-tessa-u-k 2024-10-13 00:27:32 +02:00
vmfunc 2816887a7a Merge pull request #32 from lunchcat/all-contributors/add-xyzeva
docs: add xyzeva as a contributor for blog, content, and 4 more
2024-10-13 00:25:45 +02:00
allcontributors[bot] ae82c2066d docs: update .all-contributorsrc 2024-10-12 22:25:28 +00:00
allcontributors[bot] cebfe62bcf docs: update README.md 2024-10-12 22:25:27 +00:00
allcontributors[bot] a79ffd08d4 docs: update .all-contributorsrc 2024-10-12 22:25:16 +00:00
allcontributors[bot] 22fba38ff6 docs: update README.md 2024-10-12 22:25:15 +00:00
allcontributors[bot] dd28daf795 docs: update .all-contributorsrc 2024-10-12 22:25:08 +00:00
allcontributors[bot] 7c080e99a8 docs: update README.md 2024-10-12 22:25:07 +00:00
allcontributors[bot] 7c9ba8da80 docs: update .all-contributorsrc 2024-10-12 22:24:49 +00:00
allcontributors[bot] 814be003ad docs: update README.md 2024-10-12 22:24:48 +00:00
allcontributors[bot] 2a87a5790f docs: update .all-contributorsrc 2024-10-12 22:24:41 +00:00
allcontributors[bot] 8e76b40b53 docs: update README.md 2024-10-12 22:24:40 +00:00
vmfunc e3b87e5138 Merge pull request #31 from lunchcat/all-contributors/add-vmfunc
docs: add vmfunc as a contributor for maintenance, mentoring, and 8 more
2024-10-13 00:24:12 +02:00
allcontributors[bot] 2ef4392e28 docs: update .all-contributorsrc 2024-10-12 22:23:54 +00:00
allcontributors[bot] bcb9482f00 docs: update README.md 2024-10-12 22:23:53 +00:00
vmfunc 3cd45523a3 Merge pull request #30 from lunchcat/all-contributors/add-vmfunc
docs: add vmfunc as a contributor for maintenance
2024-10-13 00:18:12 +02:00
allcontributors[bot] bd74efcc5c docs: update .all-contributorsrc 2024-10-12 22:17:40 +00:00
allcontributors[bot] 8effe8a297 docs: update README.md 2024-10-12 22:17:39 +00:00
vmfunc cb7abc230e chore<readme>: add all contributors 2024-10-13 00:12:17 +02:00
vmfunc 60ee32155a fix<ci>: remove prerelease flag on release workflow 2024-10-13 00:08:34 +02:00
vmfunc 3bc8018b26 fix<ci>: use different release version naming 2024-10-12 23:58:23 +02:00
vmfunc 4eebe0e386 fix<ci>: permission flag for release creation 2024-10-12 23:56:38 +02:00
vmfunc ea21e2188f feat<ci>: identify automated release as pre-release 2024-10-12 23:50:01 +02:00
vmfunc b262c82180 fix<ci>: add automated release tag 2024-10-12 23:47:35 +02:00
vmfunc ee0d258901 fix<ci>: add more info to the release 2024-10-12 23:45:18 +02:00
vmfunc 093b290a0d fix<ci>: use smaller release tag 2024-10-12 23:40:26 +02:00
vmfunc 4441b113e6 fix<ci>: add r/w to release 2024-10-12 23:34:50 +02:00
vmfunc ec48a8a462 fix<ci>: make release re-useable 2024-10-12 23:28:13 +02:00
vmfunc 100d385b3c chore<ci>: auto-release script 2024-10-12 23:22:34 +02:00
vmfunc 109d8efd41 chore<ci>: add func test 2024-10-12 23:16:05 +02:00
vmfunc 267aa6e177 chore<comments>: bunch of AI-generated comments to make the codebase easier to understand 2024-10-12 23:06:22 +02:00
vmfunc a2f2a51701 feat<st>: subdomain takeover checks 2024-10-12 22:52:27 +02:00
vmfunc 56516e28e2 feat<c3>: AWS c3 detection 2024-10-12 22:28:31 +02:00
vmfunc 7be0c04c7d feat<headers>: http header analysis 2024-10-12 22:15:56 +02:00
vmfunc a4dbb21e96 feat<cms>: CMS detection 2024-10-12 22:10:06 +02:00
vmfunc 887363cb16 actions!: update go version 2024-10-12 21:53:06 +02:00
vmfunc 65243f46e3 feat<logging>: assist user if no arguments are provided 2024-10-12 21:28:59 +02:00
vmfunc 5b63515650 chore<build>: improve logging 2024-10-12 21:25:30 +02:00
vmfunc d8ac81cb96 chore<build>: add extra copyrights 2024-10-12 21:21:31 +02:00
vmfunc 592ea1e14f chore<build>: enforce sudo if install fails 2024-10-12 21:16:23 +02:00
vmfunc 7fae5b1c55 chore<build>: force check UNIX system on install 2024-10-12 21:10:24 +02:00
vmfunc 18daaf61f9 chore<build>: add further logging 2024-10-12 21:08:43 +02:00
vmfunc 17aff81ee1 chore<build>: check for go install version before build 2024-10-12 21:08:02 +02:00
vmfunc 5b166ba474 fix<whois>: typos in logfile creation 2024-07-29 04:26:39 +00:00
195 changed files with 29729 additions and 2759 deletions
+87
View File
@@ -0,0 +1,87 @@
{
"projectName": "sif",
"projectOwner": "lunchcat",
"files": [
"README.md"
],
"commitType": "docs",
"commitConvention": "angular",
"contributorsPerLine": 7,
"contributors": [
{
"login": "vmfunc",
"name": "vmfunc",
"avatar_url": "https://avatars.githubusercontent.com/u/59031302?v=4",
"profile": "https://vmfunc.re",
"contributions": [
"maintenance",
"mentoring",
"projectManagement",
"security",
"code"
]
},
{
"login": "projectdiscovery",
"name": "ProjectDiscovery",
"avatar_url": "https://avatars.githubusercontent.com/u/50994705?v=4",
"profile": "https://projectdiscovery.io",
"contributions": [
"platform"
]
},
{
"login": "macdoos",
"name": "macdoos",
"avatar_url": "https://avatars.githubusercontent.com/u/127897805?v=4",
"profile": "https://github.com/macdoos",
"contributions": [
"code"
]
},
{
"login": "D3adPlays",
"name": "Matthieu Witrowiez",
"avatar_url": "https://avatars.githubusercontent.com/u/75166283?v=4",
"profile": "https://epitech.eu",
"contributions": [
"ideas"
]
},
{
"login": "tessa-u-k",
"name": "tessa ",
"avatar_url": "https://avatars.githubusercontent.com/u/109355732?v=4",
"profile": "https://github.com/tessa-u-k",
"contributions": [
"infra",
"question",
"userTesting"
]
},
{
"login": "xyzeva",
"name": "Eva",
"avatar_url": "https://avatars.githubusercontent.com/u/133499694?v=4",
"profile": "https://github.com/xyzeva",
"contributions": [
"blog",
"content",
"research",
"security",
"test",
"code"
]
},
{
"login": "vxfemboy",
"name": "Zoa Hickenlooper",
"avatar_url": "https://avatars.githubusercontent.com/u/79362520?v=4",
"profile": "https://github.com/vxfemboy",
"contributions": [
"code"
]
}
],
"repoType": "github"
}
+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"
+23
View File
@@ -0,0 +1,23 @@
name: automatic rebase
on:
issue_comment:
types: [created]
permissions:
contents: write
pull-requests: write
jobs:
rebase:
name: Rebase
if: github.event.issue.pull_request != '' && contains(github.event.comment.body, '/rebase')
runs-on: ubuntu-latest
steps:
- name: Checkout the latest code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: automatic rebase
uses: cirrus-actions/rebase@1.8
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+29
View File
@@ -0,0 +1,29 @@
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
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: check for large files
run: |
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
+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@v3
- uses: actions/checkout@v6
- 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@v2023.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"
+26
View File
@@ -0,0 +1,26 @@
name: dependency review
on:
pull_request:
push:
branches: [main]
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@v6
- name: dependency review
uses: actions/dependency-review-action@v5
continue-on-error: ${{ github.event_name == 'push' }}
- name: check dependency review outcome
if: github.event_name == 'push' && failure()
run: |
echo "::warning::Dependency review failed. Please check the dependencies for potential issues."
+45 -11
View File
@@ -1,17 +1,51 @@
name: Go
name: go
on:
push:
branches: [ "main" ]
branches: ["main"]
pull_request:
branches: [ "main" ]
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@v3
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: '1.21'
- name: Build
run: make
- uses: actions/checkout@v6
- name: set up go
uses: actions/setup-go@v6
with:
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@v6
- name: set up go
uses: actions/setup-go@v6
with:
go-version: ${{ matrix.go-version }}
- name: build
run: make
- name: run tests with coverage
run: go test -race -coverprofile=coverage.out -covermode=atomic ./...
- 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@v6
- 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
+55
View File
@@ -0,0 +1,55 @@
name: header check
on:
push:
paths:
- '**.go'
pull_request:
paths:
- '**.go'
permissions:
contents: read
jobs:
check-headers:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: check license headers
run: |
missing_headers=()
while IFS= read -r -d '' file; do
# skip test files and generated files
if [[ "$file" == *"_test.go" ]]; then
continue
fi
# check if file starts with the license header (signature is on line 4)
if ! head -n 5 "$file" | grep -q "█▀ █ █▀▀"; then
missing_headers+=("$file")
fi
done < <(find . -name "*.go" -type f -print0)
if [ ${#missing_headers[@]} -ne 0 ]; then
echo "::error::the following files are missing the license header:"
printf '%s\n' "${missing_headers[@]}"
echo ""
echo "expected header format:"
echo '/*'
echo '·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·'
echo ': :'
echo ': █▀ █ █▀▀ · Blazing-fast pentesting suite :'
echo ': ▄█ █ █▀ · BSD 3-Clause License :'
echo ': :'
echo ': (c) 2022-2026 vmfunc, xyzeva, :'
echo ': lunchcat alumni & contributors :'
echo ': :'
echo '·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·'
echo '*/'
exit 1
fi
echo "all go files have proper license headers"
+31
View File
@@ -0,0 +1,31 @@
name: mind your language
on:
issues:
types:
- opened
- edited
issue_comment:
types:
- created
- edited
pull_request_review_comment:
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@v6
- name: Profanity check step
uses: tailaiw/mind-your-language-action@v1.0.3
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+22
View File
@@ -0,0 +1,22 @@
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@v6
- name: markdownlint
uses: reviewdog/action-markdownlint@v0.26.2
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
reporter: github-pr-review
+28
View File
@@ -0,0 +1,28 @@
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@v6
- name: misspell
uses: reviewdog/action-misspell@v1.27.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
reporter: github-pr-review
level: warning
locale: "US"
+139
View File
@@ -0,0 +1,139 @@
name: pr bot
on:
pull_request:
types: [opened, synchronize, reopened, edited]
permissions:
contents: read
pull-requests: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
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:
- uses: actions/checkout@v6
- 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,
});
}
+210
View File
@@ -0,0 +1,210 @@
name: release
on:
push:
tags:
- "v*"
permissions:
contents: write
packages: write
jobs:
test:
uses: ./.github/workflows/runtest.yml
build-and-release:
needs: test
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v6
- name: set up go
uses: actions/setup-go@v5
with:
go-version: "1.25"
- name: extract version
run: |
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 windows
run: |
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 macOS
run: |
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: 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}"
cp "${binary}" "dist/${binary}/sif"
cp -r modules "dist/${binary}/"
tar -czf "${binary}.tar.gz" -C dist "${binary}"
done
for binary in sif-windows-amd64 sif-windows-386; do
mkdir -p "dist/${binary}"
cp "${binary}.exe" "dist/${binary}/sif.exe"
cp -r modules "dist/${binary}/"
cd dist && zip -r "../${binary}.zip" "${binary}" && cd ..
done
- name: build debian packages
run: |
declare -A arch_map=(
["sif-linux-amd64"]="amd64"
["sif-linux-386"]="i386"
["sif-linux-arm64"]="arm64"
)
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:
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: |
## what's changed
${{ steps.changelog.outputs.result }}
## install
**homebrew / linuxbrew**
```bash
# coming soon
```
**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: ${{ contains(github.ref_name, '-') }}
files: |
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_*_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
+23
View File
@@ -0,0 +1,23 @@
name: update report card
on:
push:
branches: [main]
pull_request:
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@v6
- name: update go report card
uses: creekorful/goreportcard-action@v1.0
+45
View File
@@ -0,0 +1,45 @@
name: functional test
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_call:
permissions:
contents: read
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: set up go
uses: actions/setup-go@v5
with:
go-version: "1.25"
- name: build sif
run: make
- name: run sif with features
run: |
./sif -u https://example.com -dnslist small -dirlist small -dork -git -whois -cms -framework
if [ $? -eq 0 ]; then
echo "Sif ran successfully"
else
echo "Sif exited with an error"
exit 1
fi
- name: test module system
run: |
echo "Listing modules..."
./sif -lm
echo "Running all modules..."
./sif -u https://example.com -am
if [ $? -eq 0 ]; then
echo "Module system working"
else
echo "Module system failed"
exit 1
fi
+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@v6
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
+22
View File
@@ -0,0 +1,22 @@
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@v6
- name: shellcheck
uses: reviewdog/action-shellcheck@v1.32.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
reporter: github-pr-review
+23
View File
@@ -0,0 +1,23 @@
name: yaml lint
on:
pull_request:
paths:
- "**/*.yml"
- "**/*.yaml"
permissions:
contents: read
pull-requests: write
jobs:
yamllint:
name: runner / yamllint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: yamllint
uses: reviewdog/action-yamllint@v1.21.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
reporter: github-pr-review
+4
View File
@@ -31,3 +31,7 @@ result
# nuclei templates
nuclei-templates
# claude files
CLAUDE.md
.claude/
+96
View File
@@ -0,0 +1,96 @@
---
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: 50
+125 -5
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.20 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.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.
*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
@@ -53,6 +55,124 @@ When making a pull request, please adhere to the following conventions:
If you have any questions, feel free to ask around on the IRC channel.
## Contributing Framework Detection Patterns
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 Detector
1. Create a detector struct in the appropriate file in `detectors/`:
```go
// 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
- Include multiple patterns for higher confidence
### Adding Version Detection
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"},
},
```
### Adding CVE Data
Add known vulnerabilities to `cve.go` in the `knownCVEs` map:
```go
"MyFramework": {
{
CVE: "CVE-YYYY-XXXXX",
AffectedVersions: []string{"1.0.0", "1.0.1", "1.1.0"},
FixedVersion: "1.2.0",
Severity: "high", // critical, high, medium, low
Description: "Brief description of the vulnerability",
Recommendations: []string{"Update to 1.2.0 or later"},
},
},
```
### Testing Your Changes
Always add tests for new frameworks in `detect_test.go`:
```go
func TestDetectFramework_MyFramework(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write([]byte(`<html><body>unique-identifier</body></html>`))
}))
defer server.Close()
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
- **CVE API Integration**: Real-time CVE data from NVD or other sources
- **Automated Signature Updates**: Fetch new signatures from a central repository
- **Framework Fingerprint Database**: Community-maintained signature database
## Configuration
### 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 |
### Environment Variables
| Variable | Description |
| ---------------- | ------------------------------------ |
| `SHODAN_API_KEY` | API key for Shodan host intelligence |
## Packaging
We'd love it if you helped us bring sif to your distribution.
+26 -8
View File
@@ -1,11 +1,29 @@
Copyright 2023 - 2024 lunchcat, inc. ALL RIGHTS RESERVED.
BSD 3-Clause License
Use of this tool is restricted to research and educational purposes only. Usage in a production environment outside of these categories is strictly prohibited. Any person or entity wishing to use this tool outside of research or school purposes must purchase a license from https://lunchcat.dev.
Copyright (c) 2022-2025 vmfunc, xyzeva, lunchcat alumni,
and other sif contributors.
For Businesses:
1. Licensing Requirement: Businesses intending to use this tool for any commercial, operational, or production purposes must obtain a commercial license from [lunchcat.dev](https://lunchcat.dev).
2. Compliance: Businesses must ensure compliance with all applicable laws and regulations when using this tool.
3. Liability: lunchcat assumes no liability for any damages or losses incurred by businesses using this tool without an appropriate license.
4. Support: Licensed business users are eligible for dedicated support and updates as per the terms of their license agreement.
5. Audits: lunchcat reserves the right to audit business usage of this tool to ensure compliance with the licensing terms.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice, this
list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the documentation
and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
-15
View File
@@ -1,15 +0,0 @@
**Copyright 2023 - 2024 lunchcat. ALL RIGHTS RESERVED.**
Use of this tool is restricted to research and educational purposes only. Usage in a production environment outside of these categories is strictly prohibited. Any person or entity wishing to use this tool outside of research or school purposes must purchase a license from [lunchcat.dev](https://lunchcat.dev).
**For Businesses:**
1. **Licensing Requirement:** Businesses intending to use this tool for any commercial, operational, or production purposes must obtain a commercial license from [lunchcat.dev](https://lunchcat.dev).
2. **Compliance:** Businesses must ensure compliance with all applicable laws and regulations when using this tool.
3. **Liability:** lunchcat assumes no liability for any damages or losses incurred by businesses using this tool without an appropriate license.
4. **Support:** Licensed business users are eligible for dedicated support and updates as per the terms of their license agreement.
5. **Audits:** lunchcat reserves the right to audit business usage of this tool to ensure compliance with the licensing terms.
---
+85 -9
View File
@@ -1,3 +1,6 @@
# Copyright (c) 2024 vmfunc, xyzeva, lunchcat, and contributors
# SPDX-License-Identifier: MIT
.POSIX:
.SUFFIXES:
@@ -6,20 +9,93 @@ RM ?= rm
GOFLAGS ?=
PREFIX ?= /usr/local
BINDIR ?= bin
MANDIR ?= share/man/man1
all: sif
# 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)
sif:
$(GO) build $(GOFLAGS) ./cmd/sif
define COPYRIGHT_ASCII
_____________
__________(_)__ __/
__ ___/_ /__ /_
_(__ )_ / _ __/
/____/ /_/ /_/
Copyright (c) 2024 vmfunc, xyzeva, lunchcat, and contributors
endef
export COPYRIGHT_ASCII
define SUPPORT_MESSAGE
│ 🌟 Enjoying sif? Please consider:
│ • Starring our repo: https://github.com/lunchcat/sif
│ • Supporting the devs: https://lunchcat.dev
Your support helps us continue improving sif!
endef
export SUPPORT_MESSAGE
all: check_go_version sif
@echo "✅ All tasks completed successfully! 🎉"
@echo "$$SUPPORT_MESSAGE"
check_go_version:
@echo "$$COPYRIGHT_ASCII"
@echo "🔍 Checking Go version..."
@$(GO) version | grep -E "go1\.[2-9][0-9]*\." || (echo "❌ Error: Please install the latest version of Go" && exit 1)
@echo "✅ Go version check passed!"
sif: check_go_version
@echo "🛠️ Building sif..."
@echo "📁 Current directory: $$(pwd)"
@echo "🔧 Go flags: $(GOFLAGS)"
@echo "📦 Building package: ./cmd/sif"
$(GO) build -v $(GOFLAGS) -ldflags "$(GO_LDFLAGS)" ./cmd/sif
@echo "📊 Build info:"
@$(GO) version -m sif
@echo "✅ sif built successfully! 🚀"
clean:
$(RM) -rf sif
@echo "$$COPYRIGHT_ASCII"
@echo "🧹 Cleaning up..."
@$(RM) -rf sif
@echo "✨ Cleanup complete!"
install:
mkdir -p $(DESTDIR)$(PREFIX)/$(BINDIR)
cp -f sif $(DESTDIR)$(PREFIX)/$(BINDIR)
install: check_go_version
@echo "$$COPYRIGHT_ASCII"
@echo "📦 Installing sif..."
@if [ "$$(uname)" != "Linux" ] && [ "$$(uname)" != "Darwin" ]; then \
echo "❌ Error: This installation script is for UNIX systems only."; \
exit 1; \
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:
$(RM) $(DESTDIR)$(PREFIX)/$(BINDIR)/sif
@echo "$$COPYRIGHT_ASCII"
@echo "🗑️ Uninstalling sif..."
@if [ "$$(uname)" != "Linux" ] && [ "$$(uname)" != "Darwin" ]; then \
echo "❌ Error: This uninstallation script is for UNIX systems only."; \
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 sif clean install uninstall
.PHONY: all check_go_version sif clean install uninstall
+409 -27
View File
@@ -1,34 +1,416 @@
<pre align="center">
_____________
__________(_)__ __/
__ ___/_ /__ /_
_(__ )_ / _ __/
/____/ /_/ /_/
</pre>
<h4 align="center">a blazing-fast pentesting (recon/exploitation) suite written in Go 🐾</h4>
<div align="center">
![Go version](https://img.shields.io/github/go-mod/go-version/dropalldatabases/sif)
[![Go Report Card](https://goreportcard.com/badge/github.com/dropalldatabases/sif)](https://goreportcard.com/report/github.com/dropalldatabases/sif)
[![Version](https://img.shields.io/github/v/tag/dropalldatabases/sif)](https://github.com/dropalldatabases/sif/tags)
[![Chat on Discord](https://img.shields.io/discord/1202922721969705010)](https://discord.gg/uzQv4YbJ8W)
<img src="assets/banner.png" alt="sif" width="600">
<br><br>
[![go version](https://img.shields.io/github/go-mod/go-version/vmfunc/sif?style=flat-square&color=00ADD8)](https://go.dev/)
[![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)
[![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>
## Features
- 📂 Directory/file fuzzing/scanning
- 📡 DNS subdomain enumeration
- 🐾 Common Web scanning
- 🖥️ Port/service scanning
- 🦠 Vulnerability scanning
- Support for pre-existing nuclei templates
- Metasploit emulation for execution
- 🔎 Automated Google dorking
- 💘 Shodan integration
---
## Contributing and support
## what is sif?
Please join [our Discord server](https://discord.gg/uzQv4YbJ8W) to discuss sif development and to ask questions. Feel free to open an issue on GitHub requesting an addition to sif or asking for help with an issue.
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.
Contributions are welcome! Make sure to read `CONTRIBUTING.md` before submitting a pull request.
```bash
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)
```bash
brew tap vmfunc/sif
brew install sif
```
### arch linux (aur)
install using your preferred aur helper:
```bash
yay -S sif
# or
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).
### from source
```bash
git clone https://github.com/vmfunc/sif.git
cd sif
make
```
requires go 1.25+
### aur (manual install)
```bash
git clone https://aur.archlinux.org/sif.git
cd sif
makepkg -si
```
## usage
```bash
# basic scan
./sif -u https://example.com
# directory fuzzing
./sif -u https://example.com -dirlist medium
# subdomain enumeration
./sif -u https://example.com -dnslist medium
# port scanning
./sif -u https://example.com -ports common
# javascript framework detection + cloud misconfig
./sif -u https://example.com -js -c3
# 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
# 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.
### built-in scan flags
| 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` | dirlist: auto-calibrate the soft-404 wildcard baseline |
| `-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 + 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
list available modules:
```bash
./sif -lm
```
run specific modules:
```bash
# run by id
./sif -u https://example.com -m sqli-error-based,xss-reflected
# run by tag
./sif -u https://example.com -mt owasp-top10
# run all modules
./sif -u https://example.com -am
```
### custom modules
create your own modules in `~/.config/sif/modules/`. modules use a yaml format similar to nuclei templates:
```yaml
id: my-custom-check
info:
name: my custom security check
author: you
severity: medium
description: checks for something specific
tags: [custom, recon]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/admin"
- "{{BaseURL}}/login"
matchers:
- type: status
status:
- 200
- type: word
part: body
words:
- "admin panel"
- "login"
condition: or
```
see [docs/modules.md](docs/modules.md) for the full module format.
## contribute
contributions welcome. see [contributing.md](CONTRIBUTING.md) for guidelines.
```bash
# format
gofmt -w .
# lint
golangci-lint run
# test
go test ./...
```
## community
join our discord for support, feature discussions, and pentesting tips:
[![discord](https://img.shields.io/badge/join%20our%20discord-5865F2?style=for-the-badge&logo=discord&logoColor=white)](https://discord.gg/sifcli)
## contributors
<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->
<!-- prettier-ignore-start -->
<!-- markdownlint-disable -->
<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://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://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>
</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>
</tr>
</tbody>
</table>
<!-- markdownlint-restore -->
<!-- prettier-ignore-end -->
<!-- ALL-CONTRIBUTORS-LIST:END -->
## acknowledgements
- [projectdiscovery](https://projectdiscovery.io/) for nuclei and other security tools
- [shodan](https://www.shodan.io/) for infrastructure intelligence
---
<div align="center">
<sub>bsd 3-clause license · made by vmfunc, xyzeva, and contributors</sub>
</div>
+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.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 115 KiB

After

Width:  |  Height:  |  Size: 1.5 MiB

+45 -1
View File
@@ -1,12 +1,50 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package main
import (
"fmt"
"os"
"github.com/charmbracelet/log"
"github.com/dropalldatabases/sif"
"github.com/dropalldatabases/sif/pkg/config"
"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)
@@ -14,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)
+52
View File
@@ -0,0 +1,52 @@
# sif documentation
welcome to the sif documentation. sif is a modular pentesting toolkit designed to be fast, concurrent, and extensible.
## table of contents
### getting started
- [installation](installation.md) - how to install sif
- [quickstart](quickstart.md) - get up and running in minutes
- [usage](usage.md) - command line options and examples
### features
- [scans](scans.md) - built-in security scans
- [modules](modules.md) - yaml module system and custom modules
### reference
- [configuration](configuration.md) - runtime configuration options
- [api mode](api-mode.md) - json output for automation
### contributing
- [development](development.md) - setting up a dev environment
- [writing modules](modules.md#writing-modules) - create your own modules
---
## quick links
```bash
# install
git clone https://github.com/dropalldatabases/sif.git && cd sif && make
# basic scan
./sif -u https://example.com
# list modules
./sif -lm
# run all modules
./sif -u https://example.com -am
# help
./sif -h
```
## support
- [github issues](https://github.com/vmfunc/sif/issues) - bug reports and feature requests
- [discord](https://discord.gg/sifcli) - community chat
+160
View File
@@ -0,0 +1,160 @@
# api mode
use sif's json output for automation and integration.
## enabling api mode
```bash
./sif -u https://example.com -api
```
## output format
api mode outputs json to stdout:
```json
{
"url": "https://example.com",
"results": [
{
"id": "module-id",
"data": {
"module_id": "module-id",
"target": "https://example.com",
"findings": [
{
"url": "https://example.com/.git/HEAD",
"severity": "high",
"evidence": "ref: refs/heads/main",
"extracted": {
"branch": "main"
}
}
]
}
}
]
}
```
## fields
### url
the target url that was scanned.
### results
array of module results.
### results[].id
module identifier.
### results[].data.findings
array of security findings from the module.
### findings[].url
the specific url where the finding was detected.
### findings[].severity
severity level: `info`, `low`, `medium`, `high`, `critical`
### findings[].evidence
evidence that triggered the finding (matched content, etc).
### findings[].extracted
extracted data from the response (versions, keys, etc).
## examples
### save to file
```bash
./sif -u https://example.com -api -am > results.json
```
### pipe to jq
```bash
./sif -u https://example.com -api -am | jq '.results[].data.findings[]'
```
### filter high severity
```bash
./sif -u https://example.com -api -am | jq '.results[].data.findings[] | select(.severity == "high")'
```
### extract urls
```bash
./sif -u https://example.com -api -am | jq -r '.results[].data.findings[].url'
```
## ci/cd integration
### github actions
```yaml
- name: run sif scan
run: |
./sif -u ${{ env.TARGET_URL }} -api -am > sif-results.json
- name: check for high severity findings
run: |
HIGH_COUNT=$(jq '[.results[].data.findings[] | select(.severity == "high" or .severity == "critical")] | length' sif-results.json)
if [ "$HIGH_COUNT" -gt 0 ]; then
echo "Found $HIGH_COUNT high/critical severity findings"
exit 1
fi
```
### gitlab ci
```yaml
security_scan:
script:
- ./sif -u $TARGET_URL -api -am > sif-results.json
- |
if jq -e '.results[].data.findings[] | select(.severity == "critical")' sif-results.json > /dev/null; then
echo "Critical findings detected"
exit 1
fi
artifacts:
paths:
- sif-results.json
```
## multiple targets
when scanning multiple urls, each target outputs a separate json object:
```bash
./sif -u https://site1.com,https://site2.com -api
```
outputs:
```json
{"url":"https://site1.com","results":[...]}
{"url":"https://site2.com","results":[...]}
```
use `jq -s` to combine into an array:
```bash
./sif -u https://site1.com,https://site2.com -api | jq -s '.'
```
## notes
- api mode suppresses banner and interactive output
- all output goes to stdout
- errors and warnings still go to stderr
- combine with `-l` flag to also save detailed logs
+162
View File
@@ -0,0 +1,162 @@
# configuration
runtime configuration options for sif.
## environment variables
### SHODAN_API_KEY
required for shodan lookups.
```bash
export SHODAN_API_KEY=your-api-key-here
./sif -u https://example.com -shodan
```
## command line options
### timeout
default request timeout is 10 seconds.
```bash
# increase for slow targets
./sif -u https://example.com -t 30s
# decrease for fast scans
./sif -u https://example.com -t 5s
```
### threads
default is 10 concurrent threads.
```bash
# more threads for faster scanning
./sif -u https://example.com --threads 50
# fewer threads to reduce load
./sif -u https://example.com --threads 5
```
### logging
save output to files:
```bash
./sif -u https://example.com -l ./logs
```
creates timestamped log files in the specified directory.
### debug mode
enable verbose logging:
```bash
./sif -u https://example.com -d
```
## user modules
place custom modules in:
- linux/macos: `~/.config/sif/modules/`
- windows: `%LOCALAPPDATA%\sif\modules\`
### directory structure
```
~/.config/sif/
├── modules/
│ ├── http/
│ │ └── my-sqli-check.yaml
│ ├── recon/
│ │ └── custom-paths.yaml
│ └── my-module.yaml
```
modules can be organized in subdirectories or placed directly in the modules folder.
### overriding built-in modules
user modules with the same id as built-in modules will override them:
```yaml
# ~/.config/sif/modules/sqli-error-based.yaml
# this overrides the built-in sqli-error-based module
id: sqli-error-based
info:
name: my custom sqli check
# ...
```
## performance tuning
### fast scans
```bash
./sif -u https://example.com \
--threads 50 \
-t 5s \
-dirlist small \
-dnslist small
```
### thorough scans
```bash
./sif -u https://example.com \
--threads 10 \
-t 30s \
-dirlist large \
-dnslist large \
-ports full
```
### low-impact scans
reduce load on target:
```bash
./sif -u https://example.com \
--threads 2 \
-t 10s
```
## output formats
### console (default)
human-readable output with colors and formatting.
### json (api mode)
```bash
./sif -u https://example.com -api
```
returns structured json:
```json
{
"url": "https://example.com",
"results": [
{
"id": "sqli-error-based",
"data": {
"findings": [...]
}
}
]
}
```
### log files
```bash
./sif -u https://example.com -l ./logs
```
creates separate log files for each scan type.
+193
View File
@@ -0,0 +1,193 @@
# development
setting up a development environment for sif.
## prerequisites
- go 1.25 or later
- git
- make
## clone and build
```bash
git clone https://github.com/dropalldatabases/sif.git
cd sif
make
```
## project structure
```
sif/
├── cmd/sif/ # entry point
│ └── main.go
├── sif.go # main application logic
├── internal/ # private packages
│ ├── config/ # configuration parsing
│ ├── logger/ # logging utilities
│ ├── modules/ # module system
│ ├── scan/ # built-in scans
│ └── styles/ # terminal styling
├── modules/ # built-in yaml modules
│ ├── http/ # http-based modules
│ ├── info/ # information gathering
│ └── recon/ # reconnaissance modules
├── docs/ # documentation
└── assets/ # images, etc
```
## running locally
```bash
# build
make
# run
./sif -u https://example.com
# run with debug
./sif -u https://example.com -d
```
## code quality
### format
```bash
gofmt -w .
```
### lint
```bash
golangci-lint run
```
### test
```bash
go test ./...
```
### race detection
```bash
go test -race ./...
```
## adding a new scan
1. create a new file in `internal/scan/`
2. implement the scan function
3. add flag to `internal/config/config.go`
4. integrate in `sif.go`
see existing scans for examples.
## adding a new module
create a yaml file in `modules/`:
```yaml
id: my-new-module
info:
name: my new security check
author: your-name
severity: medium
description: what this checks for
tags: [custom, security]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/path"
matchers:
- type: status
status:
- 200
```
see [modules.md](modules.md) for the full format.
## module system internals
the module system is in `internal/modules/`:
- `module.go` - core interface and types
- `registry.go` - module registration
- `loader.go` - discovery and loading
- `yaml.go` - yaml parsing
- `executor.go` - http execution
### adding a new module type
1. add type constant to `module.go`
2. implement executor in new file
3. update loader to handle new extension/type
## testing
### unit tests
```bash
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
./sif -u https://example.com -am
```
### test modules
```bash
./sif -lm # list modules
./sif -u https://example.com -m my-module -d # test specific module
```
## pull requests
1. fork the repository
2. create a feature branch
3. make changes
4. run `gofmt -w .` and `golangci-lint run`
5. submit pr
### commit messages
use lowercase, present tense:
```
add sql injection module
fix timeout handling in http executor
update readme with new flags
```
## release process
releases are automated via github actions on push to main.
binaries are built for:
- linux (amd64, 386, arm64)
- macos (amd64, arm64)
- windows (amd64, 386)
## resources
- [go documentation](https://golang.org/doc/)
- [goflags](https://github.com/projectdiscovery/goflags) - cli parsing
- [nuclei templates](https://github.com/projectdiscovery/nuclei-templates) - module format inspiration
+93
View File
@@ -0,0 +1,93 @@
# installation
## from releases
download the latest binary for your platform from [releases](https://github.com/vmfunc/sif/releases).
### linux
```bash
# download
curl -LO https://github.com/vmfunc/sif/releases/latest/download/sif-linux-amd64
# make executable
chmod +x sif-linux-amd64
# move to path (optional)
sudo mv sif-linux-amd64 /usr/local/bin/sif
```
### macos
```bash
# intel
curl -LO https://github.com/vmfunc/sif/releases/latest/download/sif-macos-amd64
# apple silicon
curl -LO https://github.com/vmfunc/sif/releases/latest/download/sif-macos-arm64
chmod +x sif-macos-*
sudo mv sif-macos-* /usr/local/bin/sif
```
### windows
download `sif-windows-amd64.exe` from releases and add to your PATH.
## from source
requires go 1.25+
```bash
git clone https://github.com/dropalldatabases/sif.git
cd sif
make
```
the binary will be created in the current directory.
### install to system
```bash
sudo make install
```
this installs to `/usr/local/bin/sif`.
### uninstall
```bash
sudo make uninstall
```
## verify installation
```bash
./sif -h
```
you should see the help output with available flags.
## updating
### from releases
download the new binary and replace the old one.
### from source
```bash
cd sif
git pull
make clean
make
```
## modules directory
sif looks for modules in these locations:
- **built-in**: `modules/` directory next to the sif binary
- **user modules**: `~/.config/sif/modules/` (linux/macos) or `%LOCALAPPDATA%\sif\modules\` (windows)
user modules override built-in modules with the same id.
+387
View File
@@ -0,0 +1,387 @@
# writing sif modules
sif modules are yaml files that define security checks. they're similar to nuclei templates but designed specifically for sif.
## module locations
- **built-in**: `modules/` directory in the sif installation
- **user-defined**: `~/.config/sif/modules/` (linux/macos) or `%LOCALAPPDATA%\sif\modules\` (windows)
user modules can override built-in modules with the same id.
## basic structure
```yaml
id: unique-module-id
info:
name: human readable name
author: your-name
severity: low|medium|high|critical|info
description: what this module checks for
tags: [tag1, tag2, tag3]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/path"
matchers:
- type: status
status:
- 200
```
## fields
### id (required)
unique identifier for the module. use lowercase with hyphens.
```yaml
id: sqli-error-based
```
### info (required)
metadata about the module.
```yaml
info:
name: SQL Injection Detection
author: sif
severity: high
description: detects sql injection via error messages
tags: [sqli, injection, owasp-top10]
```
**severity levels:**
- `info` - informational finding
- `low` - minor issue
- `medium` - moderate security concern
- `high` - serious vulnerability
- `critical` - critical security flaw
### type (required)
module type. currently only `http` is supported.
```yaml
type: http
```
### http
http request configuration.
#### method
http method to use.
```yaml
http:
method: GET
```
supported: `GET`, `POST`, `PUT`, `DELETE`, `HEAD`, `OPTIONS`
#### paths
urls to check. use `{{BaseURL}}` as placeholder for the target.
```yaml
http:
paths:
- "{{BaseURL}}/.git/HEAD"
- "{{BaseURL}}/.git/config"
- "{{BaseURL}}/admin"
```
#### payloads
values to inject into paths. use `{{payload}}` as placeholder.
```yaml
http:
paths:
- "{{BaseURL}}/?id={{payload}}"
payloads:
- "'"
- "1' OR '1'='1"
- "1; DROP TABLE--"
```
each payload creates a separate request for each path.
#### headers
custom headers to send.
```yaml
http:
headers:
User-Agent: "Mozilla/5.0"
X-Custom-Header: "value"
```
#### body
request body for POST/PUT requests.
```yaml
http:
method: POST
body: '{"username": "admin", "password": "{{payload}}"}'
```
#### threads
concurrent requests (default: 10).
```yaml
http:
threads: 5
```
## matchers
matchers determine if a response indicates a finding.
### status matcher
match http status codes.
```yaml
matchers:
- type: status
status:
- 200
- 301
- 302
```
### word matcher
match words in response.
```yaml
matchers:
- type: word
part: body
words:
- "admin"
- "login"
condition: or
```
**parts:**
- `body` - response body
- `header` - response headers
**conditions:**
- `or` - match any word (default)
- `and` - match all words
### regex matcher
match regex patterns.
```yaml
matchers:
- type: regex
part: body
regex:
- "SQL syntax.*MySQL"
- "ORA-[0-9]+"
- "PostgreSQL.*ERROR"
condition: or
```
### combining matchers
multiple matchers are combined with AND logic by default.
```yaml
matchers:
- type: status
status:
- 200
- type: word
part: body
words:
- "ref: refs/"
condition: or
```
this matches responses with status 200 AND containing "ref: refs/".
## extractors
extractors pull data from responses.
### regex extractor
```yaml
extractors:
- type: regex
name: version
part: body
regex:
- "version[\"']?\\s*[:=]\\s*[\"']?([0-9.]+)"
group: 1
```
**group**: capture group to extract (0 = full match, 1+ = groups)
### kv extractor
extract key-value pairs.
```yaml
extractors:
- type: kv
name: headers
part: header
```
## examples
### exposed git repository
```yaml
id: git-exposed
info:
name: exposed git repository
author: sif
severity: high
description: detects exposed .git directories
tags: [git, exposure, source-code]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/.git/HEAD"
- "{{BaseURL}}/.git/config"
matchers:
- type: word
part: body
words:
- "ref: refs/"
- "[core]"
condition: or
- type: status
status:
- 200
extractors:
- type: regex
name: branch
part: body
regex:
- "ref: refs/heads/(.+)"
group: 1
```
### sql injection detection
```yaml
id: sqli-error-based
info:
name: sql injection (error-based)
author: sif
severity: high
description: detects sql injection via database errors
tags: [sqli, injection, database]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/?id={{payload}}"
- "{{BaseURL}}/search?q={{payload}}"
payloads:
- "'"
- "1' OR '1'='1"
- "1; SELECT * FROM--"
threads: 10
matchers:
- type: regex
part: body
regex:
- "SQL syntax.*MySQL"
- "ORA-[0-9]+"
- "PostgreSQL.*ERROR"
- "Microsoft SQL Server"
condition: or
```
### security headers check
```yaml
id: security-headers
info:
name: security headers analysis
author: sif
severity: info
description: checks for missing security headers
tags: [headers, security, info]
type: http
http:
method: GET
paths:
- "{{BaseURL}}/"
matchers:
- type: status
status:
- 200
extractors:
- type: kv
name: headers
part: header
```
## tips
1. **use specific paths** - don't just check `/`, be specific about what you're looking for
2. **combine matchers** - use status + content matchers together to reduce false positives
3. **limit payloads** - too many payloads slow down scans, pick the most effective ones
4. **tag properly** - use consistent tags so modules can be filtered with `-mt`
5. **test locally** - run your module against a test target before sharing
## running modules
```bash
# list all modules
./sif -lm
# run specific module
./sif -u https://example.com -m git-exposed
# run multiple modules
./sif -u https://example.com -m git-exposed,sqli-error-based
# run by tag
./sif -u https://example.com -mt owasp-top10
# run all modules
./sif -u https://example.com -am
```
+102
View File
@@ -0,0 +1,102 @@
# quickstart
get up and running with sif in minutes.
## basic scan
run a basic scan against a target:
```bash
./sif -u https://example.com
```
this performs a base scan checking robots.txt, common files, and basic reconnaissance.
## add more scans
enable additional scan types with flags:
```bash
# directory fuzzing
./sif -u https://example.com -dirlist medium
# subdomain enumeration
./sif -u https://example.com -dnslist small
# port scanning
./sif -u https://example.com -ports common
# framework detection
./sif -u https://example.com -framework
```
## run modules
sif has a modular architecture with yaml-based security checks:
```bash
# list available modules
./sif -lm
# run all modules
./sif -u https://example.com -am
# run specific modules
./sif -u https://example.com -m sqli-error-based,xss-reflected
# run by tag
./sif -u https://example.com -mt owasp-top10
```
## multiple targets
scan multiple urls:
```bash
./sif -u https://site1.com,https://site2.com
```
or from a file:
```bash
./sif -f targets.txt
```
## save output
save results to a log directory:
```bash
./sif -u https://example.com -l ./logs
```
## json output
for automation, use api mode:
```bash
./sif -u https://example.com -api
```
## full scan example
run everything:
```bash
./sif -u https://example.com \
-dirlist medium \
-dnslist small \
-ports common \
-framework \
-js \
-headers \
-git \
-am \
-l ./logs
```
## next steps
- [usage](usage.md) - all command line options
- [scans](scans.md) - detailed scan descriptions
- [modules](modules.md) - write custom modules
+239
View File
@@ -0,0 +1,239 @@
# scans
detailed information about sif's built-in security scans.
## base scan
runs automatically unless `-noscan` is specified.
checks:
- robots.txt parsing
- common files (sitemap.xml, security.txt, etc)
- basic reconnaissance
## directory fuzzing (-dirlist)
brute-forces directories and files using wordlists.
### sizes
| size | entries | use case |
|------|---------|----------|
| small | ~1k | quick scan, low noise |
| medium | ~10k | balanced coverage |
| large | ~100k | thorough, takes longer |
### what it finds
- hidden directories (/admin, /backup, /config)
- backup files (.bak, .old, .zip)
- configuration files
- development artifacts
## subdomain enumeration (-dnslist)
discovers subdomains via dns brute-forcing.
### sizes
| size | entries | use case |
|------|---------|----------|
| small | ~1k | quick discovery |
| medium | ~10k | common subdomains |
| large | ~100k | comprehensive |
### what it finds
- dev/staging environments
- internal services
- forgotten subdomains
- api endpoints
## port scanning (-ports)
scans for open ports and identifies services.
### scopes
| scope | ports | description |
|-------|-------|-------------|
| common | top 1000 | most common services |
| full | 1-65535 | all ports, slow |
### what it finds
- web servers (80, 443, 8080)
- databases (3306, 5432, 27017)
- admin interfaces (8443, 9090)
- development servers
## framework detection (-framework)
identifies web frameworks and their versions.
### detects
- react, vue, angular, next.js
- django, flask, rails
- laravel, symfony, express
- wordpress, drupal, joomla
### features
- version detection
- cve lookup for known vulnerabilities
- confidence scoring
## javascript analysis (-js)
analyzes javascript files for security issues.
### finds
- api endpoints and keys
- hardcoded credentials
- internal urls
- framework configurations
- source maps
## http headers (-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 (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)
identifies content management systems.
### detects
- wordpress (with version)
- drupal
- joomla
- magento
- shopify
- ghost
## git repository (-git)
checks for exposed git repositories.
### finds
- .git/HEAD
- .git/config
- .git/index
- source code exposure risk
## cloud storage (-c3)
checks for cloud storage misconfigurations.
### checks
- s3 bucket access
- azure blob storage
- gcp storage buckets
- open bucket policies
## subdomain takeover (-st)
detects subdomain takeover vulnerabilities.
requires `-dnslist` to enumerate subdomains first.
### checks
- dangling cname records
- unclaimed cloud services
- expired third-party services
## shodan lookup (-shodan)
queries shodan for host intelligence.
requires `SHODAN_API_KEY` environment variable.
### returns
- open ports
- services and versions
- known vulnerabilities
- ssl/tls info
- organization data
## sql reconnaissance (-sql)
detects sql-related exposures.
### finds
- admin panels (/phpmyadmin, /adminer)
- database error messages
- sql injection indicators
## lfi scanning (-lfi)
checks for local file inclusion vulnerabilities.
### tests
- path traversal (../)
- null byte injection
- common lfi payloads
- sensitive file disclosure
## whois lookup (-whois)
performs whois lookups on target domains.
### returns
- registrar info
- creation/expiration dates
- nameservers
- registrant info (if available)
## google dorking (-dork)
automated google dorking for target.
### searches
- indexed sensitive files
- exposed admin panels
- configuration files
- backup files
- error pages
## nuclei scanning (-nuclei)
runs nuclei vulnerability templates.
requires nuclei to be installed.
### templates
- cve detection
- misconfigurations
- exposures
- default credentials
+593
View File
@@ -0,0 +1,593 @@
# usage
complete guide to sif command line options.
## target options
### -u, --urls
specify target urls (comma-separated):
```bash
./sif -u https://example.com
./sif -u https://site1.com,https://site2.com
```
### -f, --file
read targets from a file (one url per line):
```bash
./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
`-dirlist <size>` - fuzz for directories and files
sizes: `small`, `medium`, `large`
```bash
./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
sizes: `small`, `medium`, `large`
```bash
./sif -u https://example.com -dnslist small
```
### port scanning
`-ports <scope>` - scan for open ports
scopes: `common` (top ports), `full` (all ports)
```bash
./sif -u https://example.com -ports common
```
### google dorking
`-dork` - automated google dorking
```bash
./sif -u https://example.com -dork
```
### git repository detection
`-git` - check for exposed git repositories
```bash
./sif -u https://example.com -git
```
### nuclei scanning
`-nuclei` - run nuclei vulnerability templates
```bash
./sif -u https://example.com -nuclei
```
### javascript analysis
`-js` - analyze javascript files + secret and endpoint extraction
```bash
./sif -u https://example.com -js
```
### cms detection
`-cms` - detect content management systems
```bash
./sif -u https://example.com -cms
```
### http 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
```bash
./sif -u https://example.com -c3
```
### subdomain takeover
`-st` - check for subdomain takeover vulnerabilities
requires `-dnslist` to be enabled
```bash
./sif -u https://example.com -dnslist small -st
```
### shodan lookup
`-shodan` - query shodan for host intelligence
requires `SHODAN_API_KEY` environment variable
```bash
export SHODAN_API_KEY=your-api-key
./sif -u https://example.com -shodan
```
### sql reconnaissance
`-sql` - detect sql admin panels and error disclosure
```bash
./sif -u https://example.com -sql
```
### lfi scanning
`-lfi` - local file inclusion vulnerability checks
```bash
./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
```bash
./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
```bash
./sif -u https://example.com -whois
```
### skip base scan
`-noscan` - skip the base url scan (robots.txt, etc)
```bash
./sif -u https://example.com -noscan -dirlist medium
```
## module options
### -lm, --list-modules
list all available modules:
```bash
./sif -lm
```
### -m, --modules
run specific modules by id (comma-separated):
```bash
./sif -u https://example.com -m sqli-error-based,xss-reflected
```
### -mt, --module-tags
run modules matching tags:
```bash
./sif -u https://example.com -mt owasp-top10
./sif -u https://example.com -mt injection
```
### -am, --all-modules
run all available modules:
```bash
./sif -u https://example.com -am
```
## runtime options
### -t, --timeout
http request timeout (default: 10s):
```bash
./sif -u https://example.com -t 30s
```
### --threads
number of concurrent threads (default: 10). values below 1 are clamped to 1:
```bash
./sif -u https://example.com --threads 20
```
### -l, --log
directory to save log files:
```bash
./sif -u https://example.com -l ./logs
```
### -d, --debug
enable debug logging:
```bash
./sif -u https://example.com -d
```
## 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
enable api mode for json output:
```bash
./sif -u https://example.com -api
```
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
```bash
./sif -u https://example.com -framework -headers -git
```
### full scan
```bash
./sif -u https://example.com \
-dirlist large \
-dnslist medium \
-ports full \
-framework \
-js \
-headers \
-cms \
-git \
-sql \
-lfi \
-cors \
-redirect \
-xss \
-am
```
### ci/cd pipeline
```bash
./sif -u https://staging.example.com -api -am > results.json
```
### batch scanning
```bash
echo "https://site1.com
https://site2.com
https://site3.com" > targets.txt
./sif -f targets.txt -am -l ./logs
```
Generated
+4 -62
View File
@@ -1,35 +1,12 @@
{
"nodes": {
"gomod2nix": {
"inputs": {
"nixpkgs": [
"nixpkgs"
],
"utils": [
"utils"
]
},
"locked": {
"lastModified": 1677459247,
"narHash": "sha256-JbakfAiPYmCCV224yAMq/XO0udN5coWv/oazblMKdoY=",
"owner": "tweag",
"repo": "gomod2nix",
"rev": "3cbf3a51fe32e2f57af4c52744e7228bab22983d",
"type": "github"
},
"original": {
"owner": "tweag",
"repo": "gomod2nix",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1693844670,
"narHash": "sha256-t69F2nBB8DNQUWHD809oJZJVE+23XBrth4QZuVd6IE0=",
"lastModified": 1780930886,
"narHash": "sha256-rppURzHviaQN131F+nLiLdGfcb0uCd9gGP0E5+iw9MI=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "3c15feef7770eb5500a4b8792623e2d6f598c9c1",
"rev": "8c3cede7ddc26bd659d2d383b5610efbd2c7a16e",
"type": "github"
},
"original": {
@@ -41,42 +18,7 @@
},
"root": {
"inputs": {
"gomod2nix": "gomod2nix",
"nixpkgs": "nixpkgs",
"utils": "utils"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1692799911,
"narHash": "sha256-3eihraek4qL744EvQXsK1Ha6C3CR7nnT8X2qWap4RNk=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "f9e7cf818399d17d347f847525c5a5a8032e4e44",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
"nixpkgs": "nixpkgs"
}
}
},
+49 -28
View File
@@ -1,36 +1,57 @@
{
description = "a blazing-fast pentesting (recon/exploitation) suite";
description = "A blazing-fast pentesting (recon/exploitation) suite";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
utils.url = "github:numtide/flake-utils";
gomod2nix = {
url = "github:tweag/gomod2nix";
inputs.nixpkgs.follows = "nixpkgs";
inputs.utils.follows = "utils";
};
};
outputs = { self, nixpkgs, utils, gomod2nix }:
utils.lib.eachDefaultSystem (system:
let pkgs = import nixpkgs {
inherit system;
overlays = [ gomod2nix.overlays.default ];
outputs = { self, nixpkgs }:
let
systems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ];
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f system);
in
{
packages = forAllSystems (system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
default = pkgs.buildGoModule {
pname = "sif";
version = "unstable-${self.shortRev or self.dirtyShortRev or "dev"}";
src = ./.;
vendorHash = "sha256-fR63/dStMsZon22vancuLWIAvZiEYMLjMwY1kmRDNgM=";
# Tests require network access (httptest)
doCheck = false;
ldflags = [ "-s" "-w" ];
meta = with pkgs.lib; {
description = "Modular pentesting toolkit written in Go";
homepage = "https://github.com/vmfunc/sif";
license = licenses.bsd3;
mainProgram = "sif";
maintainers = [ ];
};
};
sif = self.packages.${system}.default;
});
overlays.default = final: prev: {
sif = self.packages.${final.system}.default;
};
in
{
packages.default = pkgs.buildGoApplication {
pname = "sif";
version = "0.1.0";
src = ./.;
modules = ./gomod2nix.toml;
};
devShells.default = pkgs.mkShell {
buildInputs = with pkgs; [
go
gomod2nix.packages.${system}.default
];
};
});
devShells = forAllSystems (system:
let
pkgs = nixpkgs.legacyPackages.${system};
in
{
default = pkgs.mkShell {
buildInputs = with pkgs; [ go gopls ];
};
});
};
}
+317 -144
View File
@@ -1,224 +1,397 @@
module github.com/dropalldatabases/sif
go 1.21
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 v0.10.0
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/charmbracelet/log v1.0.0
github.com/gocolly/colly/v2 v2.1.0
github.com/likexian/whois v1.15.7
github.com/projectdiscovery/goflags v0.1.74
github.com/projectdiscovery/nuclei/v3 v3.8.0
github.com/projectdiscovery/retryabledns v1.0.114
github.com/projectdiscovery/utils v0.10.1
github.com/rocketlaunchr/google-search v1.1.6
github.com/twmb/murmur3 v1.1.6
golang.org/x/net v0.53.0
golang.org/x/time v0.14.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/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/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible // indirect
github.com/Masterminds/semver/v3 v3.2.1 // 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/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/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.4.4 // 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.22.0 // 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.1 // 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/cloudflare/circl v1.3.7 // 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/docker v28.3.3+incompatible // indirect
github.com/docker/go-connections v0.6.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/gaukas/godicttls v0.0.4 // 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.26.1 // indirect
github.com/geoffgarside/ber v1.1.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.11 // 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/golang-jwt/jwt/v4 v4.5.0 // 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/gobwas/ws v1.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 // indirect
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/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/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.16.7 // 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.2 // 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/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.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/mattn/go-runewidth v0.0.16 // 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/sys/atomicwriter v0.1.0 // 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/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/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.31 // indirect
github.com/projectdiscovery/clistats v0.1.1 // indirect
github.com/projectdiscovery/dsl v0.8.14 // indirect
github.com/projectdiscovery/fastdialer v0.5.6 // indirect
github.com/projectdiscovery/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.68 // indirect
github.com/projectdiscovery/gostruct v0.0.2 // indirect
github.com/projectdiscovery/gozero v0.1.1-0.20251027191944-a4ea43320b81 // indirect
github.com/projectdiscovery/hmap v0.0.100 // indirect
github.com/projectdiscovery/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.36 // indirect
github.com/projectdiscovery/ratelimit v0.0.85 // 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/retryablehttp-go v1.3.8 // 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/quic-go/quic-go v0.42.0 // indirect
github.com/refraction-networking/utls v1.5.4 // indirect
github.com/projectdiscovery/tlsx v1.2.2 // indirect
github.com/projectdiscovery/uncover v1.2.0 // indirect
github.com/projectdiscovery/useragent v0.0.107 // indirect
github.com/projectdiscovery/wappalyzergo v0.2.76 // indirect
github.com/projectdiscovery/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/rs/xid v1.5.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rs/xid v1.6.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.3 // 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/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.9.2 // 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/gjson v1.18.0 // 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/ulikunitz/xz v0.5.11 // 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 v0.130.1 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
go.mongodb.org/mongo-driver v1.17.9 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0 // indirect
go.opentelemetry.io/otel v1.41.0 // indirect
go.opentelemetry.io/otel/metric v1.41.0 // indirect
go.opentelemetry.io/otel/trace v1.41.0 // indirect
go.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.21.0 // indirect
golang.org/x/exp v0.0.0-20230626212559-97b1e661b5df // indirect
golang.org/x/mod v0.12.0 // indirect
golang.org/x/net v0.23.0 // indirect
golang.org/x/oauth2 v0.11.0 // indirect
golang.org/x/sync v0.6.0 // indirect
golang.org/x/sys v0.20.0 // indirect
golang.org/x/term v0.18.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.13.0 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.50.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/term v0.42.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/tools v0.44.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.33.0 // indirect
google.golang.org/protobuf v1.36.6 // 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
gopkg.in/yaml.v3 v3.0.1 // indirect
mellium.im/sasl v0.3.2 // indirect
moul.io/http2curl v1.0.0 // indirect
)
+1195 -326
View File
File diff suppressed because it is too large Load Diff
-624
View File
@@ -1,624 +0,0 @@
schema = 3
[mod]
[mod."aead.dev/minisign"]
version = "v0.2.0"
hash = "sha256-2a05wSk811IdX9WSfMsrAvjPe6XVXEd4cvojrV+zqJ4="
[mod."git.mills.io/prologic/smtpd"]
version = "v0.0.0-20210710122116-a525b76c287a"
hash = "sha256-tbfKCLDJKAoZE3BvimQQLPn1cou2eA2wyMB0y1zPJEc="
[mod."github.com/Knetic/govaluate"]
version = "v3.0.1-0.20171022003610-9aa49832a739+incompatible"
hash = "sha256-Qs7qeK+Mrlm4ToAEYvN+OY6X7SRFV808frvKNr6gNhE="
[mod."github.com/Masterminds/semver/v3"]
version = "v3.2.1"
hash = "sha256-VKHIquwriyOL8A0qgtmap/3cGEOpDokOLtPg1w4xjMA="
[mod."github.com/Mzack9999/gcache"]
version = "v0.0.0-20230410081825-519e28eab057"
hash = "sha256-ofR592gukVdlEqA5ny9BPRDL4q2DrDTZeh4x1lrEmnQ="
[mod."github.com/Mzack9999/go-http-digest-auth-client"]
version = "v0.6.1-0.20220414142836-eb8883508809"
hash = "sha256-N4W589FOd0Oej0hpWsH0FaOBFxrYmAyX+L6eFW5sXDA="
[mod."github.com/Mzack9999/ldapserver"]
version = "v1.0.2-0.20211229000134-b44a0d6ad0dd"
hash = "sha256-s7X5Zd9Py8mKjJ/xWfgtrmYXl6ynpETwf0KXlnj3rRc="
[mod."github.com/PuerkitoBio/goquery"]
version = "v1.8.1"
hash = "sha256-z2RaB8PVPEzSJdMUfkfNjT616yXWTjW2gkhNOh989ZU="
[mod."github.com/VividCortex/ewma"]
version = "v1.2.0"
hash = "sha256-mHprIVRUOgs1qyYpiMO3bh6fCzDrqasDsaTaRE0oHXI="
[mod."github.com/akrylysov/pogreb"]
version = "v0.10.1"
hash = "sha256-f1BoPiR4KghX68eDPYQVuv1AVj97X1a+biip4vCrQ/s="
[mod."github.com/alecthomas/chroma"]
version = "v0.10.0"
hash = "sha256-p721vddVTv4iv1O0/dqpdk5xF6x9iLIHcrfh8JEVnqQ="
[mod."github.com/alecthomas/jsonschema"]
version = "v0.0.0-20211022214203-8b29eab41725"
hash = "sha256-l0OFXpa2E/t839tJGLY6jJUCuQC0SLCseYKsfM5o2vI="
[mod."github.com/alecthomas/template"]
version = "v0.0.0-20190718012654-fb15b899a751"
hash = "sha256-RsS4qxdRQ3q+GejA8D9Iu31A/mZNms4LbJ7518jWiu4="
[mod."github.com/alecthomas/units"]
version = "v0.0.0-20211218093645-b94a6e3cc137"
hash = "sha256-uriYmwxT69xbmWKO/5OAyeMa2lFBOJDrU2KtQh/+ZjY="
[mod."github.com/andybalholm/brotli"]
version = "v1.0.5"
hash = "sha256-/qS8wU8yZQJ+uTOg66rEl9s7spxq9VIXF5L1BcaEClc="
[mod."github.com/andybalholm/cascadia"]
version = "v1.3.2"
hash = "sha256-Nc9SkqJO/ecincVcUBFITy24TMmMGj5o0Q8EgdNhrEk="
[mod."github.com/andygrunwald/go-jira"]
version = "v1.16.0"
hash = "sha256-veyWp65T9uYYmw9o0g4w6tqn5Svq5++WFXNfy4vI+HA="
[mod."github.com/antchfx/htmlquery"]
version = "v1.3.0"
hash = "sha256-tldRSQPTmUodUepZkOnISWjfWPY37MzNN2Pd2/zmvoo="
[mod."github.com/antchfx/xmlquery"]
version = "v1.3.15"
hash = "sha256-uenaH5HiVcIswTjfwm2qqOA0ljY5la0BI4NiH4LjFD4="
[mod."github.com/antchfx/xpath"]
version = "v1.2.4"
hash = "sha256-rT5AtOv49/iGdR6X42Ho+ZEw6+YGQqfNUcYkSp1CU/g="
[mod."github.com/asaskevich/govalidator"]
version = "v0.0.0-20230301143203-a9d515a09cc2"
hash = "sha256-UCENzt1c1tFgsAzK2TNq5s2g0tQMQ5PxFaQKe8hTL/A="
[mod."github.com/aws/aws-sdk-go-v2"]
version = "v1.19.0"
hash = "sha256-z4UJRyk3eLx0yQ3kTl3zKH6bEM7MK1sqPQKvbP8d2Ec="
[mod."github.com/aws/aws-sdk-go-v2/config"]
version = "v1.18.28"
hash = "sha256-zFNtrknzaJ0zQr8EOT/3Y1qqZ/YcRMizRUZHxt9QY0I="
[mod."github.com/aws/aws-sdk-go-v2/credentials"]
version = "v1.13.27"
hash = "sha256-so4NK+rlyZnBtxgUNLld/G7vQKP/wp1A6wRJtaZT2pU="
[mod."github.com/aws/aws-sdk-go-v2/feature/ec2/imds"]
version = "v1.13.5"
hash = "sha256-zseMGwUW3NjzhD5IixiTiwp7x9hRAvpMbADEaYIB6Ig="
[mod."github.com/aws/aws-sdk-go-v2/internal/configsources"]
version = "v1.1.35"
hash = "sha256-TuDsdVuVbqUQbV4Y2E9Exmlu2an0yrfMGgdTHhXY85E="
[mod."github.com/aws/aws-sdk-go-v2/internal/endpoints/v2"]
version = "v2.4.29"
hash = "sha256-P+9wAU5sbBn1tQqS1nFwisaoa3999czJilowwO2rO3Y="
[mod."github.com/aws/aws-sdk-go-v2/internal/ini"]
version = "v1.3.36"
hash = "sha256-9VmY8oidPMnAfpt2AyiCSSascqBZGGLtIizTydlK8k8="
[mod."github.com/aws/aws-sdk-go-v2/service/internal/presigned-url"]
version = "v1.9.29"
hash = "sha256-mXNOY17gXxhS2NV7azA0mxrARkROGrrpeN0Lgg7KQSw="
[mod."github.com/aws/aws-sdk-go-v2/service/sso"]
version = "v1.12.13"
hash = "sha256-F4tTYdgFvDImOQNuKQFFsLwd6bX1CO50Ab3KYqY32Lc="
[mod."github.com/aws/aws-sdk-go-v2/service/ssooidc"]
version = "v1.14.13"
hash = "sha256-XGj/ccaj00wNN32J3JTuuqthCbxrTfmxfSYJLf/hK8Y="
[mod."github.com/aws/aws-sdk-go-v2/service/sts"]
version = "v1.19.3"
hash = "sha256-Q8NFgFRjNUFldTmr/Ya9DyAUNfsC9AuWPkSFMrVF/jg="
[mod."github.com/aws/smithy-go"]
version = "v1.13.5"
hash = "sha256-lu1UnvPnLzXjDPBk2FJ4ZImKRQf7aj43mLbuolFdE64="
[mod."github.com/aymanbagabas/go-osc52/v2"]
version = "v2.0.1"
hash = "sha256-6Bp0jBZ6npvsYcKZGHHIUSVSTAMEyieweAX2YAKDjjg="
[mod."github.com/aymerick/douceur"]
version = "v0.2.0"
hash = "sha256-NiBX8EfOvLXNiK3pJaZX4N73YgfzdrzRXdiBFe3X3sE="
[mod."github.com/bluele/gcache"]
version = "v0.0.2"
hash = "sha256-gU44V3jqw6K3Mjgh6DG9f7DU+ft3wA9GDmH4AgMTjxE="
[mod."github.com/caddyserver/certmagic"]
version = "v0.19.2"
hash = "sha256-ruz2oG5E834tMjyL/HdFPaWlNuwBg/fxqVitZX3sQR0="
[mod."github.com/charmbracelet/glamour"]
version = "v0.6.0"
hash = "sha256-L5V2P/9EPP80703KJUSMDiAPgjW1B4i1IbJADPmUCoY="
[mod."github.com/charmbracelet/lipgloss"]
version = "v0.8.0"
hash = "sha256-m+cRJCCJjNyxJKxCk1ftu32OMesYDIUw/EVUzTZjo9I="
[mod."github.com/charmbracelet/log"]
version = "v0.2.4"
hash = "sha256-LQe3fQHf/v6q8pegS5E54eSfU0Y5tnKXM+Mk6uzeWvU="
[mod."github.com/cheggaaa/pb/v3"]
version = "v3.1.4"
hash = "sha256-Fl0bM8ag8sKr8C/hj5qaxN+VjmRA403xXcQoTdQ19LU="
[mod."github.com/cloudflare/cfssl"]
version = "v1.6.4"
hash = "sha256-dAUHPutZ+bpDgJ0mWrALLIbQqNF2d1OkgSAWzQkxXWY="
[mod."github.com/cloudflare/circl"]
version = "v1.3.3"
hash = "sha256-ItdVkU53Ep01553/tJ4MdAwoTpPljRxiBW9sAd7p0xI="
[mod."github.com/cnf/structhash"]
version = "v0.0.0-20201127153200-e1b16c1ebc08"
hash = "sha256-hvJSTpbaPHgWnJ16B9a4cFVblplAgCw5OkGSUFmJBvg="
[mod."github.com/corpix/uarand"]
version = "v0.2.0"
hash = "sha256-/2ZqTtYPEbfn5adf5tIU9p8jwHFRkBYzi4WE5h2AwkI="
[mod."github.com/dimchansky/utfbom"]
version = "v1.1.1"
hash = "sha256-w8KEprK54zJkMat78T6zldjDwvhbc/O8s6pVFzfmg1I="
[mod."github.com/dlclark/regexp2"]
version = "v1.8.1"
hash = "sha256-Xm4I+Qrpwn21QsWcUMden00zWapbloa6K1yJ83tTOVE="
[mod."github.com/docker/go-units"]
version = "v0.5.0"
hash = "sha256-iK/V/jJc+borzqMeqLY+38Qcts2KhywpsTk95++hImE="
[mod."github.com/dsnet/compress"]
version = "v0.0.1"
hash = "sha256-HCqu3cKayMvx1YIUPkJ+u4UM6WN8nrsNIhdvGJIJgwg="
[mod."github.com/fatih/color"]
version = "v1.15.0"
hash = "sha256-7b+scFVQeEUoXfeCDd8X2gS8GMoWA+HxjK8wfbypa5s="
[mod."github.com/fatih/structs"]
version = "v1.1.0"
hash = "sha256-OCmubTLF1anwNnkvFZDYHnF6hFlX0WDoe/9+dDlaMPM="
[mod."github.com/gabriel-vasile/mimetype"]
version = "v1.4.2"
hash = "sha256-laV+IkgbnEG07h1eFfPISqp0ctnLXfzchz/CLR1lftk="
[mod."github.com/gaukas/godicttls"]
version = "v0.0.4"
hash = "sha256-Tok6mN6P7rnqK+VCiI6LOV9DBnOTjGyGrgfzZdMCMVk="
[mod."github.com/go-logfmt/logfmt"]
version = "v0.6.0"
hash = "sha256-RtIG2qARd5sT10WQ7F3LR8YJhS8exs+KiuUiVf75bWg="
[mod."github.com/go-ole/go-ole"]
version = "v1.2.6"
hash = "sha256-+oxitLeJxYF19Z6g+6CgmCHJ1Y5D8raMi2Cb3M6nXCs="
[mod."github.com/go-playground/locales"]
version = "v0.14.1"
hash = "sha256-BMJGAexq96waZn60DJXZfByRHb8zA/JP/i6f/YrW9oQ="
[mod."github.com/go-playground/universal-translator"]
version = "v0.18.1"
hash = "sha256-2/B2qP51zfiY+k8G0w0D03KXUc7XpWj6wKY7NjNP/9E="
[mod."github.com/go-playground/validator/v10"]
version = "v10.14.1"
hash = "sha256-13J8JqIuhI7lbBagaR7INykFRXqRbB7tjXtMZI3PNvA="
[mod."github.com/go-rod/rod"]
version = "v0.114.0"
hash = "sha256-YQwPbgeBPziMTmFg8kulEQkdTi3OTUutlX+8CmCdQ94="
[mod."github.com/goburrow/cache"]
version = "v0.1.4"
hash = "sha256-3imkv1DlePYg0aBswzxqOn1EzZFwMXW+D3Dq0u0GEEQ="
[mod."github.com/gobwas/glob"]
version = "v0.2.3"
hash = "sha256-hYHMUdwxVkMOjSKjR7UWO0D0juHdI4wL8JEy5plu/Jc="
[mod."github.com/gobwas/httphead"]
version = "v0.1.0"
hash = "sha256-6wFni/JkK2GqtVs3IW+GxHRNoSu4EJfzaBRGX2hF1IA="
[mod."github.com/gobwas/pool"]
version = "v0.2.1"
hash = "sha256-py8/+Wo5Q83EbYMUKK5U/4scRcyMo2MjOoxqi5y+sUY="
[mod."github.com/gobwas/ws"]
version = "v1.2.1"
hash = "sha256-5kWY244Vuyj01BzgTJuaJUJJwTXaKZ0UzPruKATByEg="
[mod."github.com/gocolly/colly/v2"]
version = "v2.1.0"
hash = "sha256-yWhPcNwGj31wWJrnHWOa3jBO1qZXfqOWuHDlmpSPuyg="
[mod."github.com/golang-jwt/jwt/v4"]
version = "v4.5.0"
hash = "sha256-dyKL8wQRApkdCkKxJ1knllvixsrBLw+BtRS0SjlN7NQ="
[mod."github.com/golang/groupcache"]
version = "v0.0.0-20210331224755-41bb18bfe9da"
hash = "sha256-7Gs7CS9gEYZkbu5P4hqPGBpeGZWC64VDwraSKFF+VR0="
[mod."github.com/golang/protobuf"]
version = "v1.5.3"
hash = "sha256-svogITcP4orUIsJFjMtp+Uv1+fKJv2Q5Zwf2dMqnpOQ="
[mod."github.com/golang/snappy"]
version = "v0.0.4"
hash = "sha256-Umx+5xHAQCN/Gi4HbtMhnDCSPFAXSsjVbXd8n5LhjAA="
[mod."github.com/google/certificate-transparency-go"]
version = "v1.1.4"
hash = "sha256-/V18IcVehgvhkT+w7y8vpXaVAtdV3BAsxOnbRBromGw="
[mod."github.com/google/go-github"]
version = "v17.0.0+incompatible"
hash = "sha256-5EGZnkefwLCEODLICIgaq39UoOzBJqpeLraoc2hJfM8="
[mod."github.com/google/go-github/v30"]
version = "v30.1.0"
hash = "sha256-u6m+wWJl440UI64Q2tpX0qFF3LyEH3hPww82hIEf6/Q="
[mod."github.com/google/go-querystring"]
version = "v1.1.0"
hash = "sha256-itsKgKghuX26czU79cK6C2n+lc27jm5Dw1XbIRgwZJY="
[mod."github.com/google/uuid"]
version = "v1.3.1"
hash = "sha256-JxAEAB2bFlGPShFreyOWjUahjaGV3xYS5TpfUOikod0="
[mod."github.com/gorilla/css"]
version = "v1.0.0"
hash = "sha256-Mmt/IqHpgrtWpbr/AKcJyf/USQTqEuv1HVivY4eHzoQ="
[mod."github.com/h2non/filetype"]
version = "v1.1.3"
hash = "sha256-lSX/fSbT3MVlNK7d1U6Q/lBHtGXXAQ/HY4zW6Bppqhc="
[mod."github.com/hashicorp/go-cleanhttp"]
version = "v0.5.2"
hash = "sha256-N9GOKYo7tK6XQUFhvhImtL7PZW/mr4C4Manx/yPVvcQ="
[mod."github.com/hashicorp/go-retryablehttp"]
version = "v0.7.2"
hash = "sha256-PcLyolWF7G409rs7j3tnwgQK6xhgWYk9/iK2bO13TGQ="
[mod."github.com/hashicorp/go-version"]
version = "v1.6.0"
hash = "sha256-UV0equpmW6BiJnp4W3TZlSJ+PTHuTA+CdOs2JTeHhjs="
[mod."github.com/hbakhtiyor/strsim"]
version = "v0.0.0-20190107154042-4d2bbb273edf"
hash = "sha256-vK4ghGQy9IGvAq0/3roEDiE/ybNOePULr4s/V8ZHLj8="
[mod."github.com/hdm/jarm-go"]
version = "v0.0.7"
hash = "sha256-4SnBXV+O7iWPO0Yt9/D1BhaF7MEvNUrwBj116uMt5j0="
[mod."github.com/iancoleman/orderedmap"]
version = "v0.0.0-20190318233801-ac98e3ecb4b0"
hash = "sha256-IIm0P6GnYSBGHzOYc7ljp+5LPoWBmmqXt1Yi4vBRdsQ="
[mod."github.com/itchyny/gojq"]
version = "v0.12.13"
hash = "sha256-tlnj0CCsPZRQjIZCvNPjN0JD6oqRDvdWOCYR3tYMPUA="
[mod."github.com/itchyny/timefmt-go"]
version = "v0.1.5"
hash = "sha256-FvgqEW8fnZsfbHpV+X4FQvDzzneNOpdQtQLXovh1YmI="
[mod."github.com/json-iterator/go"]
version = "v1.1.12"
hash = "sha256-To8A0h+lbfZ/6zM+2PpRpY3+L6725OPC66lffq6fUoM="
[mod."github.com/julienschmidt/httprouter"]
version = "v1.3.0"
hash = "sha256-YVbnyFLVZX1mtqcwM1SStQdhcQsPHyi1ltpOrD3w2qg="
[mod."github.com/kataras/jwt"]
version = "v0.1.8"
hash = "sha256-3AKX8wmQ6RaRMAyhe1JirEl1P0ZiMNRJZ3D1yzBRuCU="
[mod."github.com/kennygrant/sanitize"]
version = "v1.2.4"
hash = "sha256-PRNblaLosaB7tvUVgAOZORMZGUo+7Wy7h1Z1mpJLd5c="
[mod."github.com/klauspost/compress"]
version = "v1.16.7"
hash = "sha256-8miX/lnXyNLPSqhhn5BesLauaIAxETpQpWtr1cu2f+0="
[mod."github.com/klauspost/cpuid/v2"]
version = "v2.2.5"
hash = "sha256-/M8CHNah2/EPr0va44r1Sx+3H6E+jN8bGFi5jQkLBrM="
[mod."github.com/leodido/go-urn"]
version = "v1.2.4"
hash = "sha256-N2HO7ChScxI79KGvXI9LxoIlr+lkBNdDZP9OPGwPRK0="
[mod."github.com/libdns/libdns"]
version = "v0.2.1"
hash = "sha256-bxEY0wYu4Um0t7sakLyMwMPDXfv2x07gjckKSyAypsc="
[mod."github.com/logrusorgru/aurora"]
version = "v2.0.3+incompatible"
hash = "sha256-7o5Fh4jscdYKgXfnNMbcD68Kjw8Z4LcPgHcr4ZyQYrI="
[mod."github.com/lor00x/goldap"]
version = "v0.0.0-20180618054307-a546dffdd1a3"
hash = "sha256-wE3bDMJqd+drbrYK0QPF3GMQOzgB8u9uN2T0uUX9xow="
[mod."github.com/lucasb-eyer/go-colorful"]
version = "v1.2.0"
hash = "sha256-Gg9dDJFCTaHrKHRR1SrJgZ8fWieJkybljybkI9x0gyE="
[mod."github.com/lufia/plan9stats"]
version = "v0.0.0-20211012122336-39d0f177ccd0"
hash = "sha256-thb+rkDx5IeWMgw5/5jgu5gZ+6RjJAUXeMgSkJHhRlA="
[mod."github.com/mackerelio/go-osstat"]
version = "v0.2.4"
hash = "sha256-WW5VbvDedsNRxclUjI/pvlf4vB4VyDKEGlpvcLqiAyo="
[mod."github.com/mattn/go-colorable"]
version = "v0.1.13"
hash = "sha256-qb3Qbo0CELGRIzvw7NVM1g/aayaz4Tguppk9MD2/OI8="
[mod."github.com/mattn/go-isatty"]
version = "v0.0.19"
hash = "sha256-wYQqGxeqV3Elkmn26Md8mKZ/viw598R4Ych3vtt72YE="
[mod."github.com/mattn/go-runewidth"]
version = "v0.0.14"
hash = "sha256-O3QdxqAcJgQ+HL1v8oBA4iKBwJ2AlDN+F464027hWMU="
[mod."github.com/mholt/acmez"]
version = "v1.2.0"
hash = "sha256-zfj14WFQr1/AO64gYsbFk4a4T0dsMEs+W3uIa9968/M="
[mod."github.com/mholt/archiver"]
version = "v3.1.1+incompatible"
hash = "sha256-+XCbzKmuqktmYveDdJCNWB8B6Ya8yJM8H7uugYxrhhA="
[mod."github.com/microcosm-cc/bluemonday"]
version = "v1.0.25"
hash = "sha256-/crG5s6cDrJ55nkDBwugLUpY7U+vQuHpCkKm7nnN8Zc="
[mod."github.com/miekg/dns"]
version = "v1.1.55"
hash = "sha256-Jbii9veDSpqF7yIkdrzb/bEUM3wZG41mNEAYV3VEAJo="
[mod."github.com/minio/selfupdate"]
version = "v0.6.0"
hash = "sha256-CupJKkF1MNaOEMBPjfCxF+k/k3yNWXfWShmJfezg3O4="
[mod."github.com/mitchellh/go-homedir"]
version = "v1.1.0"
hash = "sha256-oduBKXHAQG8X6aqLEpqZHs5DOKe84u6WkBwi4W6cv3k="
[mod."github.com/modern-go/concurrent"]
version = "v0.0.0-20180306012644-bacd9c7ef1dd"
hash = "sha256-OTySieAgPWR4oJnlohaFTeK1tRaVp/b0d1rYY8xKMzo="
[mod."github.com/modern-go/reflect2"]
version = "v1.0.2"
hash = "sha256-+W9EIW7okXIXjWEgOaMh58eLvBZ7OshW2EhaIpNLSBU="
[mod."github.com/muesli/reflow"]
version = "v0.3.0"
hash = "sha256-Pou2ybE9SFSZG6YfZLVV1Eyfm+X4FuVpDPLxhpn47Cc="
[mod."github.com/muesli/termenv"]
version = "v0.15.2"
hash = "sha256-Eum/SpyytcNIchANPkG4bYGBgcezLgej7j/+6IhqoMU="
[mod."github.com/nwaples/rardecode"]
version = "v1.1.3"
hash = "sha256-X7Cg0kEygyy6Xw6sxRF9HirgefkH9tn9UPPelxRaAGg="
[mod."github.com/olekukonko/tablewriter"]
version = "v0.0.5"
hash = "sha256-/5i70IkH/qSW5KjGzv8aQNKh9tHoz98tqtL0K2DMFn4="
[mod."github.com/pierrec/lz4"]
version = "v2.6.1+incompatible"
hash = "sha256-5+4i5SN97wG71knAF9eUgEEG5k03HW4wPnAdPd6JSfE="
[mod."github.com/pkg/errors"]
version = "v0.9.1"
hash = "sha256-mNfQtcrQmu3sNg/7IwiieKWOgFQOVVe2yXgKBpe/wZw="
[mod."github.com/power-devops/perfstat"]
version = "v0.0.0-20210106213030-5aafc221ea8c"
hash = "sha256-ywykDYuqcMt0TvZOz1l9Z6Z2JMTYQw8cP2fT8AtpmX4="
[mod."github.com/projectdiscovery/asnmap"]
version = "v1.0.4"
hash = "sha256-J5Dn5eDzwj+ApwQ3ibTsMbwCobRAb1Cli+hbf74I9VQ="
[mod."github.com/projectdiscovery/blackrock"]
version = "v0.0.1"
hash = "sha256-E66IuBQ3meaGTVk26YzlUDwdUV4kP7VLhrhLnQShkHA="
[mod."github.com/projectdiscovery/cdncheck"]
version = "v1.0.9"
hash = "sha256-fJngwA9mAYB2awhEhS1gWXhOlmKeLrNV8WQj0r5y7Q0="
[mod."github.com/projectdiscovery/clistats"]
version = "v0.0.19"
hash = "sha256-vW7h0Eqm578jI/REU48rexVXGAeZt7JThRSeFm3gUt4="
[mod."github.com/projectdiscovery/dsl"]
version = "v0.0.20"
hash = "sha256-wkDZVgSU6EK5t6tH+g6EsEaTZ9bDNqIdix3I2MnQXOE="
[mod."github.com/projectdiscovery/fastdialer"]
version = "v0.0.37"
hash = "sha256-XxUFV6yfbH3Qw+Euogk/YFlHDxJtB4AIpOoFDK7poBY="
[mod."github.com/projectdiscovery/fasttemplate"]
version = "v0.0.2"
hash = "sha256-kl0lxr7Zhubs3b8Xgt5DRHVj6XxM/WtEAiVkecy62O4="
[mod."github.com/projectdiscovery/freeport"]
version = "v0.0.5"
hash = "sha256-14FrV/9ImnzdH8Pgl8VmgNhtEoqJtJGMO4QoYHdEZig="
[mod."github.com/projectdiscovery/goflags"]
version = "v0.1.19"
hash = "sha256-x72o/EiV2cTf9BW2XRwDGxW7rYFuXnmVc4MJyjoNvIg="
[mod."github.com/projectdiscovery/gologger"]
version = "v1.1.11"
hash = "sha256-ujoMwz77PRSqwE7Dr+MCm8144trX4le8z3l5yVNhMVs="
[mod."github.com/projectdiscovery/gostruct"]
version = "v0.0.1"
hash = "sha256-OhglrSmIVlNBWkY9WrIQB4SL4P47H/uqX9l+LjNZhSQ="
[mod."github.com/projectdiscovery/hmap"]
version = "v0.0.16"
hash = "sha256-mgnvUmgvTm7S71t5rK87eIxRHXZKsR7dUxAOuputtsE="
[mod."github.com/projectdiscovery/httpx"]
version = "v1.3.4"
hash = "sha256-Ye5xYjMaZamigmumgFzo8f3suXRJMOfJQa1S4OV2Gks="
[mod."github.com/projectdiscovery/interactsh"]
version = "v1.1.6"
hash = "sha256-kkUiuODfQwGesZi5w+t6f2BAIe9PLBDb24ltpbOqzp0="
[mod."github.com/projectdiscovery/mapcidr"]
version = "v1.1.2"
hash = "sha256-MXY4WRzRZ7OwuUxq5pCFgipHNakCB9U0UaNjYA5xnm8="
[mod."github.com/projectdiscovery/networkpolicy"]
version = "v0.0.6"
hash = "sha256-TEuxI6vJly0Sh1vkYhrr+EHZdFNZKOvNaU3q3cNyIlA="
[mod."github.com/projectdiscovery/nuclei/v2"]
version = "v2.9.14"
hash = "sha256-mTx6QCs0sTEHQX9/frJ6J1F+sJgmc4TqeoXR1esuTMY="
[mod."github.com/projectdiscovery/ratelimit"]
version = "v0.0.9"
hash = "sha256-/puvEIORXvDGDzotR0DhQnRXQramZYNtjaxjV0KgrN8="
[mod."github.com/projectdiscovery/rawhttp"]
version = "v0.1.18"
hash = "sha256-RkXxq/MAkPLTPzFvG90JgGtOeH/5oOPhCb42HCBweqs="
[mod."github.com/projectdiscovery/rdap"]
version = "v0.9.1-0.20221108103045-9865884d1917"
hash = "sha256-BEZDRPZPjhkNoyj/8Tk21UM98plLNitZ1W52GktJvMs="
[mod."github.com/projectdiscovery/retryabledns"]
version = "v1.0.35"
hash = "sha256-pGq+ZSETmt10PzBBY7ePnq+JW9YBJa9xq9+r1TmJY1E="
[mod."github.com/projectdiscovery/retryablehttp-go"]
version = "v1.0.25"
hash = "sha256-O2OksMSebG5fyiKlkTqC/draHa4g4ERYwuOmsZLPqec="
[mod."github.com/projectdiscovery/sarif"]
version = "v0.0.1"
hash = "sha256-m1s98hDVLAYbXgB0AEqHktZw2N89QeojqPZ7ConL4OE="
[mod."github.com/projectdiscovery/tlsx"]
version = "v1.1.4"
hash = "sha256-EMTNd5NOvaFbVxv31j3pBU//mWQQpThswCT8bMNx5Qw="
[mod."github.com/projectdiscovery/utils"]
version = "v0.0.52"
hash = "sha256-TOUCrtkO976RqBy6w4mQXJ8n/5klkg9tWuEMHdMooHg="
[mod."github.com/projectdiscovery/yamldoc-go"]
version = "v1.0.4"
hash = "sha256-ufjSaGHdRzyusbg5XKG6NVX/UyrUu2PBvGBl0Bour6I="
[mod."github.com/quic-go/quic-go"]
version = "v0.37.4"
hash = "sha256-EXsOITb0kh48+Wy2bIZyyNeGVuJmiL6xB0mtPOBUY/Y="
[mod."github.com/refraction-networking/utls"]
version = "v1.5.2"
hash = "sha256-QwYwEFkpo82NP4l6n6/+5HXzcFt6bEYqy4jFomushkw="
[mod."github.com/remeh/sizedwaitgroup"]
version = "v1.0.0"
hash = "sha256-CtjNoNeep0TnfkuRN/rc48diAo0jUog1fOz3I/z6jfc="
[mod."github.com/rivo/uniseg"]
version = "v0.4.4"
hash = "sha256-B8tbL9K6ICLdm0lEhs9+h4cpjAfvFtNiFMGvQZmw0bM="
[mod."github.com/rocketlaunchr/google-search"]
version = "v1.1.6"
hash = "sha256-2BMD4RXtrxMKC8AaxyeU/p1i92MvGIQjv4KOA4giXfk="
[mod."github.com/rs/xid"]
version = "v1.5.0"
hash = "sha256-u0QLm2YFMJqEjUhpWcLwfoS9lNHUxc2A79MObsqVbVU="
[mod."github.com/saintfish/chardet"]
version = "v0.0.0-20230101081208-5e3ef4b5456d"
hash = "sha256-JXlHMCbXB8iRQ9wQBGCeTjDSfgaBwUVOpvcjj0iVn5A="
[mod."github.com/sashabaranov/go-openai"]
version = "v1.14.2"
hash = "sha256-dc1SL5n3sOZPL018JDnqM6W/8pTwg7xUtxEnON4v+lM="
[mod."github.com/segmentio/ksuid"]
version = "v1.0.4"
hash = "sha256-50molk1vt8/n4Y+ruayW/EAn9NeeQ8ApmLJQVePhieE="
[mod."github.com/shirou/gopsutil/v3"]
version = "v3.23.7"
hash = "sha256-UppGryc5MO0sY3PuOC4H3hYsSomVTaXhgEprOsNFqe4="
[mod."github.com/shoenig/go-m1cpu"]
version = "v0.1.6"
hash = "sha256-hT+JP30BBllsXosK/lo89HV/uxxPLsUyO3dRaDiLnCg="
[mod."github.com/spaolacci/murmur3"]
version = "v1.1.0"
hash = "sha256-RWD4PPrlAsZZ8Xy356MBxpj+/NZI7w2XOU14Ob7/Y9M="
[mod."github.com/spf13/cast"]
version = "v1.5.1"
hash = "sha256-/tQNGGQv+Osp+2jepQaQe6GlncZbqdxzSR82FieiUBU="
[mod."github.com/syndtr/goleveldb"]
version = "v1.0.0"
hash = "sha256-rW7SW6nehede0oMZo4NBatM6Eizbnlb7xYoX/dcDUxA="
[mod."github.com/temoto/robotstxt"]
version = "v1.1.2"
hash = "sha256-/0zXEWCnvefGjU2RNxoyZu15KU6WYe9C4m58kyLU6zo="
[mod."github.com/tidwall/btree"]
version = "v1.6.0"
hash = "sha256-H4S46Yk3tVfOtrEhVWUrF4S1yWYmzU43W80HlzS9rcY="
[mod."github.com/tidwall/buntdb"]
version = "v1.3.0"
hash = "sha256-tXp+wcPYogh/Thubk4baFLpbwrCGVf0URvlBXwGg3eQ="
[mod."github.com/tidwall/gjson"]
version = "v1.14.4"
hash = "sha256-3DS2YNL95wG0qSajgRtIABD32J+oblaKVk8LIw+KSOc="
[mod."github.com/tidwall/grect"]
version = "v0.1.4"
hash = "sha256-iSS8YjTqtmlzK9T3PFXoLx5xF/vC8864yNzGw0KYwKs="
[mod."github.com/tidwall/match"]
version = "v1.1.1"
hash = "sha256-M2klhPId3Q3T3VGkSbOkYl/2nLHnsG+yMbXkPkyrRdg="
[mod."github.com/tidwall/pretty"]
version = "v1.2.1"
hash = "sha256-S0uTDDGD8qr415Ut7QinyXljCp0TkL4zOIrlJ+9OMl8="
[mod."github.com/tidwall/rtred"]
version = "v0.1.2"
hash = "sha256-C4p3rZWRLuNgbfVVPr83PZjbD8rZNN3a3YGQJQJlSQU="
[mod."github.com/tidwall/tinyqueue"]
version = "v0.1.1"
hash = "sha256-vsVVA0dAkYtX/C/pk0nDUiu6kURZrK+rxVBRB4wY78Q="
[mod."github.com/tklauser/go-sysconf"]
version = "v0.3.11"
hash = "sha256-io8s7PJi4OX+wXkCm+v5pKy4yiqA/RE/I4ksy6mKX30="
[mod."github.com/tklauser/numcpus"]
version = "v0.6.0"
hash = "sha256-6jssTsP5L6yVl43tXfqDdgeI+tEkBp3BpiWwKXLTHAM="
[mod."github.com/trivago/tgo"]
version = "v1.0.7"
hash = "sha256-VzCbopX6wKWVWmcr/qnKf4ruMicwyEeNfCEWc0UxoxI="
[mod."github.com/ulikunitz/xz"]
version = "v0.5.11"
hash = "sha256-SUyrjc2wyN3cTGKe5JdBEXjtZC1rJySRxJHVUZ59row="
[mod."github.com/ulule/deepcopier"]
version = "v0.0.0-20200430083143-45decc6639b6"
hash = "sha256-zyn5rHS5bU/4KajCVg+6pex42KVdXLZS8DFqRDUpn0E="
[mod."github.com/valyala/bytebufferpool"]
version = "v1.0.0"
hash = "sha256-I9FPZ3kCNRB+o0dpMwBnwZ35Fj9+ThvITn8a3Jr8mAY="
[mod."github.com/valyala/fasttemplate"]
version = "v1.2.2"
hash = "sha256-gp+lNXE8zjO+qJDM/YbS6V43HFsYP6PKn4ux1qa5lZ0="
[mod."github.com/weppos/publicsuffix-go"]
version = "v0.30.1-0.20230422193905-8fecedd899db"
hash = "sha256-Hw5S8ACINl+z/qZmLhcQcXjrXHzYM9HsqQF91RbDoB4="
[mod."github.com/xanzy/go-gitlab"]
version = "v0.84.0"
hash = "sha256-1Se9LHWGnmvAm0QHrb8Zw2jkyaKH2o3j0wvdMp289IQ="
[mod."github.com/xi2/xz"]
version = "v0.0.0-20171230120015-48954b6210f8"
hash = "sha256-2J4cb9KUnGHn1WZ2+g/S+yiHGLDt6KU0cP3fJpQDGZ0="
[mod."github.com/yl2chen/cidranger"]
version = "v1.0.2"
hash = "sha256-rPZApwakcZ1D3lmZnFds79+TFr9IlYkovTA7o52N9h0="
[mod."github.com/ysmood/fetchup"]
version = "v0.2.3"
hash = "sha256-sJ9PBMJ/PH3Es/ngAJkrxTPNAXr7AFjdsblF67mP2Hc="
[mod."github.com/ysmood/goob"]
version = "v0.4.0"
hash = "sha256-o0yVrxQRbN1dSjBH359VHADzPmkyrYOp7jn1GqIYhvw="
[mod."github.com/ysmood/got"]
version = "v0.34.1"
hash = "sha256-dCLb+1Yt/HAZhfQlVkEQoVG9Uv7iBGSqhxdunoakLTU="
[mod."github.com/ysmood/gson"]
version = "v0.7.3"
hash = "sha256-Dn5cTopPKtKCjQ7G6nlvPW2d7G4c5NfIdLVM9eLgR0E="
[mod."github.com/ysmood/leakless"]
version = "v0.8.0"
hash = "sha256-+D41mvLU29dPR4Lf9iWYq3oATgKHpRnUKahO0hTiCDc="
[mod."github.com/yuin/goldmark"]
version = "v1.5.4"
hash = "sha256-4he5sGi0uj1LogdqvgpvN8b7p6qlKMGuWXRFzh+FK8s="
[mod."github.com/yuin/goldmark-emoji"]
version = "v1.0.1"
hash = "sha256-liYCi6/EYG4obl51CzCaOmXf3fdzrU43J9VBZyHggEo="
[mod."github.com/yusufpapurcu/wmi"]
version = "v1.2.3"
hash = "sha256-HOLI8i58AMWeTotvYtdZessgrLwUG2aiS37eeHgsneY="
[mod."github.com/zeebo/blake3"]
version = "v0.2.3"
hash = "sha256-ZepnzkvOyicTGL078O1F84q0TzBAouJlB5AMmfsiOIg="
[mod."github.com/zmap/rc2"]
version = "v0.0.0-20190804163417-abaa70531248"
hash = "sha256-yMyZfFjcLynxiNXmUdfSfUlWekdtlXV3jGIoJMxMDz4="
[mod."github.com/zmap/zcrypto"]
version = "v0.0.0-20230422215203-9a665e1e9968"
hash = "sha256-nDBTEGDBv764XaC3KEwMtKGim0dEy4cjgo8XwnvyLh4="
[mod."go.etcd.io/bbolt"]
version = "v1.3.7"
hash = "sha256-poZk8tPLDWwW95oCOkTJcQtEvOJTD9UXAZ2TqGJutwk="
[mod."go.uber.org/multierr"]
version = "v1.11.0"
hash = "sha256-Lb6rHHfR62Ozg2j2JZy3MKOMKdsfzd1IYTR57r3Mhp0="
[mod."go.uber.org/zap"]
version = "v1.25.0"
hash = "sha256-aU270ds5r37xtfFFDVrvjOTTOv1aZNd7ffvHZJB6VIQ="
[mod."goftp.io/server/v2"]
version = "v2.0.1"
hash = "sha256-lI1UZVC9zQnyarOK6AR3Llw4exPqvNn3BZqwKlAOYbQ="
[mod."golang.org/x/crypto"]
version = "v0.12.0"
hash = "sha256-Wes72EA9ICTG8o0nEYWZk9xjpqlniorFeY6o26GExns="
[mod."golang.org/x/exp"]
version = "v0.0.0-20230626212559-97b1e661b5df"
hash = "sha256-aoesDZqls2sBtDmZ/ZSLzIudLuD8GDtGEEucyiqbCjY="
[mod."golang.org/x/mod"]
version = "v0.12.0"
hash = "sha256-M/oXnzm7odpJdQzEnG6W0pNYtl0uhOM/l7qgfGVpU2M="
[mod."golang.org/x/net"]
version = "v0.14.0"
hash = "sha256-QScKgO7lBWOsd0Y31wLRzFETv3tjqdB/eRQWW5q7aV4="
[mod."golang.org/x/oauth2"]
version = "v0.11.0"
hash = "sha256-ztz1lRVZXq6lTN/q4b4Y+P6L1EkP8ZJuhUbSJ0QvCw4="
[mod."golang.org/x/sys"]
version = "v0.11.0"
hash = "sha256-g/LjhABK2c/u6v7M2aAIrHvZjmx/ikGHkef86775N38="
[mod."golang.org/x/text"]
version = "v0.12.0"
hash = "sha256-aNQaW3EgCK9ehpnBzIAkZX6TmiUU1S175YlJUH7P5Qg="
[mod."golang.org/x/time"]
version = "v0.3.0"
hash = "sha256-/hmc9skIswMYbivxNS7R8A6vCTUF9k2/7tr/ACkcEaM="
[mod."golang.org/x/tools"]
version = "v0.11.0"
hash = "sha256-3fNsrCbUnbI5kwZRTx/olHLxR2DJhfvEQ3x0yeeZ8JY="
[mod."google.golang.org/appengine"]
version = "v1.6.7"
hash = "sha256-zIxGRHiq4QBvRqkrhMGMGCaVL4iM4TtlYpAi/hrivS4="
[mod."google.golang.org/protobuf"]
version = "v1.31.0"
hash = "sha256-UdIk+xRaMfdhVICvKRk1THe3R1VU+lWD8hqoW/y8jT0="
[mod."gopkg.in/alecthomas/kingpin.v2"]
version = "v2.2.6"
hash = "sha256-uViE2kPj7tMrGYVjjdLOl2jFDmmu+3P7GvnZBse2zVY="
[mod."gopkg.in/corvus-ch/zbase32.v1"]
version = "v1.0.0"
hash = "sha256-T6PzD4SJv6ipfCkr8CVHXjmKvYRGcLOypHTa238GGlw="
[mod."gopkg.in/djherbis/times.v1"]
version = "v1.3.0"
hash = "sha256-0ZIFWjtY4KyTPIRjUVIGKMXSXe++6vxBckckluhBYLY="
[mod."gopkg.in/yaml.v2"]
version = "v2.4.0"
hash = "sha256-uVEGglIedjOIGZzHW4YwN1VoRSTK8o0eGZqzd+TNdd0="
[mod."gopkg.in/yaml.v3"]
version = "v3.0.1"
hash = "sha256-FqL9TKYJ0XkNwJFnq9j0VvJ5ZUU1RvH/52h/f5bkYAU="
[mod."moul.io/http2curl"]
version = "v1.0.0"
hash = "sha256-1ZP4V71g1K3oTvz5nGWUBD5h84hXga/RUQwWTpSnphM="
+218
View File
@@ -0,0 +1,218 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package config
import (
"time"
"github.com/charmbracelet/log"
"github.com/projectdiscovery/goflags"
)
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
DirCalibrate bool // -ac dirlist: auto-calibrate soft-404 baseline
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
Ports string
Dorking bool
Git bool
Whois bool
Threads int
Nuclei bool
JavaScript bool
Timeout time.Duration
URLs goflags.StringSlice
File string
ApiMode bool
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
// list sizes
Small
Medium
Large
// port scan scopes
Common
Full
)
func Parse() *Settings {
settings := &Settings{}
flagSet := goflags.NewFlagSet()
flagSet.SetDescription("a blazing-fast pentesting (recon/exploitation) suite")
flagSet.CreateGroup("target", "Targets",
flagSet.StringSliceVarP(&settings.URLs, "urls", "u", nil, "List of URLs to check (comma-separated)", goflags.FileCommaSeparatedStringSliceOptions),
flagSet.StringVarP(&settings.File, "file", "f", "", "File that includes URLs to check"),
)
listSizes := goflags.AllowdTypes{"small": Small, "medium": Medium, "large": Large, "none": Nil}
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.DirCalibrate, "ac", false, "Dirlist: auto-calibrate the soft-404 wildcard baseline"),
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"),
flagSet.BoolVar(&settings.Nuclei, "nuclei", false, "Enable scanning using nuclei templates"),
flagSet.BoolVar(&settings.NoScan, "noscan", false, "Do not perform base URL (robots.txt, etc) scanning"),
flagSet.BoolVar(&settings.Whois, "whois", false, "Enable WHOIS lookup"),
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",
flagSet.BoolVarP(&settings.Debug, "debug", "d", false, "Enable debug logging"),
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.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.CreateGroup("modules", "Modules",
flagSet.StringVarP(&settings.Modules, "modules", "m", "", "Comma-separated list of module IDs to run"),
flagSet.StringVarP(&settings.ModuleTags, "module-tags", "mt", "", "Run modules matching these tags"),
flagSet.BoolVarP(&settings.AllModules, "all-modules", "am", false, "Run all loaded modules"),
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)
}
// 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
}
+165
View File
@@ -0,0 +1,165 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package config
import (
"testing"
"time"
)
func TestSettingsDefaults(t *testing.T) {
settings := &Settings{}
// noscan should default to false (base scan runs by default)
if settings.NoScan != false {
t.Errorf("expected NoScan default to be false, got %v", settings.NoScan)
}
// other scan flags should default to false
if settings.Dorking != false {
t.Errorf("expected Dorking default to be false, got %v", settings.Dorking)
}
if settings.Git != false {
t.Errorf("expected Git default to be false, got %v", settings.Git)
}
if settings.Nuclei != false {
t.Errorf("expected Nuclei default to be false, got %v", settings.Nuclei)
}
if settings.JavaScript != false {
t.Errorf("expected JavaScript default to be false, got %v", settings.JavaScript)
}
if settings.CMS != false {
t.Errorf("expected CMS default to be false, got %v", settings.CMS)
}
if settings.Headers != false {
t.Errorf("expected Headers default to be false, got %v", settings.Headers)
}
if settings.CloudStorage != false {
t.Errorf("expected CloudStorage default to be false, got %v", settings.CloudStorage)
}
if settings.SubdomainTakeover != false {
t.Errorf("expected SubdomainTakeover default to be false, got %v", settings.SubdomainTakeover)
}
// enum settings should default to empty string
if settings.Dirlist != "" {
t.Errorf("expected Dirlist default to be empty, got %v", settings.Dirlist)
}
if settings.Dnslist != "" {
t.Errorf("expected Dnslist default to be empty, got %v", settings.Dnslist)
}
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) {
tests := []struct {
name string
noScan bool
shouldBaseScan bool
}{
{
name: "default - base scan should run",
noScan: false,
shouldBaseScan: true,
},
{
name: "noscan enabled - base scan should not run",
noScan: true,
shouldBaseScan: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
settings := &Settings{NoScan: tt.noScan}
// the condition in sif.go is: if !app.settings.NoScan { scan.Scan(...) }
shouldRun := !settings.NoScan
if shouldRun != tt.shouldBaseScan {
t.Errorf("expected shouldBaseScan=%v, got %v", tt.shouldBaseScan, shouldRun)
}
})
}
}
func TestSettingsTimeoutDefault(t *testing.T) {
settings := &Settings{}
// timeout defaults to zero value, actual default (10s) is set in Parse()
if settings.Timeout != 0 {
t.Errorf("expected Timeout zero value, got %v", settings.Timeout)
}
}
func TestSettingsThreadsDefault(t *testing.T) {
settings := &Settings{}
// threads defaults to zero value, actual default (10) is set in Parse()
if settings.Threads != 0 {
t.Errorf("expected Threads zero value, got %v", settings.Threads)
}
}
func TestSettingsWithValues(t *testing.T) {
settings := &Settings{
NoScan: true,
Dorking: true,
Git: true,
Nuclei: true,
JavaScript: true,
CMS: true,
Headers: true,
CloudStorage: true,
SubdomainTakeover: true,
Dirlist: "medium",
Dnslist: "large",
Ports: "common",
Timeout: 30 * time.Second,
Threads: 20,
Debug: true,
LogDir: "/tmp/logs",
ApiMode: true,
}
if !settings.NoScan {
t.Error("expected NoScan to be true")
}
if !settings.Dorking {
t.Error("expected Dorking to be true")
}
if settings.Dirlist != "medium" {
t.Errorf("expected Dirlist 'medium', got '%s'", settings.Dirlist)
}
if settings.Dnslist != "large" {
t.Errorf("expected Dnslist 'large', got '%s'", settings.Dnslist)
}
if settings.Ports != "common" {
t.Errorf("expected Ports 'common', got '%s'", settings.Ports)
}
if settings.Timeout != 30*time.Second {
t.Errorf("expected Timeout 30s, got %v", settings.Timeout)
}
if settings.Threads != 20 {
t.Errorf("expected Threads 20, got %d", settings.Threads)
}
}
+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)
}
}
}
+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()
}
+162
View File
@@ -0,0 +1,162 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package logger
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
)
// Logger manages buffered file writers for efficient logging.
// File handles are kept open and writes are buffered to minimize I/O overhead.
type Logger struct {
mu sync.RWMutex
writers map[string]*bufio.Writer
files map[string]*os.File
}
var defaultLogger = &Logger{
writers: make(map[string]*bufio.Writer),
files: make(map[string]*os.File),
}
// 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, 0o750); err != nil {
return err
}
}
return nil
}
// getWriter returns a buffered writer for the given file path, creating it if needed.
func (l *Logger) getWriter(path string) (*bufio.Writer, error) {
l.mu.RLock()
w, exists := l.writers[path]
l.mu.RUnlock()
if exists {
return w, nil
}
l.mu.Lock()
defer l.mu.Unlock()
// Double-check after acquiring write lock
if w, exists = l.writers[path]; exists {
return w, nil
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
if err != nil {
return nil, err
}
w = bufio.NewWriter(f)
l.writers[path] = w
l.files[path] = f
return w, nil
}
// write writes text to the specified log file using buffered I/O.
func (l *Logger) write(path, text string) error {
w, err := l.getWriter(path)
if err != nil {
return err
}
l.mu.Lock()
_, err = w.WriteString(text)
l.mu.Unlock()
return err
}
// Flush flushes all buffered writers to disk.
func (l *Logger) Flush() error {
l.mu.Lock()
defer l.mu.Unlock()
for _, w := range l.writers {
if err := w.Flush(); err != nil {
return err
}
}
return nil
}
// Close flushes and closes all open file handles.
func (l *Logger) Close() error {
l.mu.Lock()
defer l.mu.Unlock()
var firstErr error
for path, w := range l.writers {
if err := w.Flush(); err != nil && firstErr == nil {
firstErr = err
}
if err := l.files[path].Close(); err != nil && firstErr == nil {
firstErr = err
}
}
l.writers = make(map[string]*bufio.Writer)
l.files = make(map[string]*os.File)
return firstErr
}
// CreateFile initializes a log file for the given URL and writes the header.
func CreateFile(logFiles *[]string, url string, dir string) error {
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)
if err := defaultLogger.write(path, header); err != nil {
return err
}
*logFiles = append(*logFiles, path)
return nil
}
// Write appends text to the log file for the given URL.
func Write(url string, dir string, text string) error {
path := filepath.Join(dir, url+".log")
return defaultLogger.write(path, text)
}
// WriteHeader writes a section header to the log file.
func WriteHeader(url string, dir string, scan string) error {
return Write(url, dir, fmt.Sprintf("\n\n--------------\nStarting %s\n--------------\n", scan))
}
// Flush flushes all buffered log data to disk.
func Flush() error {
return defaultLogger.Flush()
}
// Close flushes and closes all log files. Should be called before program exit.
func Close() error {
return defaultLogger.Close()
}
+196
View File
@@ -0,0 +1,196 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package logger
import (
"os"
"path/filepath"
"strings"
"sync"
"testing"
)
func TestInit(t *testing.T) {
tmpDir := t.TempDir()
logDir := filepath.Join(tmpDir, "logs")
if err := Init(logDir); err != nil {
t.Fatalf("Init failed: %v", err)
}
if _, err := os.Stat(logDir); os.IsNotExist(err) {
t.Fatal("Init did not create log directory")
}
// Second call should be a no-op
if err := Init(logDir); err != nil {
t.Fatalf("Init failed on existing directory: %v", err)
}
}
func TestWriteAndFlush(t *testing.T) {
tmpDir := t.TempDir()
// Write some data
if err := Write("test", tmpDir, "hello world\n"); err != nil {
t.Fatalf("Write failed: %v", err)
}
// Flush to ensure data is written
if err := Flush(); err != nil {
t.Fatalf("Flush failed: %v", err)
}
// Read back and verify
content, err := os.ReadFile(filepath.Join(tmpDir, "test.log"))
if err != nil {
t.Fatalf("Failed to read log file: %v", err)
}
if string(content) != "hello world\n" {
t.Errorf("Expected 'hello world\\n', got %q", content)
}
// Cleanup
Close()
}
func TestWriteHeader(t *testing.T) {
tmpDir := t.TempDir()
if err := WriteHeader("test", tmpDir, "TestScan"); err != nil {
t.Fatalf("WriteHeader failed: %v", err)
}
if err := Flush(); err != nil {
t.Fatalf("Flush failed: %v", err)
}
content, err := os.ReadFile(filepath.Join(tmpDir, "test.log"))
if err != nil {
t.Fatalf("Failed to read log file: %v", err)
}
if !strings.Contains(string(content), "Starting TestScan") {
t.Errorf("Expected header to contain 'Starting TestScan', got %q", content)
}
Close()
}
func TestCreateFile(t *testing.T) {
tmpDir := t.TempDir()
var logFiles []string
if err := CreateFile(&logFiles, "https://example.com", tmpDir); err != nil {
t.Fatalf("CreateFile failed: %v", err)
}
if err := Flush(); err != nil {
t.Fatalf("Flush failed: %v", err)
}
if len(logFiles) != 1 {
t.Fatalf("Expected 1 log file, got %d", len(logFiles))
}
content, err := os.ReadFile(logFiles[0])
if err != nil {
t.Fatalf("Failed to read log file: %v", err)
}
if !strings.Contains(string(content), "sif log file for https://example.com") {
t.Errorf("Expected header content, got %q", content)
}
Close()
}
func TestConcurrentWrites(t *testing.T) {
tmpDir := t.TempDir()
var wg sync.WaitGroup
numWriters := 10
writesPerWriter := 100
for i := 0; i < numWriters; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < writesPerWriter; j++ {
if err := Write("concurrent", tmpDir, "data\n"); err != nil {
t.Errorf("Write failed: %v", err)
}
}
}(i)
}
wg.Wait()
if err := Flush(); err != nil {
t.Fatalf("Flush failed: %v", err)
}
content, err := os.ReadFile(filepath.Join(tmpDir, "concurrent.log"))
if err != nil {
t.Fatalf("Failed to read log file: %v", err)
}
lines := strings.Count(string(content), "data\n")
expected := numWriters * writesPerWriter
if lines != expected {
t.Errorf("Expected %d lines, got %d", expected, lines)
}
Close()
}
func TestClose(t *testing.T) {
tmpDir := t.TempDir()
if err := Write("close_test", tmpDir, "before close\n"); err != nil {
t.Fatalf("Write failed: %v", err)
}
if err := Close(); err != nil {
t.Fatalf("Close failed: %v", err)
}
// Verify data was flushed on close
content, err := os.ReadFile(filepath.Join(tmpDir, "close_test.log"))
if err != nil {
t.Fatalf("Failed to read log file: %v", err)
}
if string(content) != "before close\n" {
t.Errorf("Expected 'before close\\n', got %q", content)
}
// Write after close should create new file handle
if err := Write("close_test", tmpDir, "after close\n"); err != nil {
t.Fatalf("Write after close failed: %v", err)
}
if err := Close(); err != nil {
t.Fatalf("Second close failed: %v", err)
}
content, err = os.ReadFile(filepath.Join(tmpDir, "close_test.log"))
if err != nil {
t.Fatalf("Failed to read log file: %v", err)
}
if string(content) != "before close\nafter close\n" {
t.Errorf("Expected both writes, got %q", content)
}
}
+400
View File
@@ -0,0 +1,400 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package modules
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"sync"
"time"
)
// 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
URL string
Headers map[string]string
Body string
Payload string
Original string // Original path template
}
// ExecuteHTTPModule runs an HTTP-based module.
func ExecuteHTTPModule(ctx context.Context, target string, def *YAMLModule, opts Options) (*Result, error) {
if def.HTTP == nil {
return nil, fmt.Errorf("no HTTP configuration")
}
cfg := def.HTTP
result := &Result{
ModuleID: def.ID,
Target: target,
Findings: make([]Finding, 0),
}
// Create HTTP client
client := opts.Client
if client == nil {
client = &http.Client{
Timeout: opts.Timeout,
Transport: &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
},
}
}
// Generate requests based on paths and payloads
requests := generateHTTPRequests(target, cfg)
// Determine thread count
threads := cfg.Threads
if threads == 0 {
threads = opts.Threads
}
if threads == 0 {
threads = 10
}
// Execute requests concurrently
var wg sync.WaitGroup
var mu sync.Mutex
resultsChan := make(chan Finding, len(requests))
// Limit concurrency
sem := make(chan struct{}, threads)
for _, req := range requests {
select {
case <-ctx.Done():
return result, ctx.Err()
case sem <- struct{}{}:
}
wg.Add(1)
go func(r *httpRequest) {
defer wg.Done()
defer func() { <-sem }()
finding, ok := executeHTTPRequest(ctx, client, r, cfg, def.Info.Severity)
if ok {
resultsChan <- finding
}
}(req)
}
// Collect results
go func() {
wg.Wait()
close(resultsChan)
}()
for finding := range resultsChan {
mu.Lock()
result.Findings = append(result.Findings, finding)
mu.Unlock()
}
return result, nil
}
// generateHTTPRequests creates all requests based on paths and payloads.
func generateHTTPRequests(target string, cfg *HTTPConfig) []*httpRequest {
var requests []*httpRequest
// Ensure target has no trailing slash
target = strings.TrimSuffix(target, "/")
method := cfg.Method
if method == "" {
method = "GET"
}
// If no payloads, just use paths directly
if len(cfg.Payloads) == 0 {
for _, path := range cfg.Paths {
url := substituteVariables(path, target, "")
requests = append(requests, &httpRequest{
Method: method,
URL: url,
Headers: cfg.Headers,
Body: cfg.Body,
Original: path,
})
}
return requests
}
// Generate requests with payloads
for _, path := range cfg.Paths {
for _, payload := range cfg.Payloads {
url := substituteVariables(path, target, payload)
body := substituteVariables(cfg.Body, target, payload)
requests = append(requests, &httpRequest{
Method: method,
URL: url,
Headers: cfg.Headers,
Body: body,
Payload: payload,
Original: path,
})
}
}
return requests
}
// substituteVariables replaces template variables in a string.
func substituteVariables(template, baseURL, payload string) string {
result := template
result = strings.ReplaceAll(result, "{{BaseURL}}", baseURL)
result = strings.ReplaceAll(result, "{{baseurl}}", baseURL)
result = strings.ReplaceAll(result, "{{payload}}", payload)
result = strings.ReplaceAll(result, "{{Payload}}", payload)
return result
}
// executeHTTPRequest executes a single HTTP request and checks matchers.
func executeHTTPRequest(ctx context.Context, client *http.Client, r *httpRequest, cfg *HTTPConfig, severity string) (Finding, bool) {
var body io.Reader
if r.Body != "" {
body = strings.NewReader(r.Body)
}
req, err := http.NewRequestWithContext(ctx, r.Method, r.URL, body)
if err != nil {
return Finding{}, false
}
// Set headers
for k, v := range r.Headers {
req.Header.Set(k, v)
}
if req.Header.Get("User-Agent") == "" {
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; sif/1.0)")
}
resp, err := client.Do(req)
if err != nil {
return Finding{}, false
}
defer resp.Body.Close()
// Read body with limit
respBody, err := io.ReadAll(io.LimitReader(resp.Body, MaxBodySize))
if err != nil {
return Finding{}, false
}
bodyStr := string(respBody)
// Check matchers
if !checkMatchers(cfg.Matchers, resp, bodyStr) {
return Finding{}, false
}
// Extract data
extracted := runExtractors(cfg.Extractors, resp, bodyStr)
return Finding{
URL: r.URL,
Severity: severity,
Evidence: truncateEvidence(bodyStr),
Extracted: extracted,
}, true
}
// checkMatchers evaluates all matchers against the response.
func checkMatchers(matchers []Matcher, resp *http.Response, body string) bool {
if len(matchers) == 0 {
return false
}
// Default to AND condition across matchers
for i := range matchers {
matched := checkMatcher(&matchers[i], resp, body)
if matchers[i].Negative {
matched = !matched
}
if !matched {
return false // AND logic
}
}
return true
}
// checkMatcher evaluates a single matcher.
func checkMatcher(m *Matcher, resp *http.Response, body string) bool {
part := getPart(m.Part, resp, body)
switch m.Type {
case "status":
for _, status := range m.Status {
if resp.StatusCode == status {
return true
}
}
return false
case "word":
return checkWords(part, m.Words, m.Condition)
case "regex":
return checkRegex(part, m.Regex, m.Condition)
default:
return false
}
}
// getPart extracts the relevant part of the response.
func getPart(part string, resp *http.Response, body string) string {
switch part {
case "header", "headers":
var sb strings.Builder
for k, v := range resp.Header {
sb.WriteString(k)
sb.WriteString(": ")
sb.WriteString(strings.Join(v, ", "))
sb.WriteString("\n")
}
return sb.String()
case "body":
return body
case "all", "":
var sb strings.Builder
for k, v := range resp.Header {
sb.WriteString(k)
sb.WriteString(": ")
sb.WriteString(strings.Join(v, ", "))
sb.WriteString("\n")
}
sb.WriteString("\n")
sb.WriteString(body)
return sb.String()
default:
return body
}
}
// checkWords checks if any/all words are found.
func checkWords(content string, words []string, condition string) bool {
if condition == "or" {
for _, word := range words {
if strings.Contains(content, word) {
return true
}
}
return false
}
// Default to AND
for _, word := range words {
if !strings.Contains(content, word) {
return false
}
}
return true
}
// checkRegex checks if any/all regex patterns match.
func checkRegex(content string, patterns []string, condition string) bool {
if condition == "or" {
for _, pattern := range patterns {
re, err := regexp.Compile(pattern)
if err != nil {
continue
}
if re.MatchString(content) {
return true
}
}
return false
}
// Default to AND
for _, pattern := range patterns {
re, err := regexp.Compile(pattern)
if err != nil {
return false
}
if !re.MatchString(content) {
return false
}
}
return true
}
// runExtractors extracts data from the response.
func runExtractors(extractors []Extractor, resp *http.Response, body string) map[string]string {
if len(extractors) == 0 {
return nil
}
result := make(map[string]string)
for _, e := range extractors {
part := getPart(e.Part, resp, body)
if e.Type == "regex" {
for _, pattern := range e.Regex {
re, err := regexp.Compile(pattern)
if err != nil {
continue
}
matches := re.FindStringSubmatch(part)
if len(matches) > e.Group {
result[e.Name] = matches[e.Group]
break
}
}
}
}
return result
}
// truncateEvidence limits evidence length for storage.
func truncateEvidence(s string) string {
const maxLen = 500
if len(s) > maxLen {
return s[:maxLen] + "..."
}
return s
}
// 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 (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)
}
+270
View File
@@ -0,0 +1,270 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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"
"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))
}
}
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")
}
})
}
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:])
}
}
+153
View File
@@ -0,0 +1,153 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package modules
import (
"fmt"
"os"
"path/filepath"
"runtime"
"github.com/charmbracelet/log"
"github.com/dropalldatabases/sif/internal/output"
)
// Loader handles module discovery and loading.
type Loader struct {
builtinDir string
userDir string
loaded int
}
// NewLoader creates a new module loader.
// It automatically detects the built-in modules directory and sets up
// the user modules directory based on the operating system.
func NewLoader() (*Loader, error) {
home, err := os.UserHomeDir()
if err != nil {
return nil, fmt.Errorf("get home dir: %w", err)
}
// Find built-in modules relative to executable
execPath, err := os.Executable()
if err != nil {
execPath = "."
}
builtinDir := filepath.Join(filepath.Dir(execPath), "modules")
// Also check current working directory for development
if _, err := os.Stat(builtinDir); os.IsNotExist(err) {
builtinDir = "modules"
}
// User modules directory based on OS
var userDir string
switch runtime.GOOS {
case "windows":
userDir = filepath.Join(home, "AppData", "Local", "sif", "modules")
default:
userDir = filepath.Join(home, ".config", "sif", "modules")
}
return &Loader{
builtinDir: builtinDir,
userDir: userDir,
}, nil
}
// LoadAll discovers and loads all modules from both built-in
// and user directories.
func (l *Loader) LoadAll() error {
// Load built-in modules first
if err := l.loadDir(l.builtinDir, false); err != nil {
log.Debugf("No built-in modules found: %v", err)
}
// Load user modules (can override built-in)
if err := l.loadDir(l.userDir, true); err != nil {
// User dir might not exist, that's OK
if !os.IsNotExist(err) {
log.Debugf("No user modules found: %v", err)
}
}
if l.loaded > 0 {
modLog := output.Module("MODULES")
modLog.Info("Loaded %d modules", l.loaded)
}
return nil
}
// loadDir loads modules from a directory.
func (l *Loader) loadDir(dir string, userDefined bool) error {
return filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
switch filepath.Ext(path) {
case ".yaml", ".yml":
if err := l.loadYAML(path); err != nil {
log.Warnf("Failed to load module %s: %v", path, err)
} else {
l.loaded++
}
case ".go":
if err := l.loadScript(path); err != nil {
log.Debugf("Failed to load script %s: %v", path, err)
} else {
l.loaded++
}
}
return nil
})
}
// loadYAML loads a YAML module definition.
func (l *Loader) loadYAML(path string) error {
def, err := ParseYAMLModule(path)
if err != nil {
return err
}
module := newYAMLModuleWrapper(def, path)
Register(module)
return nil
}
// loadScript loads a Go script module.
// Implementation will be provided in script.go.
func (l *Loader) loadScript(path string) error {
// Will be implemented in script.go
return nil
}
// BuiltinDir returns the built-in modules directory path.
func (l *Loader) BuiltinDir() string {
return l.builtinDir
}
// UserDir returns the user modules directory path.
func (l *Loader) UserDir() string {
return l.userDir
}
// Loaded returns the number of loaded modules.
func (l *Loader) Loaded() int {
return l.loaded
}
+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)
}
}
+465
View File
@@ -0,0 +1,465 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package modules
import (
"net/http"
"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: "non-regex extractor type is ignored",
extractors: []Extractor{
{Type: "kval", 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 := generateHTTPRequests("http://h/", cfg)
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 := generateHTTPRequests("http://h", cfg)
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 := generateHTTPRequests("http://h", cfg)
if len(got) != 4 {
t.Fatalf("got %d requests, want 4 (2 paths x 2 payloads)", len(got))
}
})
}
+106
View File
@@ -0,0 +1,106 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
// Package modules provides the module system infrastructure for SIF.
// It defines the core interfaces, types, and utilities for building
// and executing security scanning modules.
package modules
import (
"context"
"net/http"
"time"
)
// ModuleType represents the type of module.
type ModuleType string
const (
TypeHTTP ModuleType = "http"
TypeDNS ModuleType = "dns"
TypeTCP ModuleType = "tcp"
TypeScript ModuleType = "script"
)
// Module is the interface all modules implement.
// Each module must provide metadata, specify its type, and implement
// an Execute method for running the scan against a target.
type Module interface {
// Info returns the module metadata.
Info() Info
// Type returns the module type (http, dns, tcp, script).
Type() ModuleType
// Execute runs the module against the specified target.
Execute(ctx context.Context, target string, opts Options) (*Result, error)
}
// Info contains module metadata.
type Info struct {
ID string `yaml:"id" json:"id"`
Name string `yaml:"name" json:"name"`
Author string `yaml:"author" json:"author"`
Severity string `yaml:"severity" json:"severity"`
Description string `yaml:"description" json:"description"`
Tags []string `yaml:"tags" json:"tags"`
}
// Options for module execution.
type Options struct {
Timeout time.Duration
Threads int
LogDir string
Client *http.Client
}
// Result from module execution.
type Result struct {
ModuleID string `json:"module_id"`
Target string `json:"target"`
Findings []Finding `json:"findings,omitempty"`
}
// ResultType implements the ScanResult interface from pkg/scan.
func (r *Result) ResultType() string {
return r.ModuleID
}
// Finding represents a discovered issue.
type Finding struct {
URL string `json:"url,omitempty"`
Severity string `json:"severity"`
Evidence string `json:"evidence,omitempty"`
Extracted map[string]string `json:"extracted,omitempty"`
}
// 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
Part string `yaml:"part"` // body, header, all
Regex []string `yaml:"regex,omitempty"`
Words []string `yaml:"words,omitempty"`
Status []int `yaml:"status,omitempty"`
Condition string `yaml:"condition"` // and, or
Negative bool `yaml:"negative"`
}
// 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
Name string `yaml:"name"`
Part string `yaml:"part"`
Regex []string `yaml:"regex,omitempty"`
Group int `yaml:"group"`
}
+92
View File
@@ -0,0 +1,92 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package modules
import "sync"
var (
registry = make(map[string]Module)
mu sync.RWMutex
)
// Register adds a module to the registry.
// If a module with the same ID already exists, it will be overwritten.
func Register(m Module) {
mu.Lock()
defer mu.Unlock()
registry[m.Info().ID] = m
}
// Get returns a module by ID.
// The second return value indicates whether the module was found.
func Get(id string) (Module, bool) {
mu.RLock()
defer mu.RUnlock()
m, ok := registry[id]
return m, ok
}
// All returns all registered modules.
func All() []Module {
mu.RLock()
defer mu.RUnlock()
result := make([]Module, 0, len(registry))
for _, m := range registry {
result = append(result, m)
}
return result
}
// ByTag returns modules matching a tag.
func ByTag(tag string) []Module {
mu.RLock()
defer mu.RUnlock()
var result []Module
for _, m := range registry {
for _, t := range m.Info().Tags {
if t == tag {
result = append(result, m)
break
}
}
}
return result
}
// ByType returns modules of a specific type.
func ByType(t ModuleType) []Module {
mu.RLock()
defer mu.RUnlock()
var result []Module
for _, m := range registry {
if m.Type() == t {
result = append(result, m)
}
}
return result
}
// Count returns the number of registered modules.
func Count() int {
mu.RLock()
defer mu.RUnlock()
return len(registry)
}
// Clear removes all modules from the registry.
// This is primarily useful for testing.
func Clear() {
mu.Lock()
defer mu.Unlock()
registry = make(map[string]Module)
}
+155
View File
@@ -0,0 +1,155 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
/*
-------------------------------------------------------------------------------------------------
: :
: SIF - Blazing-fast pentesting suite :
: Blaze - BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
-------------------------------------------------------------------------------------------------
*/
package modules
import (
"context"
"fmt"
"os"
"gopkg.in/yaml.v3"
)
// YAMLModule represents a parsed YAML module file
type YAMLModule struct {
ID string `yaml:"id"`
Info YAMLModuleInfo `yaml:"info"`
Type ModuleType `yaml:"type"`
HTTP *HTTPConfig `yaml:"http,omitempty"`
DNS *DNSConfig `yaml:"dns,omitempty"`
TCP *TCPConfig `yaml:"tcp,omitempty"`
}
// YAMLModuleInfo contains module metadata
type YAMLModuleInfo struct {
Name string `yaml:"name"`
Author string `yaml:"author"`
Severity string `yaml:"severity"`
Description string `yaml:"description"`
Tags []string `yaml:"tags"`
}
// HTTPConfig defines HTTP module settings
type HTTPConfig struct {
Method string `yaml:"method"`
Paths []string `yaml:"paths"`
Payloads []string `yaml:"payloads,omitempty"`
Headers map[string]string `yaml:"headers,omitempty"`
Body string `yaml:"body,omitempty"`
Attack string `yaml:"attack,omitempty"` // sniper, pitchfork, clusterbomb
Threads int `yaml:"threads,omitempty"`
Matchers []Matcher `yaml:"matchers"`
Extractors []Extractor `yaml:"extractors,omitempty"`
}
// DNSConfig defines DNS module settings
type DNSConfig struct {
Type string `yaml:"type"` // A, AAAA, MX, TXT, NS, etc.
Name string `yaml:"name"`
Matchers []Matcher `yaml:"matchers"`
Extractors []Extractor `yaml:"extractors,omitempty"`
}
// TCPConfig defines TCP module settings
type TCPConfig struct {
Port int `yaml:"port"`
Data string `yaml:"data,omitempty"`
Matchers []Matcher `yaml:"matchers"`
Extractors []Extractor `yaml:"extractors,omitempty"`
}
// ParseYAMLModule parses a YAML file into a module definition
func ParseYAMLModule(path string) (*YAMLModule, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read module file: %w", err)
}
var ym YAMLModule
if err := yaml.Unmarshal(data, &ym); err != nil {
return nil, fmt.Errorf("parse yaml: %w", err)
}
if ym.ID == "" {
return nil, fmt.Errorf("module missing required field: id")
}
if ym.Type == "" {
return nil, fmt.Errorf("module missing required field: type")
}
return &ym, nil
}
// yamlModuleWrapper wraps YAMLModule to implement the Module interface
type yamlModuleWrapper struct {
def *YAMLModule
path string
}
// newYAMLModuleWrapper creates a Module from a YAMLModule definition
func newYAMLModuleWrapper(def *YAMLModule, path string) *yamlModuleWrapper {
return &yamlModuleWrapper{def: def, path: path}
}
// Info returns the module metadata
func (m *yamlModuleWrapper) Info() Info {
return Info{
ID: m.def.ID,
Name: m.def.Info.Name,
Author: m.def.Info.Author,
Severity: m.def.Info.Severity,
Description: m.def.Info.Description,
Tags: m.def.Info.Tags,
}
}
// Type returns the module type
func (m *yamlModuleWrapper) Type() ModuleType {
return m.def.Type
}
// Execute runs the module (delegates to appropriate executor)
func (m *yamlModuleWrapper) Execute(ctx context.Context, target string, opts Options) (*Result, error) {
switch m.def.Type {
case TypeHTTP:
if m.def.HTTP == nil {
return nil, fmt.Errorf("HTTP module missing http configuration")
}
return ExecuteHTTPModule(ctx, target, m.def, opts)
case TypeDNS:
if m.def.DNS == nil {
return nil, fmt.Errorf("DNS module missing dns configuration")
}
return ExecuteDNSModule(ctx, target, m.def, opts)
case TypeTCP:
if m.def.TCP == nil {
return nil, fmt.Errorf("TCP module missing tcp configuration")
}
return ExecuteTCPModule(ctx, target, m.def, opts)
default:
return nil, fmt.Errorf("unsupported module type: %s", m.def.Type)
}
}
+119
View File
@@ -0,0 +1,119 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package notify
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
// env var names notify reads, env-first. these mirror the conventional names so
// an operator who already exports them for other tooling gets notify for free.
const (
envSlackWebhook = "SLACK_WEBHOOK_URL"
envDiscordWebhook = "DISCORD_WEBHOOK_URL"
// the name of the env var holding the bot token, not the token itself.
envTelegramToken = "TELEGRAM_BOT_TOKEN" //nolint:gosec // env var name, not a secret
envTelegramChat = "TELEGRAM_CHAT_ID"
envWebhookURL = "NOTIFY_WEBHOOK_URL"
)
// config holds resolved destinations for every provider. yaml tags use
// projectdiscovery/notify-compatible key names so an existing notify config file
// ports over verbatim; env supplies the same values and yaml overrides it.
type config struct {
SlackWebhook string `yaml:"slack_webhook_url"`
DiscordWebhook string `yaml:"discord_webhook_url"`
// telegram needs both a bot token and a chat id. notify spells the token
// "telegram_api_key", so accept that key for drop-in compatibility.
TelegramToken string `yaml:"telegram_api_key"`
TelegramChat string `yaml:"telegram_chat_id"`
WebhookURL string `yaml:"webhook_url"`
}
// loadConfig resolves notify destinations env-first, then overlays a yaml file
// when path is non-empty. yaml wins per-field so a file value overrides the
// matching env var; an unset yaml field leaves the env value intact. an empty
// path means env-only. a missing/unparseable file is an error - if the operator
// pointed -notify-config somewhere, a typo should fail loud, not silently drop.
func loadConfig(path string) (config, error) {
cfg := config{
SlackWebhook: os.Getenv(envSlackWebhook),
DiscordWebhook: os.Getenv(envDiscordWebhook),
TelegramToken: os.Getenv(envTelegramToken),
TelegramChat: os.Getenv(envTelegramChat),
WebhookURL: os.Getenv(envWebhookURL),
}
if path == "" {
return cfg, nil
}
data, err := os.ReadFile(path)
if err != nil {
return config{}, fmt.Errorf("read config %q: %w", path, err)
}
// decode into a separate value so only the keys present in the file overlay
// the env-derived defaults; a zero field in the yaml must not blank an env var.
var file config
if err := yaml.Unmarshal(data, &file); err != nil {
return config{}, fmt.Errorf("parse config %q: %w", path, err)
}
overlay(&cfg, &file)
return cfg, nil
}
// overlay copies non-empty fields from src onto dst. used to let a yaml file
// override env without an empty yaml key wiping out a populated env value.
func overlay(dst, src *config) {
if src.SlackWebhook != "" {
dst.SlackWebhook = src.SlackWebhook
}
if src.DiscordWebhook != "" {
dst.DiscordWebhook = src.DiscordWebhook
}
if src.TelegramToken != "" {
dst.TelegramToken = src.TelegramToken
}
if src.TelegramChat != "" {
dst.TelegramChat = src.TelegramChat
}
if src.WebhookURL != "" {
dst.WebhookURL = src.WebhookURL
}
}
// providers builds the live provider list from the resolved config: a provider
// is included only when its destination is fully specified. telegram needs both
// token and chat id, so a half-configured telegram is dropped rather than POSTing
// to a broken endpoint.
func (c *config) providers() []provider {
var out []provider
if c.SlackWebhook != "" {
out = append(out, &slackProvider{webhook: c.SlackWebhook})
}
if c.DiscordWebhook != "" {
out = append(out, &discordProvider{webhook: c.DiscordWebhook})
}
if c.TelegramToken != "" && c.TelegramChat != "" {
out = append(out, &telegramProvider{token: c.TelegramToken, chatID: c.TelegramChat})
}
if c.WebhookURL != "" {
out = append(out, &webhookProvider{url: c.WebhookURL})
}
return out
}
+153
View File
@@ -0,0 +1,153 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package notify
import (
"os"
"path/filepath"
"testing"
)
// clearNotifyEnv unsets every var loadConfig reads so a test starts from a known
// blank slate; t.Setenv("", "") still records the var for cleanup restoration.
func clearNotifyEnv(t *testing.T) {
t.Helper()
for _, k := range []string{
envSlackWebhook, envDiscordWebhook,
envTelegramToken, envTelegramChat, envWebhookURL,
} {
t.Setenv(k, "")
}
}
func TestLoadConfigEnvOnly(t *testing.T) {
clearNotifyEnv(t)
t.Setenv(envSlackWebhook, "https://hooks.slack.test/a")
t.Setenv(envTelegramToken, "123:abc")
t.Setenv(envTelegramChat, "999")
cfg, err := loadConfig("")
if err != nil {
t.Fatalf("loadConfig: %v", err)
}
if cfg.SlackWebhook != "https://hooks.slack.test/a" {
t.Errorf("slack webhook = %q, want from env", cfg.SlackWebhook)
}
if cfg.TelegramToken != "123:abc" || cfg.TelegramChat != "999" {
t.Errorf("telegram = %q/%q, want from env", cfg.TelegramToken, cfg.TelegramChat)
}
// slack + telegram (both halves) configured, discord/webhook empty.
got := cfg.providers()
if len(got) != 2 {
t.Fatalf("providers = %d, want 2 (slack, telegram)", len(got))
}
wantNames := map[string]bool{"slack": false, "telegram": false}
for _, p := range got {
wantNames[p.name()] = true
}
for name, seen := range wantNames {
if !seen {
t.Errorf("provider %q missing", name)
}
}
}
func TestLoadConfigYAMLOverridesEnv(t *testing.T) {
clearNotifyEnv(t)
t.Setenv(envSlackWebhook, "https://env.slack.test/x")
t.Setenv(envWebhookURL, "https://env.webhook.test/x")
body := "" +
"slack_webhook_url: https://file.slack.test/y\n" +
"discord_webhook_url: https://file.discord.test/z\n"
path := writeTempConfig(t, body)
cfg, err := loadConfig(path)
if err != nil {
t.Fatalf("loadConfig: %v", err)
}
// yaml present -> overrides env.
if cfg.SlackWebhook != "https://file.slack.test/y" {
t.Errorf("slack = %q, want yaml override", cfg.SlackWebhook)
}
// yaml absent for webhook -> env value survives.
if cfg.WebhookURL != "https://env.webhook.test/x" {
t.Errorf("webhook = %q, want env value preserved", cfg.WebhookURL)
}
// yaml introduces discord.
if cfg.DiscordWebhook != "https://file.discord.test/z" {
t.Errorf("discord = %q, want from yaml", cfg.DiscordWebhook)
}
}
func TestLoadConfigNotifyCompatibleTelegramKey(t *testing.T) {
clearNotifyEnv(t)
// projectdiscovery/notify spells the bot token "telegram_api_key"; assert a
// drop-in config wires telegram from that key.
body := "" +
"telegram_api_key: 555:tok\n" +
"telegram_chat_id: \"42\"\n"
path := writeTempConfig(t, body)
cfg, err := loadConfig(path)
if err != nil {
t.Fatalf("loadConfig: %v", err)
}
if cfg.TelegramToken != "555:tok" || cfg.TelegramChat != "42" {
t.Fatalf("telegram = %q/%q, want from notify-compatible keys", cfg.TelegramToken, cfg.TelegramChat)
}
if len(cfg.providers()) != 1 {
t.Fatalf("providers = %d, want 1 (telegram)", len(cfg.providers()))
}
}
func TestLoadConfigMissingFileErrors(t *testing.T) {
clearNotifyEnv(t)
if _, err := loadConfig(filepath.Join(t.TempDir(), "nope.yaml")); err == nil {
t.Fatal("loadConfig with missing file: want error, got nil")
}
}
func TestLoadConfigBadYAMLErrors(t *testing.T) {
clearNotifyEnv(t)
path := writeTempConfig(t, "slack_webhook_url: [unterminated\n")
if _, err := loadConfig(path); err == nil {
t.Fatal("loadConfig with malformed yaml: want error, got nil")
}
}
func TestProvidersTelegramNeedsBothHalves(t *testing.T) {
// token without chat id must not produce a (broken) telegram provider.
cfg := config{TelegramToken: "tok"}
if got := cfg.providers(); len(got) != 0 {
t.Fatalf("providers = %d, want 0 for half-configured telegram", len(got))
}
}
func TestProvidersEmptyConfigIsNone(t *testing.T) {
var cfg config
if got := cfg.providers(); len(got) != 0 {
t.Fatalf("providers = %d, want 0 for empty config", len(got))
}
}
// writeTempConfig writes body to a temp yaml file and returns its path.
func writeTempConfig(t *testing.T, body string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "notify.yaml")
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write temp config: %v", err)
}
return path
}
+39
View File
@@ -0,0 +1,39 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package notify
import (
"context"
"net/http"
"github.com/dropalldatabases/sif/internal/finding"
)
// discordProvider posts to a discord webhook. discord's incoming-webhook body
// keys the message on "content" (slack uses "text"); same code-block wrapping so
// the finding columns line up in the channel.
type discordProvider struct {
webhook string
}
func (d *discordProvider) name() string { return "discord" }
// discordPayload is the minimal webhook body: a single content field.
type discordPayload struct {
Content string `json:"content"`
}
func (d *discordProvider) send(ctx context.Context, client *http.Client, findings []finding.Finding) error {
payload := discordPayload{Content: codeBlock(renderFindings(findings))}
return postJSON(ctx, client, d.webhook, payload)
}
+74
View File
@@ -0,0 +1,74 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package notify
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/dropalldatabases/sif/internal/finding"
"github.com/dropalldatabases/sif/internal/httpx"
)
// contentTypeJSON is the body type every provider POSTs; all four speak json.
const contentTypeJSON = "application/json"
// messageHeader prefixes the rendered finding block. kept terse - chat sinks
// truncate, so the count and lead-in carry the signal.
const messageHeader = "sif found %d finding(s):"
// renderFindings turns a batch into a single plain-text block, one finding per
// line in the same "[severity] target module title" shape as the -silent sink so
// a reader sees identical lines across stdout and chat. a strings.Builder keeps
// the per-line concat to one allocation path.
func renderFindings(findings []finding.Finding) string {
var b strings.Builder
fmt.Fprintf(&b, messageHeader, len(findings))
b.WriteByte('\n')
for i := 0; i < len(findings); i++ {
b.WriteString(findings[i].Line())
b.WriteByte('\n')
}
return b.String()
}
// postJSON marshals payload and POSTs it to url through the shared client. it
// drains+closes the response so the conn returns to httpx's pool, and treats any
// non-2xx as a delivery failure so a 4xx from a bad webhook surfaces loudly.
func postJSON(ctx context.Context, client *http.Client, url string, payload any) error {
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal payload: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("build request: %w", err)
}
req.Header.Set("Content-Type", contentTypeJSON)
resp, err := client.Do(req) //nolint:bodyclose // drained and closed via httpx.DrainClose
if err != nil {
return fmt.Errorf("post: %w", err)
}
defer httpx.DrainClose(resp)
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return fmt.Errorf("unexpected status %d", resp.StatusCode)
}
return nil
}
+85
View File
@@ -0,0 +1,85 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
// Package notify ships findings to chat/webhook sinks (slack, discord, telegram,
// generic webhook) so a continuous-recon run can alert on what it turns up. every
// provider is one POST through httpx.Client, so the global proxy/rate-limit/header
// config applies uniformly and there's no extra http stack to keep in sync.
package notify
import (
"context"
"fmt"
"net/http"
"time"
"github.com/dropalldatabases/sif/internal/finding"
"github.com/dropalldatabases/sif/internal/httpx"
"github.com/dropalldatabases/sif/internal/output"
)
// Options carries the runtime knobs Send needs. Timeout bounds each provider's
// POST; ConfigPath is an optional yaml file whose values override env. severity
// filtering is the caller's job - Send ships whatever batch it's handed.
type Options struct {
Timeout time.Duration
ConfigPath string
}
// Send dispatches findings to every configured provider. config resolves
// env-first, then a yaml file overlays it (notify-compatible key names). a
// provider with no destination is skipped, so zero configured providers makes
// Send a silent no-op - notify is opt-in and never errors just for being unwired.
// an empty findings slice is also a no-op: nothing to report.
func Send(ctx context.Context, findings []finding.Finding, opts Options) error {
if len(findings) == 0 {
return nil
}
cfg, err := loadConfig(opts.ConfigPath)
if err != nil {
return fmt.Errorf("notify config: %w", err)
}
providers := cfg.providers()
if len(providers) == 0 {
// nothing wired up; opt-in feature stays quiet rather than erroring.
return nil
}
log := output.Module("NOTIFY")
client := httpx.Client(opts.Timeout)
// run every provider; a failure on one sink must not suppress the others, so
// errors accumulate and the first is returned after all have been attempted.
var firstErr error
for i := 0; i < len(providers); i++ {
p := providers[i]
if err := p.send(ctx, client, findings); err != nil {
log.Error("%s delivery failed: %v", p.name(), err)
if firstErr == nil {
firstErr = fmt.Errorf("%s: %w", p.name(), err)
}
continue
}
log.Success("sent %d findings to %s", len(findings), p.name())
}
return firstErr
}
// provider is one delivery sink. name is for logging; send formats findings into
// the sink's payload and POSTs it through the shared client.
type provider interface {
name() string
send(ctx context.Context, client *http.Client, findings []finding.Finding) error
}
+224
View File
@@ -0,0 +1,224 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package notify
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/dropalldatabases/sif/internal/finding"
)
// sampleFindings returns a small mixed-severity batch for payload assertions.
func sampleFindings() []finding.Finding {
return []finding.Finding{
{Target: "https://a.test", Module: "cors", Severity: finding.SeverityHigh, Key: "cors:a", Title: "reflected origin", Raw: "ACAO echo"},
{Target: "https://a.test", Module: "headers", Severity: finding.SeverityInfo, Key: "headers:x", Title: "Server header", Raw: "nginx"},
}
}
// capture records the method, content-type and raw body of the request a provider
// makes, so each test can assert the wire shape without a real network.
type capture struct {
method string
contentType string
path string
body []byte
}
// captureServer stands up an httptest server that records the single inbound
// request into c and replies 200, the happy path every provider expects.
func captureServer(t *testing.T, c *capture) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
c.method = r.Method
c.contentType = r.Header.Get("Content-Type")
c.path = r.URL.Path
c.body = body
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
return srv
}
func TestSlackPayloadShape(t *testing.T) {
var c capture
srv := captureServer(t, &c)
p := &slackProvider{webhook: srv.URL}
if err := p.send(context.Background(), srv.Client(), sampleFindings()); err != nil {
t.Fatalf("slack send: %v", err)
}
assertPostJSON(t, c)
var payload slackPayload
if err := json.Unmarshal(c.body, &payload); err != nil {
t.Fatalf("unmarshal slack body: %v", err)
}
// slack keys on "text"; both findings must appear, code-block fenced.
if !strings.Contains(payload.Text, "reflected origin") || !strings.Contains(payload.Text, "Server header") {
t.Errorf("slack text missing findings: %q", payload.Text)
}
if !strings.HasPrefix(payload.Text, "```") {
t.Errorf("slack text not code-block fenced: %q", payload.Text)
}
}
func TestDiscordPayloadShape(t *testing.T) {
var c capture
srv := captureServer(t, &c)
p := &discordProvider{webhook: srv.URL}
if err := p.send(context.Background(), srv.Client(), sampleFindings()); err != nil {
t.Fatalf("discord send: %v", err)
}
assertPostJSON(t, c)
var payload discordPayload
if err := json.Unmarshal(c.body, &payload); err != nil {
t.Fatalf("unmarshal discord body: %v", err)
}
// discord keys on "content", not "text".
if !strings.Contains(payload.Content, "reflected origin") {
t.Errorf("discord content missing finding: %q", payload.Content)
}
}
func TestTelegramPayloadShape(t *testing.T) {
var c capture
srv := captureServer(t, &c)
// repoint the bot api base at the test server for the lifetime of this test.
orig := telegramAPIBase
telegramAPIBase = srv.URL
t.Cleanup(func() { telegramAPIBase = orig })
p := &telegramProvider{token: "555:tok", chatID: "42"}
if err := p.send(context.Background(), srv.Client(), sampleFindings()); err != nil {
t.Fatalf("telegram send: %v", err)
}
assertPostJSON(t, c)
// the token rides the path and the method is sendMessage.
if c.path != "/bot555:tok/sendMessage" {
t.Errorf("telegram path = %q, want /bot555:tok/sendMessage", c.path)
}
var payload telegramPayload
if err := json.Unmarshal(c.body, &payload); err != nil {
t.Fatalf("unmarshal telegram body: %v", err)
}
if payload.ChatID != "42" {
t.Errorf("telegram chat_id = %q, want 42", payload.ChatID)
}
if !strings.Contains(payload.Text, "reflected origin") {
t.Errorf("telegram text missing finding: %q", payload.Text)
}
}
func TestWebhookPayloadShape(t *testing.T) {
var c capture
srv := captureServer(t, &c)
p := &webhookProvider{url: srv.URL}
if err := p.send(context.Background(), srv.Client(), sampleFindings()); err != nil {
t.Fatalf("webhook send: %v", err)
}
assertPostJSON(t, c)
var payload webhookPayload
if err := json.Unmarshal(c.body, &payload); err != nil {
t.Fatalf("unmarshal webhook body: %v", err)
}
// generic webhook carries structured findings, not a prerendered blob.
if payload.Count != 2 || len(payload.Findings) != 2 {
t.Fatalf("webhook count = %d / %d findings, want 2", payload.Count, len(payload.Findings))
}
first := payload.Findings[0]
if first.Severity != "high" {
t.Errorf("webhook severity = %q, want canonical string \"high\"", first.Severity)
}
if first.Key != "cors:a" || first.Module != "cors" {
t.Errorf("webhook finding fields wrong: %+v", first)
}
}
func TestProviderNon2xxIsError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusForbidden)
}))
t.Cleanup(srv.Close)
p := &slackProvider{webhook: srv.URL}
if err := p.send(context.Background(), srv.Client(), sampleFindings()); err == nil {
t.Fatal("send to 403 endpoint: want error, got nil")
}
}
func TestSendNoProviderIsNoop(t *testing.T) {
clearNotifyEnv(t)
// no env, no config file -> zero providers -> Send must not error.
if err := Send(context.Background(), sampleFindings(), Options{Timeout: time.Second}); err != nil {
t.Fatalf("Send with no provider: want nil, got %v", err)
}
}
func TestSendEmptyFindingsIsNoop(t *testing.T) {
// even with a provider configured, an empty batch must not POST anything.
hit := false
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
hit = true
w.WriteHeader(http.StatusOK)
}))
t.Cleanup(srv.Close)
clearNotifyEnv(t)
t.Setenv(envSlackWebhook, srv.URL)
if err := Send(context.Background(), nil, Options{Timeout: time.Second}); err != nil {
t.Fatalf("Send with empty findings: want nil, got %v", err)
}
if hit {
t.Fatal("Send with empty findings posted to provider, want no-op")
}
}
func TestSendDeliversToConfiguredProvider(t *testing.T) {
var c capture
srv := captureServer(t, &c)
clearNotifyEnv(t)
t.Setenv(envSlackWebhook, srv.URL)
if err := Send(context.Background(), sampleFindings(), Options{Timeout: time.Second}); err != nil {
t.Fatalf("Send: %v", err)
}
if c.method != http.MethodPost {
t.Fatalf("provider not hit (method=%q)", c.method)
}
}
// assertPostJSON checks the request was a json POST.
func assertPostJSON(t *testing.T, c capture) {
t.Helper()
if c.method != http.MethodPost {
t.Errorf("method = %q, want POST", c.method)
}
if c.contentType != contentTypeJSON {
t.Errorf("content-type = %q, want %q", c.contentType, contentTypeJSON)
}
}
+45
View File
@@ -0,0 +1,45 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package notify
import (
"context"
"net/http"
"github.com/dropalldatabases/sif/internal/finding"
)
// slackProvider posts to a slack incoming webhook. the webhook url already pins
// the channel, so the payload is just the rendered text in slack's mrkdwn-aware
// "text" field wrapped in a code block to keep the fixed-width finding lines.
type slackProvider struct {
webhook string
}
func (s *slackProvider) name() string { return "slack" }
// slackPayload is the minimal incoming-webhook body: a single text field.
type slackPayload struct {
Text string `json:"text"`
}
func (s *slackProvider) send(ctx context.Context, client *http.Client, findings []finding.Finding) error {
payload := slackPayload{Text: codeBlock(renderFindings(findings))}
return postJSON(ctx, client, s.webhook, payload)
}
// codeBlock wraps body in a triple-backtick fence; both slack and discord render
// it fixed-width, which preserves the column-aligned finding lines.
func codeBlock(body string) string {
return "```\n" + body + "```"
}
+48
View File
@@ -0,0 +1,48 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package notify
import (
"context"
"net/http"
"github.com/dropalldatabases/sif/internal/finding"
)
// telegramAPIBase is the bot api root. it's a var so tests can repoint it at an
// httptest server; the token is appended path-side per telegram's scheme.
var telegramAPIBase = "https://api.telegram.org"
// telegramProvider posts via the bot api's sendMessage. unlike slack/discord the
// destination isn't a single opaque webhook: it needs the bot token (in the url
// path) plus the chat id (in the body).
type telegramProvider struct {
token string
chatID string
}
func (t *telegramProvider) name() string { return "telegram" }
// telegramPayload is the sendMessage body. parse_mode "MarkdownV2" would force
// escaping every special char in the finding lines, so we send plain text and
// let the lines stand as-is.
type telegramPayload struct {
ChatID string `json:"chat_id"`
Text string `json:"text"`
}
func (t *telegramProvider) send(ctx context.Context, client *http.Client, findings []finding.Finding) error {
endpoint := telegramAPIBase + "/bot" + t.token + "/sendMessage"
payload := telegramPayload{ChatID: t.chatID, Text: renderFindings(findings)}
return postJSON(ctx, client, endpoint, payload)
}
+65
View File
@@ -0,0 +1,65 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package notify
import (
"context"
"net/http"
"github.com/dropalldatabases/sif/internal/finding"
)
// webhookProvider posts a structured json payload to an arbitrary endpoint. unlike
// the chat sinks it carries the findings as data, not a prerendered blob, so
// downstream automation (a siem, a bot, ci) keys off the fields directly.
type webhookProvider struct {
url string
}
func (w *webhookProvider) name() string { return "webhook" }
// webhookFinding is the per-item wire shape: the normalized Finding fields with
// severity flattened to its canonical string so a json consumer never sees the
// internal integer rank.
type webhookFinding struct {
Target string `json:"target"`
Module string `json:"module"`
Severity string `json:"severity"`
Key string `json:"key"`
Title string `json:"title"`
Raw string `json:"raw,omitempty"`
}
// webhookPayload wraps the batch with a count so a consumer can size buffers /
// assert completeness without walking the slice first.
type webhookPayload struct {
Count int `json:"count"`
Findings []webhookFinding `json:"findings"`
}
func (w *webhookProvider) send(ctx context.Context, client *http.Client, findings []finding.Finding) error {
items := make([]webhookFinding, 0, len(findings))
for i := 0; i < len(findings); i++ {
f := findings[i]
items = append(items, webhookFinding{
Target: f.Target,
Module: f.Module,
Severity: f.Severity.String(),
Key: f.Key,
Title: f.Title,
Raw: f.Raw,
})
}
payload := webhookPayload{Count: len(items), Findings: items}
return postJSON(ctx, client, w.url, payload)
}
+20 -10
View File
@@ -1,25 +1,35 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package format
import (
"fmt"
"github.com/dropalldatabases/sif/internal/styles"
"github.com/projectdiscovery/nuclei/v2/pkg/output"
nucleiout "github.com/projectdiscovery/nuclei/v3/pkg/output"
)
func FormatLine(event *output.ResultEvent) string {
output := event.TemplateID
func FormatLine(event *nucleiout.ResultEvent) string {
line := event.TemplateID
if event.MatcherName != "" {
output += ":" + styles.Highlight.Render(event.MatcherName)
line += ":" + styles.Highlight.Render(event.MatcherName)
} else if event.ExtractorName != "" {
output += ":" + styles.Highlight.Render(event.ExtractorName)
line += ":" + styles.Highlight.Render(event.ExtractorName)
}
output += " [" + event.Type + "]"
output += " [" + formatSeverity(fmt.Sprintf("%s", event.Info.SeverityHolder.Severity)) + "]"
line += " [" + event.Type + "]"
line += " [" + formatSeverity(event.Info.SeverityHolder.Severity.String()) + "]"
return output
return line
}
func formatSeverity(severity string) string {
+44 -6
View File
@@ -1,13 +1,28 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package templates
import (
"archive/tar"
"compress/gzip"
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/charmbracelet/log"
)
@@ -25,7 +40,12 @@ func Install(logger *log.Logger) error {
logger.Infof("nuclei-templates directory not found. Installing...")
resp, err := http.Get(fmt.Sprintf(archive, ref))
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, fmt.Sprintf(archive, ref), http.NoBody)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
@@ -35,37 +55,55 @@ func Install(logger *log.Logger) error {
if err != nil {
return err
}
defer tarball.Close()
defer func() {
if cerr := tarball.Close(); cerr != nil {
logger.Warnf("closing gzip reader: %v", cerr)
}
}()
data := tar.NewReader(tarball)
dest, err := os.Getwd()
if err != nil {
return err
}
cleanDest := filepath.Clean(dest)
for {
header, err := data.Next()
if errors.Is(io.EOF, err) {
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return err
}
// guard against path traversal ("Zip Slip"): the resolved path must
// stay within the extraction directory before any filesystem op.
target := filepath.Join(cleanDest, header.Name)
if !strings.HasPrefix(target, cleanDest+string(os.PathSeparator)) {
return fmt.Errorf("invalid archive entry %q: escapes extraction directory", header.Name)
}
switch header.Typeflag {
case tar.TypeDir:
if err := os.Mkdir(header.Name, 0755); err != nil {
if err := os.Mkdir(target, 0o750); err != nil {
return err
}
case tar.TypeReg:
file, err := os.Create(header.Name)
file, err := os.Create(target)
if err != nil {
return err
}
if _, err := io.Copy(file, data); err != nil {
file.Close()
return err
}
file.Close()
}
}
if err = os.Rename(fmt.Sprintf("nuclei-templates-%s", ref), "nuclei-templates"); err != nil {
if err := os.Rename(fmt.Sprintf("nuclei-templates-%s", ref), "nuclei-templates"); err != nil {
return err
}
+331
View File
@@ -0,0 +1,331 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package output
import (
"fmt"
"io"
"os"
"strings"
"github.com/charmbracelet/lipgloss"
)
// Clean, subtle color palette
var (
ColorGreen = lipgloss.Color("#22c55e") // success green
ColorBlue = lipgloss.Color("#3b82f6") // info blue
ColorYellow = lipgloss.Color("#eab308") // warning yellow
ColorRed = lipgloss.Color("#ef4444") // error red
ColorGray = lipgloss.Color("#6b7280") // muted gray
ColorWhite = lipgloss.Color("#f3f4f6") // bright text
)
// Prefix styles
var (
prefixInfo = lipgloss.NewStyle().Foreground(ColorBlue).Bold(true)
prefixSuccess = lipgloss.NewStyle().Foreground(ColorGreen).Bold(true)
prefixWarning = lipgloss.NewStyle().Foreground(ColorYellow).Bold(true)
prefixError = lipgloss.NewStyle().Foreground(ColorRed).Bold(true)
)
// Text styles
var (
Highlight = lipgloss.NewStyle().Bold(true).Foreground(ColorWhite)
Muted = lipgloss.NewStyle().Foreground(ColorGray)
Status = lipgloss.NewStyle().Bold(true).Foreground(ColorGreen)
)
// Box style for banners
var Box = lipgloss.NewStyle().
Bold(true).
Foreground(ColorWhite).
BorderStyle(lipgloss.RoundedBorder()).
BorderForeground(ColorGray).
Align(lipgloss.Center).
PaddingRight(15).
PaddingLeft(15).
Width(60)
// Subheading style
var Subheading = lipgloss.NewStyle().
Foreground(ColorGray).
Align(lipgloss.Center).
PaddingRight(15).
PaddingLeft(15).
Width(60)
// Severity styles
var (
SeverityLow = lipgloss.NewStyle().Foreground(ColorGreen)
SeverityMedium = lipgloss.NewStyle().Foreground(ColorYellow)
SeverityHigh = lipgloss.NewStyle().Foreground(lipgloss.Color("#f97316")) // orange
SeverityCritical = lipgloss.NewStyle().Foreground(ColorRed).Bold(true)
)
// Module color palette - visually distinct, nice colors
var moduleColors = []lipgloss.Color{
lipgloss.Color("#6366f1"), // indigo
lipgloss.Color("#8b5cf6"), // violet
lipgloss.Color("#ec4899"), // pink
lipgloss.Color("#f97316"), // orange
lipgloss.Color("#14b8a6"), // teal
lipgloss.Color("#06b6d4"), // cyan
lipgloss.Color("#84cc16"), // lime
lipgloss.Color("#a855f7"), // purple
lipgloss.Color("#f43f5e"), // rose
lipgloss.Color("#0ea5e9"), // sky
}
// getModuleColor returns a consistent color for a module name
func getModuleColor(name string) lipgloss.Color {
// Simple hash to pick a color
hash := 0
for _, c := range name {
hash = hash*31 + int(c)
}
if hash < 0 {
hash = -hash
}
return moduleColors[hash%len(moduleColors)]
}
// moduleStyleFor returns a styled prefix for a module
func moduleStyleFor(name string) lipgloss.Style {
return lipgloss.NewStyle().
Background(getModuleColor(name)).
Foreground(lipgloss.Color("#ffffff")).
Bold(true).
Padding(0, 1)
}
// IsTTY returns true if stdout is a terminal
var IsTTY = checkTTY()
func checkTTY() bool {
if fileInfo, _ := os.Stdout.Stat(); (fileInfo.Mode() & os.ModeCharDevice) != 0 {
return true
}
return false
}
// apiMode disables visual output when true
var apiMode bool
// SetAPIMode enables or disables API mode
func SetAPIMode(enabled bool) {
apiMode = enabled
}
// sink is where all banner/spinner/log chrome is written. it defaults to stdout
// so normal runs are unchanged; -silent repoints it at stderr so stdout carries
// nothing but the machine-readable findings a downstream pipe consumes.
var sink io.Writer = os.Stdout
// silent is the plain-sink mode: chrome goes to stderr and interactive widgets
// (spinners, live progress) are suppressed so a piped consumer never sees them.
var silent bool
// SetSilent routes all chrome to stderr and marks the run non-interactive.
// findings are printed to stdout by the caller via Finding/PrintFinding; the
// output package itself never touches stdout once silent is on.
func SetSilent(enabled bool) {
silent = enabled
if enabled {
sink = os.Stderr
return
}
sink = os.Stdout
}
// Silent reports whether plain-sink mode is active. callers gate interactive
// behaviour (spinners, prompts) on this.
func Silent() bool {
return silent
}
// Writer is the current chrome sink (stdout normally, stderr under -silent).
// callers that render their own chrome (the startup banner) write here so it
// follows the same routing as everything else.
func Writer() io.Writer {
return sink
}
// Info prints an informational message with [*] prefix
func Info(format string, args ...interface{}) {
if apiMode {
return
}
msg := fmt.Sprintf(format, args...)
fmt.Fprintf(sink, "%s %s\n", prefixInfo.Render("[*]"), msg)
}
// Success prints a success message with [+] prefix
func Success(format string, args ...interface{}) {
if apiMode {
return
}
msg := fmt.Sprintf(format, args...)
fmt.Fprintf(sink, "%s %s\n", prefixSuccess.Render("[+]"), msg)
}
// Warn prints a warning message with [!] prefix
func Warn(format string, args ...interface{}) {
if apiMode {
return
}
msg := fmt.Sprintf(format, args...)
fmt.Fprintf(sink, "%s %s\n", prefixWarning.Render("[!]"), msg)
}
// Error prints an error message with [-] prefix
func Error(format string, args ...interface{}) {
if apiMode {
return
}
msg := fmt.Sprintf(format, args...)
fmt.Fprintf(sink, "%s %s\n", prefixError.Render("[-]"), msg)
}
// ScanStart prints a styled scan start message
func ScanStart(scanName string) {
if apiMode {
return
}
fmt.Fprintf(sink, "%s starting %s\n", prefixInfo.Render("[*]"), scanName)
}
// ScanComplete prints a styled scan completion message
func ScanComplete(scanName string, resultCount int, resultType string) {
if apiMode {
return
}
fmt.Fprintf(sink, "%s %s complete (%d %s)\n", prefixInfo.Render("[*]"), scanName, resultCount, resultType)
}
// Module creates a prefixed logger for a specific module/tool
func Module(name string) *ModuleLogger {
return &ModuleLogger{
name: name,
style: moduleStyleFor(name),
}
}
// ModuleLogger provides prefixed logging for a specific module
type ModuleLogger struct {
name string
style lipgloss.Style
}
func (m *ModuleLogger) prefix() string {
return m.style.Render(m.name)
}
// Info prints an info message with module prefix
func (m *ModuleLogger) Info(format string, args ...interface{}) {
if apiMode {
return
}
msg := fmt.Sprintf(format, args...)
fmt.Fprintf(sink, "%s %s\n", m.prefix(), msg)
}
// Success prints a success message with module prefix
func (m *ModuleLogger) Success(format string, args ...interface{}) {
if apiMode {
return
}
msg := fmt.Sprintf(format, args...)
fmt.Fprintf(sink, "%s %s %s\n", m.prefix(), prefixSuccess.Render("✓"), msg)
}
// Warn prints a warning message with module prefix
func (m *ModuleLogger) Warn(format string, args ...interface{}) {
if apiMode {
return
}
msg := fmt.Sprintf(format, args...)
fmt.Fprintf(sink, "%s %s %s\n", m.prefix(), prefixWarning.Render("!"), msg)
}
// Error prints an error message with module prefix
func (m *ModuleLogger) Error(format string, args ...interface{}) {
if apiMode {
return
}
msg := fmt.Sprintf(format, args...)
fmt.Fprintf(sink, "%s %s %s\n", m.prefix(), prefixError.Render("✗"), msg)
}
// Start prints a scan start message with module prefix (adds newline before for separation)
func (m *ModuleLogger) Start() {
if apiMode {
return
}
fmt.Fprintf(sink, "\n%s starting scan\n", m.prefix())
}
// Complete prints a scan complete message with module prefix
func (m *ModuleLogger) Complete(resultCount int, resultType string) {
if apiMode {
return
}
fmt.Fprintf(sink, "%s complete (%d %s)\n", m.prefix(), resultCount, resultType)
}
// ClearLine clears the current line (for progress bar updates). silent mode is
// non-interactive, so there's no live line to clear and stdout stays untouched.
func ClearLine() {
if !IsTTY || silent {
return
}
fmt.Fprint(sink, "\033[2K\r")
}
// Summary styles
var (
summaryHeader = lipgloss.NewStyle().
Bold(true).
Foreground(ColorWhite).
Background(lipgloss.Color("#22c55e")).
Padding(0, 2)
summaryLine = lipgloss.NewStyle().
Foreground(ColorGray)
)
// PrintSummary prints a clean scan completion summary
func PrintSummary(scans []string, logFiles []string) {
if apiMode {
return
}
fmt.Fprintln(sink)
fmt.Fprintln(sink, summaryLine.Render("────────────────────────────────────────────────────────────"))
fmt.Fprintln(sink)
fmt.Fprintf(sink, " %s\n", summaryHeader.Render("SCAN COMPLETE"))
fmt.Fprintln(sink)
// Print scans
scanList := strings.Join(scans, ", ")
fmt.Fprintf(sink, " %s %s\n", Muted.Render("Scans:"), scanList)
// Print log files if any
if len(logFiles) > 0 {
fmt.Fprintf(sink, " %s %s\n", Muted.Render("Output:"), strings.Join(logFiles, ", "))
}
fmt.Fprintln(sink)
fmt.Fprintln(sink, summaryLine.Render("────────────────────────────────────────────────────────────"))
fmt.Fprintln(sink)
}
+194
View File
@@ -0,0 +1,194 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package output
import (
"fmt"
"sync"
"sync/atomic"
)
// Progress bar configuration
const (
progressWidth = 30
progressFilled = "="
progressCurrent = ">"
progressEmpty = " "
)
// Progress displays a progress bar for operations with known counts
type Progress struct {
total int64
current int64
message string
lastItem string
mu sync.Mutex
paused bool
lastShown int // last printed milestone bucket in non-tty mode
}
// NewProgress creates a new progress bar
func NewProgress(total int, message string) *Progress {
return &Progress{
total: int64(total),
message: message,
}
}
// Increment advances the progress by 1 and optionally updates the current item
func (p *Progress) Increment(item string) {
atomic.AddInt64(&p.current, 1)
p.mu.Lock()
p.lastItem = item
paused := p.paused
p.mu.Unlock()
if !paused {
p.render()
}
}
// Set sets the progress to a specific value
func (p *Progress) Set(current int, item string) {
atomic.StoreInt64(&p.current, int64(current))
p.mu.Lock()
p.lastItem = item
paused := p.paused
p.mu.Unlock()
if !paused {
p.render()
}
}
// Pause temporarily stops rendering (use before printing other output)
func (p *Progress) Pause() {
p.mu.Lock()
p.paused = true
p.mu.Unlock()
ClearLine()
}
// Resume resumes rendering after a pause
func (p *Progress) Resume() {
p.mu.Lock()
p.paused = false
p.mu.Unlock()
p.render()
}
// Done clears the progress bar line
func (p *Progress) Done() {
if apiMode || !IsTTY {
return
}
ClearLine()
}
func (p *Progress) render() {
if apiMode || silent {
return
}
// In non-TTY mode, print progress at milestones only
if !IsTTY {
current := atomic.LoadInt64(&p.current)
total := p.total
if total <= 0 {
return
}
percent := int(current * 100 / total)
// map current to a milestone bucket (0=none,1..5). concurrent workers
// hammer the same bucket, so only print when the bucket advances.
bucket := 0
switch {
case current >= total:
bucket = 5
case percent >= 75:
bucket = 4
case percent >= 50:
bucket = 3
case percent >= 25:
bucket = 2
case current >= 1:
bucket = 1
}
p.mu.Lock()
advanced := bucket > p.lastShown
if advanced {
p.lastShown = bucket
}
p.mu.Unlock()
if advanced {
fmt.Fprintf(sink, " [%d%%] %d/%d\n", percent, current, total)
}
return
}
current := atomic.LoadInt64(&p.current)
total := p.total
p.mu.Lock()
lastItem := p.lastItem
p.mu.Unlock()
// Calculate percentage
percent := 0
if total > 0 {
percent = int(current * 100 / total)
}
// Build progress bar
filled := 0
if total > 0 {
filled = int(progressWidth * current / total)
}
if filled > progressWidth {
filled = progressWidth
}
bar := ""
for i := 0; i < progressWidth; i++ {
switch {
case i < filled:
bar += progressFilled
case i == filled && current < total:
bar += progressCurrent
default:
bar += progressEmpty
}
}
// Truncate item if too long
maxItemLen := 30
if len(lastItem) > maxItemLen {
lastItem = lastItem[:maxItemLen-3] + "..."
}
// Format: [========> ] 45% (4500/10000) /admin
line := fmt.Sprintf(" [%s] %3d%% (%d/%d) %s",
prefixInfo.Render(bar),
percent,
current,
total,
Muted.Render(lastItem),
)
ClearLine()
fmt.Fprint(sink, line)
}
+96
View File
@@ -0,0 +1,96 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package output
import (
"os"
"strings"
"sync"
"testing"
)
// the non-tty milestone path divides current*100/total, so a zero-total bar
// used to panic with integer divide-by-zero when piped or redirected.
func TestProgressZeroTotalNoPanic(t *testing.T) {
p := NewProgress(0, "scanning")
p.Increment("item")
p.Set(0, "item")
p.Done()
}
func TestProgressCounts(t *testing.T) {
p := NewProgress(4, "scanning")
for i := 0; i < 4; i++ {
p.Increment("x")
}
if p.current != 4 {
t.Errorf("current = %d, want 4", p.current)
}
}
// many concurrent workers used to spam the same milestone bucket (e.g. ten
// "[25%] .../1000" lines). each bucket must now print at most once.
func TestProgressNonTTYDedupesMilestones(t *testing.T) {
savedTTY, savedAPI := IsTTY, apiMode
IsTTY, apiMode = false, false
defer func() { IsTTY, apiMode = savedTTY, savedAPI }()
out := captureStdout(t, func() {
p := NewProgress(1000, "scanning")
var wg sync.WaitGroup
for i := 0; i < 40; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 25; j++ {
p.Increment("x")
}
}()
}
wg.Wait()
})
lines := strings.Count(out, "\n")
if lines > 5 {
t.Errorf("printed %d milestone lines, want <=5:\n%s", lines, out)
}
}
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("pipe: %v", err)
}
saved := os.Stdout
os.Stdout = w
done := make(chan string, 1)
go func() {
buf := make([]byte, 0, 4096)
tmp := make([]byte, 1024)
for {
n, rerr := r.Read(tmp)
buf = append(buf, tmp[:n]...)
if rerr != nil {
break
}
}
done <- string(buf)
}()
fn()
os.Stdout = saved
w.Close()
return <-done
}
+113
View File
@@ -0,0 +1,113 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package output
import (
"os"
"strings"
"testing"
)
// in silent mode chrome must land on stderr and leave stdout untouched, so a
// piped consumer downstream never sees a banner or log line.
func TestSetSilentRoutesChromeToStderr(t *testing.T) {
defer SetSilent(false)
outStr, errStr := captureStdoutStderr(t, func() {
// SetSilent reads os.Stderr at call time, so swap then set.
SetSilent(true)
Info("scanning %s", "example.com")
Success("done")
})
if outStr != "" {
t.Errorf("silent mode wrote chrome to stdout: %q", outStr)
}
if !strings.Contains(errStr, "scanning example.com") {
t.Errorf("silent chrome missing from stderr: %q", errStr)
}
}
// the default (non-silent) sink is stdout; flipping silent off must restore it.
func TestSetSilentOffRoutesChromeToStdout(t *testing.T) {
outStr, errStr := captureStdoutStderr(t, func() {
SetSilent(false)
Info("hello")
})
if !strings.Contains(outStr, "hello") {
t.Errorf("non-silent chrome missing from stdout: %q", outStr)
}
if strings.Contains(errStr, "hello") {
t.Errorf("non-silent chrome leaked to stderr: %q", errStr)
}
}
// Silent() reflects the toggle so callers can gate interactive widgets.
func TestSilentToggle(t *testing.T) {
defer SetSilent(false)
SetSilent(true)
if !Silent() {
t.Error("Silent() = false after SetSilent(true)")
}
SetSilent(false)
if Silent() {
t.Error("Silent() = true after SetSilent(false)")
}
}
// captureStdoutStderr swaps both real streams for pipes, runs fn, and returns
// what landed on each. SetSilent reads os.Stdout/os.Stderr at call time, so the
// swap has to happen before fn flips the sink - fn does that itself.
func captureStdoutStderr(t *testing.T, fn func()) (string, string) {
t.Helper()
outR, outW, err := os.Pipe()
if err != nil {
t.Fatalf("pipe stdout: %v", err)
}
errR, errW, err := os.Pipe()
if err != nil {
t.Fatalf("pipe stderr: %v", err)
}
savedOut, savedErr := os.Stdout, os.Stderr
os.Stdout, os.Stderr = outW, errW
outCh := drain(outR)
errCh := drain(errR)
fn()
os.Stdout, os.Stderr = savedOut, savedErr
outW.Close()
errW.Close()
return <-outCh, <-errCh
}
func drain(r *os.File) <-chan string {
ch := make(chan string, 1)
go func() {
buf := make([]byte, 0, 4096)
tmp := make([]byte, 1024)
for {
n, rerr := r.Read(tmp)
buf = append(buf, tmp[:n]...)
if rerr != nil {
break
}
}
ch <- string(buf)
}()
return ch
}
+120
View File
@@ -0,0 +1,120 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package output
import (
"fmt"
"sync"
"time"
)
// Spinner frames using simple ASCII
var spinnerFrames = []string{"|", "/", "-", "\\"}
// Spinner displays an animated spinner for indeterminate operations
type Spinner struct {
message string
running bool
done chan struct{}
mu sync.Mutex
interval time.Duration
}
// NewSpinner creates a new spinner with the given message
func NewSpinner(message string) *Spinner {
return &Spinner{
message: message,
interval: 100 * time.Millisecond,
done: make(chan struct{}),
}
}
// Start begins the spinner animation
func (s *Spinner) Start() {
if apiMode || silent {
return
}
s.mu.Lock()
if s.running {
s.mu.Unlock()
return
}
s.running = true
s.done = make(chan struct{})
s.mu.Unlock()
// In non-TTY mode, just print the message once
if !IsTTY {
fmt.Fprintf(sink, " %s...\n", s.message)
return
}
go s.animate()
}
// Stop halts the spinner and clears the line
func (s *Spinner) Stop() {
if apiMode || silent {
return
}
s.mu.Lock()
if !s.running {
s.mu.Unlock()
return
}
s.running = false
close(s.done)
s.mu.Unlock()
// Give animation goroutine time to exit
time.Sleep(s.interval)
// Clear the spinner line
if IsTTY {
ClearLine()
}
}
// Update changes the spinner message while running
func (s *Spinner) Update(message string) {
s.mu.Lock()
s.message = message
s.mu.Unlock()
}
func (s *Spinner) animate() {
frame := 0
ticker := time.NewTicker(s.interval)
defer ticker.Stop()
for {
select {
case <-s.done:
return
case <-ticker.C:
s.mu.Lock()
msg := s.message
s.mu.Unlock()
spinnerChar := prefixInfo.Render(spinnerFrames[frame])
line := fmt.Sprintf("\r %s %s", spinnerChar, msg)
fmt.Fprint(sink, "\033[2K") // Clear line
fmt.Fprint(sink, line)
frame = (frame + 1) % len(spinnerFrames)
}
}
}
+145
View File
@@ -0,0 +1,145 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
// Package patchnotes shows release notes pulled from the github releases.
package patchnotes
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/charmbracelet/glamour"
)
const releasesAPI = "https://api.github.com/repos/vmfunc/sif/releases"
type release struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
Body string `json:"body"`
URL string `json:"html_url"`
}
func fetch(ctx context.Context, path string) (*release, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, releasesAPI+path, http.NoBody)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("github returned %s", resp.Status)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 5*1024*1024))
if err != nil {
return nil, err
}
var r release
if err := json.Unmarshal(body, &r); err != nil {
return nil, err
}
return &r, nil
}
// render turns a release's markdown body into styled terminal output, falling
// back to the raw body if glamour can't render it.
func render(r *release) string {
out, err := glamour.Render(r.Body, "dark")
if err != nil {
return r.Body
}
return fmt.Sprintf("%s\n%s", r.TagName, out)
}
// Print fetches the latest release and writes its notes to stdout. tag may be
// empty for the latest release, or a "vX" tag for a specific one.
func Print(tag string) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
path := "/latest"
if tag != "" {
path = "/tags/" + tag
}
r, err := fetch(ctx, path)
if err != nil {
fmt.Printf("couldn't fetch patch notes: %v\n", err)
return
}
fmt.Print(render(r))
}
// ShowOnce prints the running version's notes the first time that version runs,
// then records it so it isn't shown again. best-effort: dev builds, the
// SIF_NO_PATCHNOTES opt-out, and any network failure stay silent.
func ShowOnce(version string) {
// only clean release tags (e.g. 2026.6.7) map to a github release; skip dev
// and pseudo-versions (a commit/dirty build) so we don't make a doomed call.
if version == "" || version == "dev" || strings.ContainsAny(version, "-+") || os.Getenv("SIF_NO_PATCHNOTES") != "" {
return
}
path, err := statePath()
if err != nil || hasSeen(path, version) {
return
}
// record before fetching so a flaky network doesn't nag on every run
recordSeen(path, version)
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
r, err := fetch(ctx, "/tags/v"+version)
if err != nil {
return
}
fmt.Printf("\nwhat's new in this release:\n%s", render(r))
}
func statePath() (string, error) {
dir, err := os.UserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "sif", "seen_version"), nil
}
func hasSeen(path, version string) bool {
data, err := os.ReadFile(path)
if err != nil {
return false
}
return strings.TrimSpace(string(data)) == version
}
func recordSeen(path, version string) {
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return
}
_ = os.WriteFile(path, []byte(version), 0o600)
}
+42
View File
@@ -0,0 +1,42 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package patchnotes
import (
"path/filepath"
"strings"
"testing"
)
func TestSeenRoundTrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "sif", "seen_version")
if hasSeen(path, "2026.6.7") {
t.Fatal("nothing recorded yet, hasSeen should be false")
}
recordSeen(path, "2026.6.7")
if !hasSeen(path, "2026.6.7") {
t.Error("recorded version should read back as seen")
}
if hasSeen(path, "2026.6.8") {
t.Error("a different version should not be seen")
}
}
func TestRenderIncludesTag(t *testing.T) {
out := render(&release{TagName: "v2026.6.7", Body: "## what's changed\n- a thing"})
if !strings.Contains(out, "v2026.6.7") {
t.Errorf("rendered notes should include the tag, got %q", out)
}
}
+57
View File
@@ -0,0 +1,57 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
// Package pool spreads independent per-item work across a fixed set of workers
// that all pull from one shared channel. that's the point over a static
// modulo-stride partition: a slow or timing-out item only stalls the one worker
// holding it, the rest keep draining the queue instead of idling behind it.
package pool
import "sync"
// Each runs fn for every item in items, concurrently, across at most workers
// goroutines. order isn't preserved - fn must be safe to call from multiple
// goroutines and guard any shared state itself. blocks until every item is done.
func Each[T any](items []T, workers int, fn func(T)) {
if len(items) == 0 {
return
}
// floor at one worker; a non-positive count would otherwise spawn nothing
// and silently drop the work.
if workers < 1 {
workers = 1
}
// never spin more workers than there is work for.
if workers > len(items) {
workers = len(items)
}
queue := make(chan T, len(items))
for i := 0; i < len(items); i++ {
queue <- items[i]
}
close(queue)
var wg sync.WaitGroup
wg.Add(workers)
for i := 0; i < workers; i++ {
go func() {
defer wg.Done()
// pull until the queue is drained; a worker that finishes its
// current item just grabs the next, which is the work-stealing.
for item := range queue {
fn(item)
}
}()
}
wg.Wait()
}
+145
View File
@@ -0,0 +1,145 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package pool
import (
"sync"
"sync/atomic"
"testing"
)
// every item runs exactly once across a spread of sizes and worker counts,
// including the floors (zero/negative workers) and workers > len.
func TestEachProcessesAllExactlyOnce(t *testing.T) {
tests := []struct {
name string
items int
workers int
}{
{"empty", 0, 4},
{"single item", 1, 8},
{"workers floored from zero", 5, 0},
{"workers floored from negative", 5, -3},
{"more workers than items", 3, 16},
{"even split", 100, 4},
{"uneven split", 101, 7},
{"one worker", 50, 1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
items := make([]int, tt.items)
for i := 0; i < tt.items; i++ {
items[i] = i
}
var mu sync.Mutex
seen := make(map[int]int, tt.items)
Each(items, tt.workers, func(v int) {
mu.Lock()
seen[v]++
mu.Unlock()
})
if len(seen) != tt.items {
t.Fatalf("processed %d distinct items, want %d", len(seen), tt.items)
}
for v, n := range seen {
if n != 1 {
t.Errorf("item %d processed %d times, want 1", v, n)
}
}
})
}
}
// no more than `workers` (capped at len(items)) callbacks ever run at once.
func TestEachRespectsWorkerCap(t *testing.T) {
const (
items = 200
workers = 6
)
work := make([]int, items)
var inFlight, peak int64
var release = make(chan struct{})
var started sync.WaitGroup
started.Add(items)
go func() {
Each(work, workers, func(int) {
cur := atomic.AddInt64(&inFlight, 1)
for {
p := atomic.LoadInt64(&peak)
if cur <= p || atomic.CompareAndSwapInt64(&peak, p, cur) {
break
}
}
started.Done()
<-release
atomic.AddInt64(&inFlight, -1)
})
}()
// the cap means at most `workers` callbacks block on release at once, so
// release exactly that many at a time until everything drains.
done := make(chan struct{})
go func() {
for i := 0; i < items; i++ {
release <- struct{}{}
}
close(done)
}()
<-done
if got := atomic.LoadInt64(&peak); got > workers {
t.Fatalf("peak concurrency %d exceeded worker cap %d", got, workers)
}
}
// the cap is min(workers, len(items)): fewer items than workers must not spin
// idle goroutines past the item count.
func TestEachCapsAtItemCount(t *testing.T) {
const (
items = 3
workers = 32
)
work := make([]int, items)
var inFlight, peak int64
var ready sync.WaitGroup
ready.Add(items)
release := make(chan struct{})
go func() {
for i := 0; i < items; i++ {
release <- struct{}{}
}
}()
Each(work, workers, func(int) {
cur := atomic.AddInt64(&inFlight, 1)
for {
p := atomic.LoadInt64(&peak)
if cur <= p || atomic.CompareAndSwapInt64(&peak, p, cur) {
break
}
}
<-release
atomic.AddInt64(&inFlight, -1)
})
if got := atomic.LoadInt64(&peak); got > items {
t.Fatalf("peak concurrency %d exceeded item count %d", got, items)
}
}
+74
View File
@@ -0,0 +1,74 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package report
import (
"bytes"
"encoding/json"
"sort"
"strings"
)
// Markdown renders results as a readable report grouped by target, then by
// module, with each module's finding pretty-printed as a json code block.
func Markdown(results []Result) []byte {
var b strings.Builder
b.WriteString("# sif scan report\n\n")
// group module results under their target so the report reads target-first
// regardless of the order results came in.
byTarget := make(map[string][]Result)
order := make([]string, 0)
for i := 0; i < len(results); i++ {
t := results[i].Target
if _, seen := byTarget[t]; !seen {
order = append(order, t)
}
byTarget[t] = append(byTarget[t], results[i])
}
for i := 0; i < len(order); i++ {
target := order[i]
b.WriteString("## ")
b.WriteString(target)
b.WriteString("\n\n")
mods := byTarget[target]
// sort modules so the report is deterministic across runs
sort.SliceStable(mods, func(a, c int) bool { return mods[a].Module < mods[c].Module })
for j := 0; j < len(mods); j++ {
b.WriteString("### ")
b.WriteString(mods[j].Module)
b.WriteString("\n\n")
b.WriteString("```json\n")
b.WriteString(prettyJSON(mods[j].Data))
b.WriteString("\n```\n\n")
}
}
return []byte(b.String())
}
// prettyJSON re-indents the raw finding for readability; if it doesn't parse as
// json (shouldn't happen, but never trust it) the raw bytes are returned as-is.
func prettyJSON(raw json.RawMessage) string {
if len(raw) == 0 {
return "null"
}
var indented bytes.Buffer
if err := json.Indent(&indented, raw, "", " "); err != nil {
return string(raw)
}
return indented.String()
}
+26
View File
@@ -0,0 +1,26 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
// Package report serializes collected scan results to sarif and markdown. it's
// deliberately decoupled from the scan package: callers map their own results
// into report.Result, so report never imports a scanner type.
package report
import "encoding/json"
// Result is one module's output for one target. Data is whatever the scanner
// returned, carried as raw json so report stays free of scan types.
type Result struct {
Target string
Module string
Data json.RawMessage
}
+172
View File
@@ -0,0 +1,172 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package report
import (
"encoding/json"
"strings"
"testing"
)
// fakeResults are a couple of representative findings across two targets used by
// every test below.
func fakeResults() []Result {
return []Result{
{Target: "https://a.example.com", Module: "cors", Data: json.RawMessage(`{"severity":"high"}`)},
{Target: "https://a.example.com", Module: "probe", Data: json.RawMessage(`{"status_code":200}`)},
{Target: "https://b.example.com", Module: "redirect", Data: json.RawMessage(`{"parameter":"next"}`)},
}
}
func TestSARIF_ValidAndContainsFindings(t *testing.T) {
out, err := SARIF(fakeResults())
if err != nil {
t.Fatalf("SARIF: %v", err)
}
// the output must parse back into the sarif shape
var doc sarifLog
if err := json.Unmarshal(out, &doc); err != nil {
t.Fatalf("sarif output is not valid json: %v", err)
}
if doc.Version != "2.1.0" {
t.Errorf("expected sarif version 2.1.0, got %q", doc.Version)
}
if len(doc.Runs) != 1 {
t.Fatalf("expected exactly one run, got %d", len(doc.Runs))
}
run := doc.Runs[0]
if run.Tool.Driver.Name != "sif" {
t.Errorf("expected tool name sif, got %q", run.Tool.Driver.Name)
}
if len(run.Results) != 3 {
t.Fatalf("expected 3 results, got %d", len(run.Results))
}
// each finding's module id surfaces as the ruleId and its target as the uri
tests := []struct {
ruleID string
target string
}{
{"cors", "https://a.example.com"},
{"probe", "https://a.example.com"},
{"redirect", "https://b.example.com"},
}
for _, tt := range tests {
if !sarifHasResult(run.Results, tt.ruleID, tt.target) {
t.Errorf("expected sarif result rule=%q target=%q, got %+v", tt.ruleID, tt.target, run.Results)
}
}
// rules list each module id once, deduped across targets
if len(run.Tool.Driver.Rules) != 3 {
t.Errorf("expected 3 deduped rules, got %d: %+v", len(run.Tool.Driver.Rules), run.Tool.Driver.Rules)
}
}
func TestSARIF_DedupesRulesAcrossTargets(t *testing.T) {
// the same module on two targets must yield one rule but two results.
results := []Result{
{Target: "https://a.example.com", Module: "cors", Data: json.RawMessage(`{}`)},
{Target: "https://b.example.com", Module: "cors", Data: json.RawMessage(`{}`)},
}
out, err := SARIF(results)
if err != nil {
t.Fatalf("SARIF: %v", err)
}
var doc sarifLog
if err := json.Unmarshal(out, &doc); err != nil {
t.Fatalf("invalid json: %v", err)
}
run := doc.Runs[0]
if len(run.Tool.Driver.Rules) != 1 {
t.Errorf("expected 1 deduped rule, got %d", len(run.Tool.Driver.Rules))
}
if len(run.Results) != 2 {
t.Errorf("expected 2 results, got %d", len(run.Results))
}
}
func TestSARIF_Empty(t *testing.T) {
out, err := SARIF(nil)
if err != nil {
t.Fatalf("SARIF: %v", err)
}
var doc sarifLog
if err := json.Unmarshal(out, &doc); err != nil {
t.Fatalf("empty sarif is not valid json: %v", err)
}
if len(doc.Runs) != 1 {
t.Fatalf("expected one run even when empty, got %d", len(doc.Runs))
}
if len(doc.Runs[0].Results) != 0 {
t.Errorf("expected no results, got %d", len(doc.Runs[0].Results))
}
}
func TestMarkdown_ContainsTargetsAndModules(t *testing.T) {
out := string(Markdown(fakeResults()))
wants := []string{
"# sif scan report",
"## https://a.example.com",
"## https://b.example.com",
"### cors",
"### probe",
"### redirect",
`"severity": "high"`, // re-indented finding body
`"parameter": "next"`,
}
for _, want := range wants {
if !strings.Contains(out, want) {
t.Errorf("markdown report missing %q\n---\n%s", want, out)
}
}
}
func TestMarkdown_GroupsByTarget(t *testing.T) {
// a.example.com's two modules must both appear before b.example.com's header.
out := string(Markdown(fakeResults()))
aHeader := strings.Index(out, "## https://a.example.com")
bHeader := strings.Index(out, "## https://b.example.com")
if aHeader < 0 || bHeader < 0 {
t.Fatalf("missing target headers in:\n%s", out)
}
if aHeader > bHeader {
t.Errorf("expected target a before target b, got a=%d b=%d", aHeader, bHeader)
}
// both of a's modules sit between a's header and b's header
corsIdx := strings.Index(out, "### cors")
probeIdx := strings.Index(out, "### probe")
if corsIdx < aHeader || corsIdx > bHeader || probeIdx < aHeader || probeIdx > bHeader {
t.Errorf("expected a's modules grouped under a, cors=%d probe=%d (a=%d b=%d)", corsIdx, probeIdx, aHeader, bHeader)
}
}
// sarifHasResult reports whether any result carries the given rule id and target
// uri, the pairing that proves a finding survived serialization.
func sarifHasResult(results []sarifResult, ruleID, target string) bool {
for i := 0; i < len(results); i++ {
r := results[i]
if r.RuleID != ruleID {
continue
}
for j := 0; j < len(r.Locations); j++ {
if r.Locations[j].PhysicalLocation.ArtifactLocation.URI == target {
return true
}
}
}
return false
}
+133
View File
@@ -0,0 +1,133 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package report
import (
"encoding/json"
"fmt"
)
// sarif format/version constants pinned to the 2.1.0 schema so the output is
// ingestable by github code scanning and other sarif consumers.
const (
sarifVersion = "2.1.0"
sarifSchema = "https://json.schemastore.org/sarif-2.1.0.json"
toolName = "sif"
)
// sarifLog is the minimal valid 2.1.0 shape: one run from one tool.
type sarifLog struct {
Schema string `json:"$schema"`
Version string `json:"version"`
Runs []sarifRun `json:"runs"`
}
type sarifRun struct {
Tool sarifTool `json:"tool"`
Results []sarifResult `json:"results"`
}
type sarifTool struct {
Driver sarifDriver `json:"driver"`
}
type sarifDriver struct {
Name string `json:"name"`
Rules []sarifRule `json:"rules"`
}
type sarifRule struct {
ID string `json:"id"`
}
type sarifResult struct {
RuleID string `json:"ruleId"`
Level string `json:"level"`
Message sarifMessage `json:"message"`
Locations []sarifLocation `json:"locations"`
}
type sarifMessage struct {
Text string `json:"text"`
}
type sarifLocation struct {
PhysicalLocation sarifPhysicalLocation `json:"physicalLocation"`
}
type sarifPhysicalLocation struct {
ArtifactLocation sarifArtifactLocation `json:"artifactLocation"`
}
type sarifArtifactLocation struct {
URI string `json:"uri"`
}
// sarifLevel is the default severity for findings; sif results don't carry a
// uniform severity field, so "warning" is the neutral middle ground.
const sarifLevel = "warning"
// SARIF serializes results to a minimal valid sarif 2.1.0 log. Each module
// result becomes one sarif result tagged with its module id (the rule) and the
// target uri, with the raw module data inlined into the message for context.
func SARIF(results []Result) ([]byte, error) {
sarifResults := make([]sarifResult, 0, len(results))
ruleSet := make(map[string]struct{}, len(results))
for i := 0; i < len(results); i++ {
res := results[i]
ruleSet[res.Module] = struct{}{}
sarifResults = append(sarifResults, sarifResult{
RuleID: res.Module,
Level: sarifLevel,
Message: sarifMessage{Text: messageFor(res)},
Locations: []sarifLocation{{
PhysicalLocation: sarifPhysicalLocation{
ArtifactLocation: sarifArtifactLocation{URI: res.Target},
},
}},
})
}
// rules must list each id exactly once; build it from the set so duplicate
// modules across targets don't duplicate the rule.
rules := make([]sarifRule, 0, len(ruleSet))
for id := range ruleSet {
rules = append(rules, sarifRule{ID: id})
}
doc := sarifLog{
Schema: sarifSchema,
Version: sarifVersion,
Runs: []sarifRun{{
Tool: sarifTool{Driver: sarifDriver{Name: toolName, Rules: rules}},
Results: sarifResults,
}},
}
out, err := json.MarshalIndent(doc, "", " ")
if err != nil {
return nil, fmt.Errorf("marshal sarif: %w", err)
}
return out, nil
}
// messageFor builds a human-readable result message: the module id plus the raw
// finding json so a sarif viewer shows what was actually found.
func messageFor(res Result) string {
if len(res.Data) == 0 {
return fmt.Sprintf("%s finding on %s", res.Module, res.Target)
}
return fmt.Sprintf("%s finding on %s: %s", res.Module, res.Target, string(res.Data))
}
@@ -0,0 +1,95 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package builtin
import (
"context"
"fmt"
"strings"
"github.com/dropalldatabases/sif/internal/modules"
"github.com/dropalldatabases/sif/internal/scan/frameworks"
)
type FrameworksModule struct{}
func (m *FrameworksModule) Info() modules.Info {
return modules.Info{
ID: "framework-detection",
Name: "Web Framework Detection",
Author: "sif",
Severity: "info",
Description: "Detects web frameworks with version and CVE mapping",
Tags: []string{"recon", "framework", "cve"},
}
}
func (m *FrameworksModule) Type() modules.ModuleType {
return modules.TypeHTTP
}
func (m *FrameworksModule) Execute(ctx context.Context, target string, opts modules.Options) (*modules.Result, error) {
// Call existing legacy frameworks.DetectFramework function
frameworkResult, err := frameworks.DetectFramework(target, opts.Timeout, opts.LogDir)
if err != nil {
return nil, err
}
result := &modules.Result{
ModuleID: m.Info().ID,
Target: target,
Findings: []modules.Finding{},
}
// Return empty if no framework detected
if frameworkResult == nil {
return result, nil
}
// Construct finding
evidence := fmt.Sprintf("Detected %s framework (version: %s, confidence: %.2f)",
frameworkResult.Name, frameworkResult.Version, frameworkResult.Confidence)
severity := "info"
if frameworkResult.RiskLevel != "" && frameworkResult.RiskLevel != "low" {
severity = frameworkResult.RiskLevel
}
finding := modules.Finding{
URL: target,
Severity: severity,
Evidence: evidence,
Extracted: map[string]string{
"framework": frameworkResult.Name,
"version": frameworkResult.Version,
"confidence": fmt.Sprintf("%.2f", frameworkResult.Confidence),
"version_confidence": fmt.Sprintf("%.2f", frameworkResult.VersionConfidence),
},
}
// Add CVE information
if len(frameworkResult.CVEs) > 0 {
finding.Extracted["cves"] = strings.Join(frameworkResult.CVEs, ", ")
finding.Extracted["risk_level"] = frameworkResult.RiskLevel
}
// Add recommendations
if len(frameworkResult.Suggestions) > 0 {
finding.Extracted["recommendations"] = strings.Join(frameworkResult.Suggestions, "; ")
}
result.Findings = append(result.Findings, finding)
return result, nil
}
+143
View File
@@ -0,0 +1,143 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package builtin
import (
"context"
"fmt"
"github.com/dropalldatabases/sif/internal/modules"
"github.com/dropalldatabases/sif/internal/scan"
)
type NucleiModule struct{}
func (m *NucleiModule) Info() modules.Info {
return modules.Info{
ID: "nuclei-scan",
Name: "Nuclei Vulnerability Scanner",
Author: "sif",
Severity: "high",
Description: "Runs Nuclei vulnerability scanning templates against target",
Tags: []string{"vuln", "nuclei", "cve"},
}
}
func (m *NucleiModule) Type() modules.ModuleType {
return modules.TypeScript
}
func (m *NucleiModule) Execute(ctx context.Context, target string, opts modules.Options) (*modules.Result, error) {
// Call existing legacy scan.Nuclei function
nucleiResults, err := scan.Nuclei(target, opts.Timeout, opts.Threads, opts.LogDir)
if err != nil {
return nil, err
}
result := &modules.Result{
ModuleID: m.Info().ID,
Target: target,
Findings: make([]modules.Finding, 0, len(nucleiResults)),
}
// Process nuclei results into module findings
for i := range nucleiResults {
event := &nucleiResults[i]
severity := "info"
switch event.Info.SeverityHolder.Severity.String() {
case "critical":
severity = "critical"
case "high":
severity = "high"
case "medium":
severity = "medium"
case "low":
severity = "low"
}
evidence := fmt.Sprintf("[%s] %s", event.TemplateID, event.Info.Name)
if event.Matched != "" {
evidence = fmt.Sprintf("[%s] %s - matched: %s", event.TemplateID, event.Info.Name, event.Matched)
}
finding := modules.Finding{
URL: event.Host,
Severity: severity,
Evidence: evidence,
Extracted: map[string]string{
"template_id": event.TemplateID,
"template_name": event.Info.Name,
"severity": event.Info.SeverityHolder.Severity.String(),
},
}
// Template info
if event.Type != "" {
finding.Extracted["type"] = event.Type
}
// Matcher name
if event.MatcherName != "" {
finding.Extracted["matcher_name"] = event.MatcherName
}
// Extractor name
if event.ExtractorName != "" {
finding.Extracted["extractor_name"] = event.ExtractorName
}
// Matched line/data
if event.Matched != "" {
finding.Extracted["matched"] = event.Matched
}
// Metadata
if len(event.Info.Metadata) > 0 {
for key, value := range event.Info.Metadata {
finding.Extracted[fmt.Sprintf("metadata_%s", key)] = fmt.Sprintf("%v", value)
}
}
// Tags
if !event.Info.Tags.IsEmpty() {
tagStr := ""
for _, tag := range event.Info.Tags.ToSlice() {
if tagStr != "" {
tagStr += ", "
}
tagStr += tag
}
finding.Extracted["tags"] = tagStr
}
// Reference
if event.Info.Reference != nil && !event.Info.Reference.IsEmpty() {
refStr := ""
for _, ref := range event.Info.Reference.ToSlice() {
if refStr != "" {
refStr += "; "
}
refStr += ref
}
finding.Extracted["references"] = refStr
}
result.Findings = append(result.Findings, finding)
}
return result, nil
}
+25
View File
@@ -0,0 +1,25 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package builtin
import "github.com/dropalldatabases/sif/internal/modules"
// Register registers all Go-based built-in scans as modules.
// Allows complex Go scans to participate in the module system
func Register() {
modules.Register(&ShodanModule{})
modules.Register(&FrameworksModule{})
modules.Register(&NucleiModule{})
modules.Register(&WhoisModule{})
modules.Register(&SecurityTrailsModule{})
}
@@ -0,0 +1,79 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package builtin
import (
"context"
"fmt"
"strings"
"github.com/dropalldatabases/sif/internal/modules"
"github.com/dropalldatabases/sif/internal/scan"
)
type SecurityTrailsModule struct{}
func (m *SecurityTrailsModule) Info() modules.Info {
return modules.Info{
ID: "securitytrails-lookup",
Name: "SecurityTrails Domain Discovery",
Author: "sif",
Severity: "info",
Description: "Queries SecurityTrails API for subdomains and associated domains (requires SECURITYTRAILS_API_KEY)",
Tags: []string{"recon", "osint", "dns", "subdomains"},
}
}
func (m *SecurityTrailsModule) Type() modules.ModuleType {
return modules.TypeScript
}
func (m *SecurityTrailsModule) Execute(ctx context.Context, target string, opts modules.Options) (*modules.Result, error) {
stResult, err := scan.SecurityTrails(target, opts.Timeout, opts.LogDir)
if err != nil {
return nil, err
}
result := &modules.Result{
ModuleID: m.Info().ID,
Target: target,
Findings: []modules.Finding{},
}
if stResult == nil {
return result, nil
}
finding := modules.Finding{
URL: target,
Severity: "info",
Evidence: fmt.Sprintf("discovered %d subdomains and %d associated domains",
len(stResult.Subdomains), len(stResult.AssociatedDomains)),
Extracted: map[string]string{
"domain": stResult.Domain,
"subdomain_count": fmt.Sprintf("%d", len(stResult.Subdomains)),
"associated_count": fmt.Sprintf("%d", len(stResult.AssociatedDomains)),
},
}
if len(stResult.Subdomains) > 0 {
finding.Extracted["subdomains"] = strings.Join(stResult.Subdomains, ", ")
}
if len(stResult.AssociatedDomains) > 0 {
finding.Extracted["associated_domains"] = strings.Join(stResult.AssociatedDomains, ", ")
}
result.Findings = append(result.Findings, finding)
return result, nil
}
+158
View File
@@ -0,0 +1,158 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package builtin
import (
"context"
"fmt"
"strings"
"github.com/dropalldatabases/sif/internal/modules"
"github.com/dropalldatabases/sif/internal/scan"
)
type ShodanModule struct{}
func (m *ShodanModule) Info() modules.Info {
return modules.Info{
ID: "shodan-lookup",
Name: "Shodan Host Intelligence",
Author: "sif",
Severity: "info",
Description: "Queries Shodan API for host information, open ports, and vulnerabilities (requires SHODAN_API_KEY)",
Tags: []string{"recon", "osint", "shodan", "vulns"},
}
}
func (m *ShodanModule) Type() modules.ModuleType {
return modules.TypeScript
}
func (m *ShodanModule) Execute(ctx context.Context, target string, opts modules.Options) (*modules.Result, error) {
// Call existing legacy scan.Shodan function
shodanResult, err := scan.Shodan(target, opts.Timeout, opts.LogDir)
if err != nil {
return nil, err
}
result := &modules.Result{
ModuleID: m.Info().ID,
Target: target,
Findings: []modules.Finding{},
}
// If nothing returned, return empty result
if shodanResult == nil || shodanResult.IP == "" {
return result, nil
}
// Create main finding
evidence := fmt.Sprintf("Shodan data found for %s", shodanResult.IP)
severity := "info"
if len(shodanResult.Vulns) > 0 {
severity = "high"
evidence = fmt.Sprintf("Host %s has %d known vulnerabilities", shodanResult.IP, len(shodanResult.Vulns))
}
finding := modules.Finding{
URL: target,
Severity: severity,
Evidence: evidence,
Extracted: map[string]string{
"ip": shodanResult.IP,
},
}
// Add hostnames
if len(shodanResult.Hostnames) > 0 {
finding.Extracted["hostnames"] = strings.Join(shodanResult.Hostnames, ", ")
}
// Add organization info
if shodanResult.Organization != "" {
finding.Extracted["organization"] = shodanResult.Organization
}
// Add ISP info
if shodanResult.ISP != "" {
finding.Extracted["isp"] = shodanResult.ISP
}
// Add ASN
if shodanResult.ASN != "" {
finding.Extracted["asn"] = shodanResult.ASN
}
// Add location
if shodanResult.Country != "" {
location := shodanResult.Country
if shodanResult.City != "" {
location = shodanResult.City + ", " + shodanResult.Country
}
finding.Extracted["location"] = location
}
// Add OS
if shodanResult.OS != "" {
finding.Extracted["os"] = shodanResult.OS
}
// Add open ports
if len(shodanResult.Ports) > 0 {
portStrs := make([]string, len(shodanResult.Ports))
for i, port := range shodanResult.Ports {
portStrs[i] = fmt.Sprintf("%d", port)
}
finding.Extracted["open_ports"] = strings.Join(portStrs, ", ")
finding.Extracted["port_count"] = fmt.Sprintf("%d", len(shodanResult.Ports))
}
// Add vulnerabilities
if len(shodanResult.Vulns) > 0 {
finding.Extracted["vulnerabilities"] = strings.Join(shodanResult.Vulns, ", ")
finding.Extracted["vuln_count"] = fmt.Sprintf("%d", len(shodanResult.Vulns))
}
// Add last update
if shodanResult.LastUpdate != "" {
finding.Extracted["last_update"] = shodanResult.LastUpdate
}
// Add service count
if len(shodanResult.Services) > 0 {
finding.Extracted["service_count"] = fmt.Sprintf("%d", len(shodanResult.Services))
// Add service details
serviceDetails := make([]string, 0, len(shodanResult.Services))
for _, service := range shodanResult.Services {
detail := fmt.Sprintf("%d/%s", service.Port, service.Protocol)
if service.Product != "" {
detail += " " + service.Product
if service.Version != "" {
detail += " " + service.Version
}
}
serviceDetails = append(serviceDetails, detail)
}
finding.Extracted["services"] = strings.Join(serviceDetails, "; ")
}
result.Findings = append(result.Findings, finding)
return result, nil
}
+57
View File
@@ -0,0 +1,57 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package builtin
import (
"context"
"github.com/dropalldatabases/sif/internal/modules"
"github.com/dropalldatabases/sif/internal/scan"
)
type WhoisModule struct{}
func (m *WhoisModule) Info() modules.Info {
return modules.Info{
ID: "whois-lookup",
Name: "WHOIS Domain Information",
Author: "sif",
Severity: "info",
Description: "Performs WHOIS lookup for domain registration information",
Tags: []string{"recon", "whois", "osint"},
}
}
func (m *WhoisModule) Type() modules.ModuleType {
return modules.TypeScript
}
func (m *WhoisModule) Execute(ctx context.Context, target string, opts modules.Options) (*modules.Result, error) {
// Call existing legacy scan.Whois function
scan.Whois(target, opts.LogDir)
// Return that scan was executed, since no data is returned from scan.Whois
result := &modules.Result{
ModuleID: m.Info().ID,
Target: target,
Findings: []modules.Finding{
{
URL: target,
Severity: "info",
Evidence: "WHOIS lookup completed",
},
},
}
return result, nil
}
+116
View File
@@ -0,0 +1,116 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package scan
import (
"context"
"fmt"
"net/http"
"os"
"strings"
"time"
"github.com/charmbracelet/log"
"github.com/dropalldatabases/sif/internal/httpx"
"github.com/dropalldatabases/sif/internal/logger"
"github.com/dropalldatabases/sif/internal/styles"
)
// s3EndpointFmt is a var so integration tests can repoint it at a fixture; the
// %s is the bucket name.
var s3EndpointFmt = "https://%s.s3.amazonaws.com"
type CloudStorageResult struct {
BucketName string `json:"bucket_name"`
IsPublic bool `json:"is_public"`
}
func CloudStorage(url string, timeout time.Duration, logdir string) ([]CloudStorageResult, error) {
fmt.Println(styles.Separator.Render("Starting " + styles.Status.Render("Cloud Storage Misconfiguration Scan") + "..."))
sanitizedURL := stripScheme(url)
if logdir != "" {
if err := logger.WriteHeader(sanitizedURL, logdir, "Cloud Storage Misconfiguration Scan"); err != nil {
log.Errorf("Error creating log file: %v", err)
return nil, err
}
}
cloudlog := log.NewWithOptions(os.Stderr, log.Options{
Prefix: "C3",
}).With("url", url)
client := httpx.Client(timeout)
potentialBuckets := extractPotentialBuckets(sanitizedURL)
var results []CloudStorageResult
for _, bucket := range potentialBuckets {
isPublic, err := checkS3Bucket(context.TODO(), bucket, client)
if err != nil {
cloudlog.Errorf("Error checking S3 bucket %s: %v", bucket, err)
continue
}
result := CloudStorageResult{
BucketName: bucket,
IsPublic: isPublic,
}
results = append(results, result)
if isPublic {
cloudlog.Warnf("Public S3 bucket found: %s", styles.Highlight.Render(bucket))
if logdir != "" {
_ = logger.Write(sanitizedURL, logdir, fmt.Sprintf("Public S3 bucket found: %s\n", bucket))
}
} else {
cloudlog.Infof("S3 bucket is not public/found: %s", bucket)
}
}
return results, nil
}
func extractPotentialBuckets(url string) []string {
// TODO: handle non-adjacent label combos and strip the tld
parts := strings.Split(url, ".")
var buckets []string
for i, part := range parts {
buckets = append(buckets, part, part+"-s3", "s3-"+part)
if i < len(parts)-1 {
domainExtension := part + "-" + parts[i+1]
buckets = append(buckets, domainExtension, parts[i+1]+"-"+part)
}
}
return buckets
}
func checkS3Bucket(ctx context.Context, bucket string, client *http.Client) (bool, error) {
url := fmt.Sprintf(s3EndpointFmt, bucket)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
if err != nil {
return false, err
}
resp, err := client.Do(req) //nolint:bodyclose // drained and closed via httpx.DrainClose
if err != nil {
return false, err
}
// status only; drain on close so the conn returns to the pool.
defer httpx.DrainClose(resp)
// If we can access the bucket listing, it's public
return resp.StatusCode == http.StatusOK, nil
}
+143
View File
@@ -0,0 +1,143 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package scan
import (
"context"
"io"
"net/http"
"strings"
"time"
"github.com/dropalldatabases/sif/internal/httpx"
"github.com/dropalldatabases/sif/internal/logger"
"github.com/dropalldatabases/sif/internal/output"
)
type CMSResult struct {
Name string `json:"name"`
Version string `json:"version"`
}
func CMS(url string, timeout time.Duration, logdir string) (*CMSResult, error) {
log := output.Module("CMS")
log.Start()
spin := output.NewSpinner("Detecting content management system")
spin.Start()
sanitizedURL := stripScheme(url)
if logdir != "" {
if err := logger.WriteHeader(sanitizedURL, logdir, "CMS detection"); err != nil {
spin.Stop()
log.Error("Error creating log file: %v", err)
return nil, err
}
}
client := httpx.Client(timeout)
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, http.NoBody)
if err != nil {
spin.Stop()
return nil, err
}
resp, err := client.Do(req)
if err != nil {
spin.Stop()
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, 5*1024*1024))
if err != nil {
spin.Stop()
return nil, err
}
bodyString := string(body)
// WordPress
if detectWordPress(url, client, bodyString) {
spin.Stop()
result := &CMSResult{Name: "WordPress", Version: "Unknown"}
log.Success("Detected CMS: %s", output.Highlight.Render(result.Name))
log.Complete(1, "detected")
return result, nil
}
// Drupal
if strings.Contains(resp.Header.Get("X-Drupal-Cache"), "HIT") || strings.Contains(bodyString, "Drupal.settings") {
spin.Stop()
result := &CMSResult{Name: "Drupal", Version: "Unknown"}
log.Success("Detected CMS: %s", output.Highlight.Render(result.Name))
log.Complete(1, "detected")
return result, nil
}
// Joomla
if strings.Contains(bodyString, "joomla") || strings.Contains(bodyString, "/media/system/js/core.js") {
spin.Stop()
result := &CMSResult{Name: "Joomla", Version: "Unknown"}
log.Success("Detected CMS: %s", output.Highlight.Render(result.Name))
log.Complete(1, "detected")
return result, nil
}
spin.Stop()
log.Info("No CMS detected")
log.Complete(0, "detected")
return nil, nil //nolint:nilnil // no CMS found is not an error
}
func detectWordPress(url string, client *http.Client, bodyString string) bool {
// Check for common WordPress indicators in the HTML
wpIndicators := []string{
"wp-content",
"wp-includes",
"wp-json",
"wordpress",
}
for _, indicator := range wpIndicators {
if strings.Contains(bodyString, indicator) {
return true
}
}
// Check for WordPress-specific files
wpFiles := []string{
"/wp-login.php",
"/wp-admin/",
"/wp-config.php",
}
for _, file := range wpFiles {
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url+file, http.NoBody)
if err != nil {
continue
}
resp, err := client.Do(req) //nolint:bodyclose // drained and closed via httpx.DrainClose
if err == nil {
found := resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusFound
// status only; drain so the conn returns to the pool.
httpx.DrainClose(resp)
if found {
return true
}
}
}
return false
}
+236
View File
@@ -0,0 +1,236 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package scan
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"time"
charmlog "github.com/charmbracelet/log"
"github.com/dropalldatabases/sif/internal/httpx"
"github.com/dropalldatabases/sif/internal/logger"
"github.com/dropalldatabases/sif/internal/output"
)
// CORSResult collects every cors misconfiguration found on the target.
type CORSResult struct {
Findings []CORSFinding `json:"findings,omitempty"`
}
// CORSFinding is a single reflecting/permissive cors response.
type CORSFinding struct {
URL string `json:"url"`
OriginTested string `json:"origin_tested"`
AllowOrigin string `json:"allow_origin"`
AllowCredentials bool `json:"allow_credentials"`
Severity string `json:"severity"`
Note string `json:"note"`
}
// corsMaxRedirects caps the redirect chain so we read the cors headers off the
// host we actually asked about, not whatever it bounces us to.
const corsMaxRedirects = 3
// the sentinel attacker origin; if it comes back in Access-Control-Allow-Origin
// the target reflects arbitrary origins and any site can read the response.
const corsEvilOrigin = "https://sif-cors-probe.evil.com"
// corsOrigin is a header to inject + why it matters. {host} expands to the
// target host so the prefix/suffix bypasses key off the real name.
var corsOrigins = []struct {
origin string // crafted Origin header, {host} -> target host
note string // why this case is interesting
reflects bool // true when a literal echo of this origin is exploitable
}{
// arbitrary attacker origin - the classic "reflects anything" bug
{corsEvilOrigin, "arbitrary origin reflected", true},
// the literal null origin (sandboxed iframes, redirects, file://) is forgeable
{"null", "null origin allowed", true},
// suffix bypass: attacker registers {host}.evil.com, naive endswith checks pass
{"https://{host}.evil.com", "suffix bypass (attacker subdomain)", true},
// prefix bypass: attacker registers evil-{host}, naive startswith checks pass
{"https://evil-{host}", "prefix bypass", true},
// embedded bypass: {host} appears inside an attacker domain
{"https://evil.com.{host}", "embedded-host bypass", true},
// scheme downgrade: http origin trusted lets a mitm read cross-origin data
{"http://{host}", "http scheme downgrade trusted", true},
}
// CORS probes the target for cross-origin resource sharing misconfigurations.
func CORS(targetURL string, timeout time.Duration, threads int, logdir string) (*CORSResult, error) {
log := output.Module("CORS")
log.Start()
spin := output.NewSpinner("Scanning for CORS misconfigurations")
spin.Start()
sanitizedURL := stripScheme(targetURL)
if logdir != "" {
if err := logger.WriteHeader(sanitizedURL, logdir, "CORS misconfiguration probe"); err != nil {
spin.Stop()
log.Error("error creating log file: %v", err)
return nil, fmt.Errorf("create cors log: %w", err)
}
}
parsedURL, err := url.Parse(targetURL)
if err != nil {
spin.Stop()
return nil, fmt.Errorf("parse url: %w", err)
}
host := parsedURL.Host
client := httpx.Client(timeout)
client.CheckRedirect = func(_ *http.Request, via []*http.Request) error {
if len(via) >= corsMaxRedirects {
return http.ErrUseLastResponse
}
return nil
}
result := &CORSResult{Findings: make([]CORSFinding, 0, len(corsOrigins))}
var mu sync.Mutex
var wg sync.WaitGroup
// one origin per worker item; the set is small so a buffered channel is plenty
originChan := make(chan int, len(corsOrigins))
for i := 0; i < len(corsOrigins); i++ {
originChan <- i
}
close(originChan)
wg.Add(threads)
for t := 0; t < threads; t++ {
go func() {
defer wg.Done()
for idx := range originChan {
spec := corsOrigins[idx]
// {host} is the seam that turns a template into a real attacker origin
origin := strings.ReplaceAll(spec.origin, "{host}", host)
finding, ok := probeCORS(client, targetURL, origin, spec.note)
if !ok {
continue
}
mu.Lock()
result.Findings = append(result.Findings, finding)
mu.Unlock()
spin.Stop()
log.Warn("cors %s: origin %s reflected (creds=%t)",
renderCORSSeverity(finding.Severity),
output.Highlight.Render(origin),
finding.AllowCredentials)
spin.Start()
if logdir != "" {
logger.Write(sanitizedURL, logdir,
fmt.Sprintf("CORS: %s - origin [%s] reflected as [%s] creds=%t\n",
finding.Note, origin, finding.AllowOrigin, finding.AllowCredentials))
}
}
}()
}
wg.Wait()
spin.Stop()
if len(result.Findings) == 0 {
log.Info("no cors misconfigurations detected")
log.Complete(0, "found")
return nil, nil //nolint:nilnil // no finding is not an error, mirrors the other scanners
}
log.Complete(len(result.Findings), "found")
return result, nil
}
// probeCORS sends one request with the crafted Origin and decides whether the
// response trusts it. It returns the finding and true only when the server
// reflects the origin (or "null"/"*" with credentials), which is the exploitable
// shape - a server that ignores Origin or returns its own host is fine.
func probeCORS(client *http.Client, targetURL, origin, note string) (CORSFinding, bool) {
req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, targetURL, http.NoBody)
if err != nil {
charmlog.Debugf("cors: build request for %s: %v", targetURL, err)
return CORSFinding{}, false
}
req.Header.Set("Origin", origin)
resp, err := client.Do(req) //nolint:bodyclose // drained and closed via httpx.DrainClose
if err != nil {
charmlog.Debugf("cors: request %s with origin %s: %v", targetURL, origin, err)
return CORSFinding{}, false
}
// headers are all we need; drain the body so the conn returns to the pool.
httpx.DrainClose(resp)
allowOrigin := resp.Header.Get("Access-Control-Allow-Origin")
if allowOrigin == "" {
return CORSFinding{}, false
}
allowCreds := strings.EqualFold(resp.Header.Get("Access-Control-Allow-Credentials"), "true")
// a wildcard with credentials is forbidden by browsers, so it isn't directly
// exploitable; a plain wildcard exposes only public data. neither is a finding.
if allowOrigin == "*" {
return CORSFinding{}, false
}
// the bug is reflection: the server echoed our attacker origin back. if it
// returned something else (its own host) it isn't trusting us.
reflected := allowOrigin == origin
if !reflected {
return CORSFinding{}, false
}
return CORSFinding{
URL: targetURL,
OriginTested: origin,
AllowOrigin: allowOrigin,
AllowCredentials: allowCreds,
Severity: corsSeverity(allowCreds),
Note: note,
}, true
}
// corsSeverity ranks the finding: reflection + credentials lets an attacker read
// authenticated responses, which is the high-impact case.
func corsSeverity(allowCreds bool) string {
if allowCreds {
return "high"
}
return "medium"
}
func renderCORSSeverity(severity string) string {
if severity == "high" {
return output.SeverityHigh.Render(severity)
}
return output.SeverityMedium.Render(severity)
}
// ResultType identifies cors findings for the result registry.
func (r *CORSResult) ResultType() string { return "cors" }
var _ ScanResult = (*CORSResult)(nil)

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