Compare commits

..

94 Commits

Author SHA1 Message Date
vmfunc 60b2dd2804 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 73af6bc868 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 88fb01d70a Merge branch 'main' into feat/builtin-frameworks 2026-01-12 11:22:56 +01:00
vmfunc 8c4d6d81a7 fix: rename to snakecase 2026-01-12 11:19:54 +01:00
vmfunc b6ad6f7a3e 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 26ea7c0ee6 fix(conflicts): fix PR conflicts on 2026-01-12 11:19:48 +01:00
vmfunc 976a2d4390 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 c62409bf0a fix: rename to snakecase 2026-01-07 22:39:56 +01:00
vmfunc 280e6ad8b0 fix: rename to snakecase 2026-01-07 22:39:19 +01:00
vmfunc 2ee2283412 fix: frameworks module file rename
Renamed frameworks module file to differentiate from legacy framework
scans.
2026-01-07 22:34:53 +01:00
vmfunc 748f320e59 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 ad9a98b132 fix: colorizer exception
Fixed Nuclei giving off exception for missing Colorizer on the executor
options.
2026-01-07 19:06:51 +01:00
vmfunc 6d6a57a0e0 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 53687e4bd4 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 4331929bd0 feat(modules): legacy nuclei scan
Converted nuclei scan to be able to run as module.
2026-01-07 13:07:35 +01:00
vmfunc 9290bbe6a0 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 0958a2c19c feat(modules): infra for builtin modules
Infrastructure preparation for builtin complex Go module registration.
2026-01-07 12:56:17 +01:00
vmfunc 4e97da6863 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 e5c88b754c fix: adjust generator meta weight
Adjusted generator meta weight to remain consistent with other meta-framework detectors.

Co-authored-by: Celeste Hickenlooper <celeste@router.sex>
2026-01-06 14:45:03 +01:00
vmfunc 2389901614 Merge pull request #55 from 0x4bs3nt/docs/contributing-update
docs: update CONTRIBUTING.md
2026-01-05 23:50:02 -08:00
vmfunc 1f73a0dd8f fix: discord invite
Fixed discord invite to official server invite url.
2026-01-06 06:35:32 +01:00
vmfunc 4de9786e99 fix: use dynamic versioning for debian packages 2026-01-05 20:55:30 -08:00
vmfunc e5510a4a16 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 8928ac7e99 docs: add apt/cloudsmith installation instructions and badge 2026-01-05 20:28:30 -08:00
vmfunc 137ba0c89a ci: push debian packages to cloudsmith 2026-01-05 20:28:07 -08:00
vmfunc 9ea0805090 ci: add debian package builds to releases 2026-01-05 20:13:18 -08:00
vmfunc df6bfc91f0 docs: add 0xatrilla to contributors for AUR packaging 2026-01-05 19:51:50 -08:00
vmfunc 255b67dff6 docs: add AUR and Homebrew badges to readme 2026-01-05 19:48:51 -08:00
vmfunc 9c3a5fe1f0 Merge pull request #53 from 0xatrilla/add-aur-install-instructions
docs: add AUR installation instructions
2026-01-05 19:44:43 -08:00
vmfunc df274328ee 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 487b440e52 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 bf613c3aaf 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 2922481be3 docs: add homebrew installation instructions 2026-01-05 16:53:26 -08:00
vmfunc e27476652f chore: readme inconsistency 2026-01-03 06:14:40 -08:00
vmfunc 5ffea24182 ci: upgrade to go 1.24 in all workflows 2026-01-03 06:04:33 -08:00
vmfunc 67288577a6 chore: add license headers to missing files 2026-01-03 06:01:00 -08:00
vmfunc 7beb68e145 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 d2f3a42c43 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 c33896a45a docs: update readme and add module documentation 2026-01-03 05:57:10 -08:00
vmfunc 652f4f0c7c feat: show module loading and execution logs by default 2026-01-03 05:57:10 -08:00
vmfunc bf65820ff1 feat: add debug logging for module execution 2026-01-03 05:57:10 -08:00
vmfunc 0d4c10e6ff refactor: move pkg/scan to internal/scan 2026-01-03 05:57:10 -08:00
vmfunc 0eba16bc4d 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 c067eafed0 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 f20198dd26 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 a86f117658 feat: implement loadYAML in module loader 2026-01-03 05:57:10 -08:00
vmfunc a614b2ee8a 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 a8686c1e4a feat: add module cli flags 2026-01-03 05:57:10 -08:00
vmfunc c3f824e1e3 feat: add built-in yaml modules for security scanning 2026-01-03 05:57:10 -08:00
vmfunc 57acc6d37c feat: add yaml module parser and http executor 2026-01-03 05:57:10 -08:00
vmfunc 2596ce1ea2 feat: add module system infrastructure 2026-01-03 05:57:10 -08:00
vmfunc abce1405ca refactor: move config to internal 2026-01-03 05:57:10 -08:00
vmfunc f212aed50a refactor: move logger to internal 2026-01-03 05:57:10 -08:00
vmfunc 7e27e73554 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 da645ee42f 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 16c191fbaa 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 697c7def57 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 ff002f43f7 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 30482ecbce 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 ce07ac8b16 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 8b056231f7 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 c517e2eb1f 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 0dd533446a 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 1c0ad454dc 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 17aa3c00f0 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 b6b0a5a782 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 24b3b43b57 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 1550202e7a 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 a8eb319efe 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 43d5f7383b 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 1447485af9 docs: update contributor name and add vxfemboy 2026-01-02 19:56:44 -08:00
vmfunc 6e1d5cf488 chore: fix contributorrc 2026-01-02 19:55:31 -08:00
vmfunc 1583e00478 chore: fix contributorrc 2026-01-02 19:51:03 -08:00
vmfunc 40db023632 Merge pull request #40 from vmfunc/feat/framework-detection
feat: framework detection module
2026-01-02 19:15:07 -08:00
vmfunc 5868deef21 fix: adjust sif logo alignment 2026-01-02 19:12:28 -08:00
vmfunc b43e54bf60 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 c75ebccb27 docs: add framework detection to readme 2026-01-02 18:54:24 -08:00
vmfunc 858555bc47 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 51ee65c5df chore: add license header to detect.go 2026-01-02 18:52:15 -08:00
vmfunc 035e9406d9 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 ba5468725e chore(actions): add framework to CI 2026-01-02 18:52:15 -08:00
vmfunc 49b081dc30 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 127eeff265 feat: framework detection module 2026-01-02 18:52:15 -08:00
vmfunc 1b493e9572 fix: use static discord badge instead of server id 2026-01-02 18:45:07 -08:00
vmfunc 74b044ce59 docs: update readme with new modules and discord link 2026-01-02 18:42:45 -08:00
vmfunc 2a2bcf5b92 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 166e1b82c2 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 be9c02e8ba 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 018af224d6 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 97aeb4c8b0 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 2d38d3fea5 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 3df0064e4b 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 c20c37463a chore: delete old license 2026-01-02 17:45:14 -08:00
vmfunc 9190fa4741 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
163 changed files with 1708 additions and 18106 deletions
-1
View File
@@ -1 +0,0 @@
* @vmfunc
-15
View File
@@ -1,15 +0,0 @@
# 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
@@ -1,17 +0,0 @@
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
@@ -1,44 +0,0 @@
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"
+3 -8
View File
@@ -1,12 +1,7 @@
name: automatic rebase
name: Automatic Rebase
on:
issue_comment:
types: [created]
permissions:
contents: write
pull-requests: write
jobs:
rebase:
name: Rebase
@@ -14,10 +9,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the latest code
uses: actions/checkout@v7
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: automatic rebase
- name: Automatic Rebase
uses: cirrus-actions/rebase@1.8
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+7 -18
View File
@@ -1,29 +1,18 @@
name: check large files
name: Check Large Files
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
check-large-files:
name: check for large files
name: Check for large files
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: check for large files
- uses: actions/checkout@v4
- 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
find . -type f -size +5M | while read file; do
echo "::error file=${file}::File ${file} is larger than 5MB"
done
+12 -27
View File
@@ -1,39 +1,24 @@
name: code quality
name: Qodana
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:
codeql:
qodana:
runs-on: ubuntu-latest
permissions:
security-events: write
contents: read
contents: write
pull-requests: write
checks: write
steps:
- uses: actions/checkout@v7
- name: set up go
uses: actions/setup-go@v5
- uses: actions/checkout@v4
with:
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"
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- name: 'Qodana Scan'
uses: JetBrains/qodana-action@v2024.3
env:
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
+6 -10
View File
@@ -1,4 +1,4 @@
name: dependency review
name: "Dependency Review"
on:
pull_request:
push:
@@ -7,20 +7,16 @@ on:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
dependency-review:
runs-on: ubuntu-latest
steps:
- name: checkout repository
uses: actions/checkout@v7
- name: dependency review
uses: actions/dependency-review-action@v5
- name: "Checkout Repository"
uses: actions/checkout@v4
- name: "Dependency Review"
uses: actions/dependency-review-action@v4
continue-on-error: ${{ github.event_name == 'push' }}
- name: check dependency review outcome
- name: "Check Dependency Review Outcome"
if: github.event_name == 'push' && failure()
run: |
echo "::warning::Dependency review failed. Please check the dependencies for potential issues."
+9 -36
View File
@@ -1,51 +1,24 @@
name: go
name: Go
on:
push:
branches: ["main"]
pull_request:
branches: ["main"]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- 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@v7
- name: set up go
uses: actions/setup-go@v6
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: ${{ matrix.go-version }}
- name: build
go-version: "1.24"
- name: Build
run: make
- name: run tests with coverage
- name: Run tests with coverage
run: go test -race -coverprofile=coverage.out -covermode=atomic ./...
- name: upload coverage to codecov
uses: codecov/codecov-action@v7
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v4
with:
files: ./coverage.out
fail_ci_if_error: false
- name: run integration tests
run: go test -tags=integration -race ./internal/scan/...
-27
View File
@@ -1,27 +0,0 @@
name: govulncheck
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: "0 6 * * 1" # monday 06:00 UTC
permissions:
contents: read
jobs:
govulncheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: set up go
uses: actions/setup-go@v5
with:
go-version: "1.25"
- name: install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@v1.1.4
- name: run govulncheck
run: govulncheck ./...
continue-on-error: true
+2 -5
View File
@@ -8,14 +8,11 @@ on:
paths:
- '**.go'
permissions:
contents: read
jobs:
check-headers:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- name: check license headers
run: |
@@ -44,7 +41,7 @@ jobs:
echo ': █▀ █ █▀▀ · Blazing-fast pentesting suite :'
echo ': ▄█ █ █▀ · BSD 3-Clause License :'
echo ': :'
echo ': (c) 2022-2026 vmfunc, xyzeva, :'
echo ': (c) 2022-2025 vmfunc (vmfunc), xyzeva, :'
echo ': lunchcat alumni & contributors :'
echo ': :'
echo '·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·'
+2 -8
View File
@@ -1,4 +1,4 @@
name: mind your language
name: Mind your language
on:
issues:
types:
@@ -12,19 +12,13 @@ on:
types:
- created
- edited
permissions:
contents: read
issues: write
pull-requests: write
jobs:
echo_issue_comment:
runs-on: ubuntu-latest
name: profanity check
steps:
- name: Checkout
uses: actions/checkout@v7
uses: actions/checkout@v4
- name: Profanity check step
uses: tailaiw/mind-your-language-action@v1.0.3
env:
+3 -7
View File
@@ -1,22 +1,18 @@
name: markdown lint
name: Markdown Lint
on:
pull_request:
paths:
- "**/*.md"
permissions:
contents: read
pull-requests: write
jobs:
markdownlint:
name: runner / markdownlint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- name: markdownlint
uses: reviewdog/action-markdownlint@v0.26.2
uses: reviewdog/action-markdownlint@v0.24.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
reporter: github-pr-review
+3 -11
View File
@@ -1,26 +1,18 @@
name: misspell check
name: Misspell Check
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
pull-requests: write
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
misspell:
name: runner / misspell
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- name: misspell
uses: reviewdog/action-misspell@v1.27.0
uses: reviewdog/action-misspell@v1.26.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
reporter: github-pr-review
-139
View File
@@ -1,139 +0,0 @@
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@v7
- 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,
});
}
+50 -105
View File
@@ -1,9 +1,8 @@
name: release
name: Release
on:
push:
tags:
- "v*"
branches: [main]
permissions:
contents: write
@@ -19,35 +18,29 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v7
- name: set up go
uses: actions/setup-go@v5
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: "1.25"
go-version: "1.24"
- name: extract version
- name: Build for Windows
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
GOOS=windows GOARCH=amd64 go build -o sif-windows-amd64.exe ./cmd/sif
GOOS=windows GOARCH=386 go build -o sif-windows-386.exe ./cmd/sif
- name: build for windows
- name: Build for macOS
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
GOOS=darwin GOARCH=amd64 go build -o sif-macos-amd64 ./cmd/sif
GOOS=darwin GOARCH=arm64 go build -o sif-macos-arm64 ./cmd/sif
- name: build for macOS
- name: Build for Linux
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
GOOS=linux GOARCH=amd64 go build -o sif-linux-amd64 ./cmd/sif
GOOS=linux GOARCH=386 go build -o sif-linux-386 ./cmd/sif
GOOS=linux GOARCH=arm64 go build -o sif-linux-arm64 ./cmd/sif
- 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
- 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}"
@@ -62,8 +55,10 @@ jobs:
cd dist && zip -r "../${binary}.zip" "${binary}" && cd ..
done
- name: build debian packages
- name: Build Debian packages
run: |
VERSION="0.1.0-$(git rev-parse --short HEAD)"
declare -A arch_map=(
["sif-linux-amd64"]="amd64"
["sif-linux-386"]="i386"
@@ -72,7 +67,7 @@ jobs:
for binary in sif-linux-amd64 sif-linux-386 sif-linux-arm64; do
arch="${arch_map[$binary]}"
pkg_dir="sif_${{ env.VERSION }}_${arch}"
pkg_dir="sif_${VERSION}_${arch}"
mkdir -p "${pkg_dir}/DEBIAN"
mkdir -p "${pkg_dir}/usr/bin"
@@ -84,11 +79,11 @@ jobs:
cat > "${pkg_dir}/DEBIAN/control" << EOF
Package: sif
Version: ${{ env.VERSION }}
Version: ${VERSION}
Section: security
Priority: optional
Architecture: ${arch}
Maintainer: vmfunc <celeste@linux.com>
Maintainer: vmfunc <celeste@router.sex>
Homepage: https://github.com/vmfunc/sif
Description: Modular pentesting toolkit
sif is a fast, concurrent, and extensible pentesting toolkit written in Go.
@@ -99,90 +94,43 @@ jobs:
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: Set release version
run: echo "RELEASE_VERSION=$(git rev-parse --short HEAD)" >> $GITHUB_ENV
- name: generate SBOM
uses: anchore/sbom-action@v0
- name: Create Release and Upload Assets
uses: softprops/action-gh-release@v2
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 }}
tag_name: automated-release-${{ env.RELEASE_VERSION }}
name: Release ${{ env.RELEASE_VERSION }}
body: |
## what's changed
Automated release v${{ env.RELEASE_VERSION }}
${{ steps.changelog.outputs.result }}
## Assets
## install
Each archive contains the sif binary and built-in modules.
**homebrew / linuxbrew**
```bash
# coming soon
```
- Windows (64-bit): `sif-windows-amd64.zip`
- Windows (32-bit): `sif-windows-386.zip`
- macOS (64-bit Intel): `sif-macos-amd64.tar.gz`
- macOS (64-bit ARM): `sif-macos-arm64.tar.gz`
- Linux (64-bit): `sif-linux-amd64.tar.gz`
- Linux (32-bit): `sif-linux-386.tar.gz`
- Linux (64-bit ARM): `sif-linux-arm64.tar.gz`
- Debian/Ubuntu (64-bit): `sif_*_amd64.deb`
- Debian/Ubuntu (32-bit): `sif_*_i386.deb`
- Debian/Ubuntu (64-bit ARM): `sif_*_arm64.deb`
**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
## Installation
```bash
sha256sum -c checksums-sha256.txt
tar -xzf sif-linux-amd64.tar.gz
cd sif-linux-amd64
./sif -h
```
For more details, check the [commit history](https://github.com/${{ github.repository }}/commits/main).
draft: false
prerelease: ${{ contains(github.ref_name, '-') }}
prerelease: false
files: |
sif-windows-amd64.zip
sif-windows-386.zip
@@ -194,13 +142,10 @@ jobs:
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, '-') }}
- name: Push to Cloudsmith
env:
CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_API_KEY }}
run: |
+3 -10
View File
@@ -1,4 +1,4 @@
name: update report card
name: Update Report Card
on:
push:
@@ -7,17 +7,10 @@ on:
branches: [main]
workflow_call:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
update-report-card:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: update go report card
- uses: actions/checkout@v4
- name: Update Go Report Card
uses: creekorful/goreportcard-action@v1.0
+8 -11
View File
@@ -1,4 +1,4 @@
name: functional test
name: Functional Test
on:
push:
@@ -7,21 +7,18 @@ on:
branches: [main]
workflow_call:
permissions:
contents: read
jobs:
build-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- name: set up go
uses: actions/setup-go@v5
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v4
with:
go-version: "1.25"
- name: build sif
go-version: "1.24"
- name: Build Sif
run: make
- name: run sif with features
- name: Run Sif with features
run: |
./sif -u https://example.com -dnslist small -dirlist small -dork -git -whois -cms -framework
if [ $? -eq 0 ]; then
@@ -31,7 +28,7 @@ jobs:
exit 1
fi
- name: test module system
- name: Test module system
run: |
echo "Listing modules..."
./sif -lm
-30
View File
@@ -1,30 +0,0 @@
name: scorecard
on:
push:
branches: [main]
schedule:
- cron: "0 6 * * 1" # monday 06:00 UTC
permissions: read-all
jobs:
analysis:
runs-on: ubuntu-latest
permissions:
security-events: write
id-token: write
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: run scorecard
uses: ossf/scorecard-action@v2.4.3
with:
results_file: results.sarif
results_format: sarif
publish_results: true
- name: upload sarif results
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: results.sarif
+3 -7
View File
@@ -1,22 +1,18 @@
name: shell check
name: Shell Check
on:
pull_request:
paths:
- "**/*.sh"
permissions:
contents: read
pull-requests: write
jobs:
shellcheck:
name: runner / shellcheck
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- name: shellcheck
uses: reviewdog/action-shellcheck@v1.32.0
uses: reviewdog/action-shellcheck@v1.27.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
reporter: github-pr-review
+3 -7
View File
@@ -1,4 +1,4 @@
name: yaml lint
name: YAML Lint
on:
pull_request:
@@ -6,18 +6,14 @@ on:
- "**/*.yml"
- "**/*.yaml"
permissions:
contents: read
pull-requests: write
jobs:
yamllint:
name: runner / yamllint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v4
- name: yamllint
uses: reviewdog/action-yamllint@v1.21.0
uses: reviewdog/action-yamllint@v1.19.0
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
reporter: github-pr-review
+22 -79
View File
@@ -1,96 +1,39 @@
---
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)
- staticcheck # advanced static analysis
- unused # unused code
- gosimple # simplifications
- ineffassign # useless assignments
- misspell # spelling mistakes
- gocritic # opinionated lints
- revive # replacement for golint
- unconvert # unnecessary type conversions
- prealloc # slice preallocation hints
- bodyclose # http response body not closed
- noctx # http requests without context
- 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.
- exportloopref # loop variable capture
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:
linters-settings:
govet:
enable-all: true
errcheck:
check-blank: false
revive:
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
- name: exported
arguments: [checkPrivateReceivers]
gocritic:
enabled-tags:
- diagnostic
- style
- performance
run:
timeout: 5m
issues-exit-code: 1
issues:
max-issues-per-linter: 50
max-same-issues: 50
max-same-issues: 3
+1 -1
View File
@@ -1,6 +1,6 @@
BSD 3-Clause License
Copyright (c) 2022-2025 vmfunc, xyzeva, lunchcat alumni,
Copyright (c) 2022-2025 vmfunc (vmfunc), xyzeva, lunchcat alumni,
and other sif contributors.
Redistribution and use in source and binary forms, with or without
+1 -11
View File
@@ -9,12 +9,6 @@ RM ?= rm
GOFLAGS ?=
PREFIX ?= /usr/local
BINDIR ?= bin
MANDIR ?= share/man/man1
# stamp local builds with the nearest v* tag (or short sha), matching the
# release ci. --match keeps the automated-release-* tags out of the version.
VERSION ?= $(shell git describe --tags --match 'v*' --always --dirty 2>/dev/null | sed 's/^v//')
GO_LDFLAGS = -X main.version=$(VERSION)
define COPYRIGHT_ASCII
@@ -62,7 +56,7 @@ sif: check_go_version
@echo "📁 Current directory: $$(pwd)"
@echo "🔧 Go flags: $(GOFLAGS)"
@echo "📦 Building package: ./cmd/sif"
$(GO) build -v $(GOFLAGS) -ldflags "$(GO_LDFLAGS)" ./cmd/sif
$(GO) build -v $(GOFLAGS) ./cmd/sif
@echo "📊 Build info:"
@$(GO) version -m sif
@echo "✅ sif built successfully! 🚀"
@@ -82,9 +76,6 @@ install: check_go_version
fi
@mkdir -p $(DESTDIR)$(PREFIX)/$(BINDIR) || (echo "🔒 Permission denied. Trying with sudo..." && sudo mkdir -p $(DESTDIR)$(PREFIX)/$(BINDIR))
@cp -f sif $(DESTDIR)$(PREFIX)/$(BINDIR) || (echo "🔒 Permission denied. Trying with sudo..." && sudo cp -f sif $(DESTDIR)$(PREFIX)/$(BINDIR))
@echo "📖 Installing man page..."
@mkdir -p $(DESTDIR)$(PREFIX)/$(MANDIR) || (echo "🔒 Permission denied. Trying with sudo..." && sudo mkdir -p $(DESTDIR)$(PREFIX)/$(MANDIR))
@cp -f man/sif.1 $(DESTDIR)$(PREFIX)/$(MANDIR) || (echo "🔒 Permission denied. Trying with sudo..." && sudo cp -f man/sif.1 $(DESTDIR)$(PREFIX)/$(MANDIR))
@echo "✅ sif installed successfully! 🎊"
uninstall:
@@ -95,7 +86,6 @@ uninstall:
exit 1; \
fi
@$(RM) $(DESTDIR)$(PREFIX)/$(BINDIR)/sif || (echo "🔒 Permission denied. Trying with sudo..." && sudo $(RM) $(DESTDIR)$(PREFIX)/$(BINDIR)/sif)
@$(RM) $(DESTDIR)$(PREFIX)/$(MANDIR)/sif.1 || (echo "🔒 Permission denied. Trying with sudo..." && sudo $(RM) $(DESTDIR)$(PREFIX)/$(MANDIR)/sif.1)
@echo "✅ sif uninstalled successfully!"
.PHONY: all check_go_version sif clean install uninstall
+7 -174
View File
@@ -8,39 +8,24 @@
[![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)
[![discord](https://img.shields.io/badge/discord-join-5865F2?style=flat-square&logo=discord&logoColor=white)](https://discord.gg/sifcli)
**[install](#install) · [usage](#usage) · [modules](#modules) · [docs](docs/) · [contribute](#contribute)**
*fast, concurrent recon to exploitation in one binary. every scanner shares one connection-pooled http client.*
</div>
---
## what is sif?
sif is a 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.
sif is a modular pentesting toolkit written in go. it's designed to be fast, concurrent, and extensible. run multiple scan types against targets with a single command.
```bash
sif -u https://example.com -dnslist -ports -crawl -js -framework -nuclei
./sif -u https://example.com -all
```
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)
@@ -60,25 +45,6 @@ yay -S sif
paru -S sif
```
### nix
```bash
# nixpkgs (declarative: add to configuration.nix or home-manager)
environment.systemPackages = [ pkgs.sif ];
# or imperatively
nix profile install nixpkgs#sif
# or just run it without installing
nix run nixpkgs#sif -- -u https://example.com -headers -sh -framework
```
the repo also ships a flake if you want to build from source:
```bash
nix run github:vmfunc/sif
```
### debian/ubuntu (apt)
```bash
@@ -98,7 +64,7 @@ cd sif
make
```
requires go 1.25+
requires go 1.23+
### aur (manual install)
@@ -129,39 +95,18 @@ makepkg -si
# shodan host intelligence (requires SHODAN_API_KEY env var)
./sif -u https://example.com -shodan
# securitytrails domain discovery (requires SECURITYTRAILS_API_KEY env var)
# discovers subdomains + associated domains, then scans all of them
./sif -u https://example.com -securitytrails -headers
# sql recon + lfi scanning
./sif -u https://example.com -sql -lfi
# web vuln probes (cors, open redirect, reflected xss)
./sif -u https://example.com -cors -redirect -xss
# framework detection (with cve lookup)
./sif -u https://example.com -framework
# a broad sweep
./sif -u https://example.com -dirlist small -dnslist small -ports common -headers -sh -cms -framework -git -whois
# everything
./sif -u https://example.com -all
```
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.
@@ -171,133 +116,21 @@ sif has a modular architecture. modules are defined in yaml and can be extended
| flag | description |
|------|-------------|
| `-dirlist` | directory and file fuzzing (small/medium/large) |
| `-mc` | dirlist: match these status codes (comma list, e.g. 200,301) |
| `-fc` | dirlist: filter out these status codes (comma list) |
| `-fs` | dirlist: filter out responses of these body sizes (comma list) |
| `-fw` | dirlist: filter out responses with these word counts (comma list) |
| `-fr` | dirlist: filter out responses whose body matches this regex |
| `-ac` | 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 |
| `-js` | javascript analysis |
| `-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
-15
View File
@@ -1,15 +0,0 @@
# 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.
+1 -30
View File
@@ -4,7 +4,7 @@
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: (c) 2022-2025 vmfunc (vmfunc), xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
@@ -13,38 +13,15 @@
package main
import (
"fmt"
"os"
"github.com/charmbracelet/log"
"github.com/dropalldatabases/sif"
"github.com/dropalldatabases/sif/internal/config"
"github.com/dropalldatabases/sif/internal/patchnotes"
ver "github.com/dropalldatabases/sif/internal/version"
// Register framework detectors
_ "github.com/dropalldatabases/sif/internal/scan/frameworks/detectors"
)
// version is stamped at release time via -ldflags "-X main.version=...";
// ver.Resolve falls back to the build info or "dev" for other builds.
var version = "dev"
func main() {
version = ver.Resolve(version)
sif.Version = version
if len(os.Args) > 1 {
switch os.Args[1] {
case "patchnote", "patchnotes", "-pn", "--patchnotes":
patchnotes.Print("")
return
case "version", "-version", "--version":
fmt.Printf("sif %s\n", version)
return
}
}
settings := config.Parse()
app, err := sif.New(settings)
@@ -52,12 +29,6 @@ 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)
+3 -11
View File
@@ -4,7 +4,7 @@ setting up a development environment for sif.
## prerequisites
- go 1.25 or later
- go 1.23 or later
- git
- make
@@ -28,7 +28,8 @@ sif/
│ ├── logger/ # logging utilities
│ ├── modules/ # module system
│ ├── scan/ # built-in scans
── styles/ # terminal styling
── styles/ # terminal styling
│ └── worker/ # worker pool
├── modules/ # built-in yaml modules
│ ├── http/ # http-based modules
│ ├── info/ # information gathering
@@ -137,15 +138,6 @@ the module system is in `internal/modules/`:
go test ./internal/...
```
### integration tests
run the scanners against a local testbed that plants the artifacts each one
should find (network-free, behind a build tag):
```bash
go test -tags=integration ./internal/scan/...
```
### functional test
```bash
+1 -1
View File
@@ -36,7 +36,7 @@ download `sif-windows-amd64.exe` from releases and add to your PATH.
## from source
requires go 1.25+
requires go 1.23+
```bash
git clone https://github.com/dropalldatabases/sif.git
+4 -15
View File
@@ -98,27 +98,16 @@ analyzes javascript files for security issues.
## http headers (-headers)
dumps the target's response headers.
## security headers (-sh)
flags missing or weak security headers and headers that leak server internals.
analyzes security headers.
### checks
- strict-transport-security (https only)
- content-security-policy
- x-frame-options
- x-content-type-options (expects nosniff)
- referrer-policy
- x-content-type-options
- strict-transport-security
- x-xss-protection
- permissions-policy
- cross-origin-opener-policy
### flagged as disclosure
- server
- x-powered-by
- x-aspnet-version / x-aspnetmvc-version
## cms detection (-cms)
+3 -303
View File
@@ -21,23 +21,6 @@ read targets from a file (one url per line):
./sif -f targets.txt
```
### stdin (pipe mode)
when stdin is a pipe, sif reads one target per line from it, alongside any `-u`/`-f` targets. this lets sif slot into a unix pipeline:
```bash
subfinder -d example.com | sif -silent -probe | notify
```
### naked hosts
targets without a scheme default to `https://`; an explicit `http://`/`https://` is kept as given. any other scheme (`ftp://`, `file://`, ...) is rejected:
```bash
./sif -u example.com # scanned as https://example.com
echo example.com | sif -probe # same, over stdin
```
## scan options
### directory fuzzing
@@ -50,42 +33,6 @@ sizes: `small`, `medium`, `large`
./sif -u https://example.com -dirlist medium
```
#### response filters
modern apps serve a catch-all 200 for unknown routes, so a naive scan reports
every path. these ffuf-style filters cut the noise (a filter always wins over a
match):
- `-mc <codes>` - match only these status codes (comma list, e.g. `200,301`)
- `-fc <codes>` - filter out these status codes
- `-fs <sizes>` - filter out responses of these body sizes
- `-fw <counts>` - filter out responses with these word counts
- `-fr <regex>` - filter out responses whose body matches this regex
```bash
./sif -u https://example.com -dirlist medium -mc 200,301 -fs 1234
```
#### wildcard calibration
`-ac` probes a few paths that cannot exist, learns the soft-404 baseline
(status + size + words), and auto-drops any response matching it - so SPA
catch-all 200s stop flooding the output:
```bash
./sif -u https://example.com -dirlist medium -ac
```
#### custom wordlists and extensions
`-w <path|url>` overrides the size switch with your own list (local file or
remote url); `-e <exts>` appends each extension to every word, keeping the bare
word too:
```bash
./sif -u https://example.com -w /path/to/words.txt -e php,bak,env
```
### subdomain enumeration
`-dnslist <size>` - enumerate subdomains
@@ -132,7 +79,7 @@ scopes: `common` (top ports), `full` (all ports)
### javascript analysis
`-js` - analyze javascript files + secret and endpoint extraction
`-js` - analyze javascript files
```bash
./sif -u https://example.com -js
@@ -148,20 +95,12 @@ scopes: `common` (top ports), `full` (all ports)
### http headers
`-headers` - dump the target's response headers
`-headers` - analyze security 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
@@ -207,56 +146,6 @@ export SHODAN_API_KEY=your-api-key
./sif -u https://example.com -lfi
```
### cors probe
`-cors` - probe for cors misconfigurations (reflected/permissive origins)
```bash
./sif -u https://example.com -cors
```
### open redirect probe
`-redirect` - probe redirect-prone params for open redirects
```bash
./sif -u https://example.com/login?next=home -redirect
```
### reflected xss probe
`-xss` - inject a canary into params and report unescaped reflections
```bash
./sif -u https://example.com/search?q=test -xss
```
### jwt analysis
`-jwt` - fetch the target once, harvest jwts from response headers, cookies and body, then analyze each one entirely offline
flags alg:none, the rs256->hs256 confusion surface, missing/expired exp, plaintext sensitive claims, and cracks a small bundled weak-hmac wordlist. no token is ever sent off-box.
```bash
./sif -u https://example.com -jwt
```
### openapi/swagger exposure
`-openapi` - probe the conventional spec paths (`/swagger.json`, `/openapi.json`, `/v3/api-docs`, ...), parse the first hit (json or yaml) and enumerate every path+method, flagging operations with no security requirement
```bash
./sif -u https://example.com -openapi
```
### favicon fingerprint
`-favicon` - fetch `/favicon.ico` (or the declared `<link rel=icon>`), compute the shodan-style mmh3 hash, match it against a bundled tech map and print the `http.favicon.hash:<n>` pivot query
```bash
./sif -u https://example.com -favicon
```
### framework detection
`-framework` - detect web frameworks with version and cve lookup
@@ -265,34 +154,6 @@ flags alg:none, the rs256->hs256 confusion surface, missing/expired exp, plainte
./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
@@ -356,7 +217,7 @@ http request timeout (default: 10s):
### --threads
number of concurrent threads (default: 10). values below 1 are clamped to 1:
number of concurrent threads (default: 10):
```bash
./sif -u https://example.com --threads 20
@@ -378,142 +239,6 @@ enable debug logging:
./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
@@ -526,28 +251,6 @@ enable api mode for json output:
output is a json object with scan results.
## commands
these run without scanning a target.
### version
print the sif version. release builds are stamped via ldflags, local `make` builds derive it from `git describe`, and `go install`ed builds read it from the module build info:
```bash
./sif version
```
### patchnote
show the latest release's notes, fetched from github (also `-pn`):
```bash
./sif patchnote
```
the first time you run a new release sif also prints that release's notes once. set `SIF_NO_PATCHNOTES=1` to disable that.
## examples
### quick recon
@@ -570,9 +273,6 @@ the first time you run a new release sif also prints that release's notes once.
-git \
-sql \
-lfi \
-cors \
-redirect \
-xss \
-am
```
Generated
+3 -3
View File
@@ -2,11 +2,11 @@
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1780930886,
"narHash": "sha256-rppURzHviaQN131F+nLiLdGfcb0uCd9gGP0E5+iw9MI=",
"lastModified": 1767364772,
"narHash": "sha256-fFUnEYMla8b7UKjijLnMe+oVFOz6HjijGGNS1l7dYaQ=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "8c3cede7ddc26bd659d2d383b5610efbd2c7a16e",
"rev": "16c7794d0a28b5a37904d55bcca36003b9109aaa",
"type": "github"
},
"original": {
+1 -1
View File
@@ -21,7 +21,7 @@
version = "unstable-${self.shortRev or self.dirtyShortRev or "dev"}";
src = ./.;
vendorHash = "sha256-fR63/dStMsZon22vancuLWIAvZiEYMLjMwY1kmRDNgM=";
vendorHash = "sha256-ztKXnOjZS/jMxsRjtF0rIZ3lKv4YjMdZd6oQFRuAtR4=";
# Tests require network access (httptest)
doCheck = false;
+140 -315
View File
@@ -1,398 +1,223 @@
module github.com/dropalldatabases/sif
go 1.25.7
go 1.24.0
require (
github.com/antchfx/htmlquery v1.3.6
github.com/charmbracelet/glamour v1.0.0
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
github.com/charmbracelet/log v1.0.0
github.com/gocolly/colly/v2 v2.3.0
github.com/likexian/whois v1.15.7
github.com/projectdiscovery/goflags v0.1.74
github.com/projectdiscovery/nuclei/v3 v3.8.0
github.com/projectdiscovery/retryabledns v1.0.114
github.com/projectdiscovery/utils v0.10.1
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/rocketlaunchr/google-search v1.1.6
github.com/twmb/murmur3 v1.1.8
golang.org/x/net v0.56.0
golang.org/x/time v0.14.0
gopkg.in/yaml.v3 v3.0.1
)
require (
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
aead.dev/minisign v0.2.0 // 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.4.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/Masterminds/semver/v3 v3.2.1 // indirect
github.com/Mzack9999/gcache v0.0.0-20230410081825-519e28eab057 // indirect
github.com/Mzack9999/go-http-digest-auth-client v0.6.1-0.20220414142836-eb8883508809 // indirect
github.com/Mzack9999/go-rsync v0.0.0-20250821180103-81ffa574ef4d // indirect
github.com/Mzack9999/goja v0.0.0-20250507184235-e46100e9c697 // indirect
github.com/Mzack9999/goja_nodejs v0.0.0-20250507184139-66bcbf65c883 // indirect
github.com/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/PuerkitoBio/goquery v1.8.1 // indirect
github.com/VividCortex/ewma v1.2.0 // indirect
github.com/akrylysov/pogreb v0.10.2 // indirect
github.com/akrylysov/pogreb v0.10.1 // indirect
github.com/alecthomas/chroma v0.10.0 // indirect
github.com/alecthomas/chroma/v2 v2.20.0 // indirect
github.com/alecthomas/jsonschema v0.0.0-20211022214203-8b29eab41725 // indirect
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 // indirect
github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 // indirect
github.com/alexsnet/go-vnc v0.1.0 // indirect
github.com/alitto/pond v1.9.2 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/andybalholm/cascadia v1.3.3 // indirect
github.com/andygrunwald/go-jira v1.16.1 // indirect
github.com/antchfx/xmlquery v1.5.0 // indirect
github.com/antchfx/xpath v1.3.6 // indirect
github.com/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/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // 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/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/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/bahlo/generic-list-go v0.2.0 // indirect
github.com/bits-and-blooms/bitset v1.24.4 // indirect
github.com/bits-and-blooms/bloom/v3 v3.5.0 // indirect
github.com/bluele/gcache v0.0.2 // indirect
github.com/bodgit/plumbing v1.3.0 // indirect
github.com/bodgit/sevenzip v1.6.1 // indirect
github.com/bodgit/windows v1.0.1 // indirect
github.com/brianvoe/gofakeit/v7 v7.2.1 // indirect
github.com/buger/jsonparser v1.1.2 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/caddyserver/certmagic v0.25.0 // indirect
github.com/caddyserver/zerossl v0.1.3 // indirect
github.com/censys/censys-sdk-go v0.19.1 // indirect
github.com/cespare/xxhash v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/charmbracelet/colorprofile v0.3.2 // indirect
github.com/charmbracelet/x/ansi v0.10.2 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/exp/slice v0.0.0-20250908092851-c2208eb08494 // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/cheggaaa/pb/v3 v3.1.7 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/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/cloudflare/cfssl v1.6.4 // 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/cnf/structhash v0.0.0-20201127153200-e1b16c1ebc08 // indirect
github.com/corpix/uarand v0.2.0 // indirect
github.com/dimchansky/utfbom v1.1.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/dlclark/regexp2 v1.8.1 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
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/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect
github.com/fatih/color v1.16.0 // indirect
github.com/fatih/structs v1.1.0 // 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/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/go-logfmt/logfmt v0.6.0 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // 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/go-playground/validator/v10 v10.14.1 // indirect
github.com/go-rod/rod v0.114.0 // 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.4.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/gogo/protobuf v1.3.2 // 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.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/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/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.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/google/uuid v1.3.1 // indirect
github.com/gorilla/css v1.0.0 // 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.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/hashicorp/go-retryablehttp v0.7.7 // indirect
github.com/hashicorp/go-version v1.6.0 // indirect
github.com/hbakhtiyor/strsim v0.0.0-20190107154042-4d2bbb273edf // indirect
github.com/hdm/jarm-go v0.0.7 // indirect
github.com/iangcarroll/cookiemonster v1.6.0 // indirect
github.com/imdario/mergo v0.3.16 // indirect
github.com/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/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/json-iterator/go v1.1.12 // indirect
github.com/julienschmidt/httprouter v1.3.0 // 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/kataras/jwt v0.1.8 // indirect
github.com/kennygrant/sanitize v1.2.4 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/kitabisa/go-ci v1.0.3 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/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/klauspost/compress v1.17.4 // indirect
github.com/klauspost/cpuid/v2 v2.2.5 // indirect
github.com/klauspost/pgzip v1.2.5 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/libdns/libdns v0.2.1 // indirect
github.com/logrusorgru/aurora v2.0.3+incompatible // 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/lor00x/goldap v0.0.0-20180618054307-a546dffdd1a3 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/mackerelio/go-osstat v0.2.4 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.17 // indirect
github.com/mattn/go-sqlite3 v1.14.28 // indirect
github.com/maypok86/otter/v2 v2.2.1 // indirect
github.com/mholt/acmez/v3 v3.1.3 // indirect
github.com/mholt/archives v0.1.5 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/microsoft/go-mssqldb v1.9.2 // indirect
github.com/miekg/dns v1.1.68 // indirect
github.com/mikelolasagasti/xz v1.0.1 // indirect
github.com/minio/minlz v1.0.1 // indirect
github.com/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/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.16.0 // indirect
github.com/nlnwa/whatwg-url v0.6.2 // indirect
github.com/nwaples/rardecode/v2 v2.2.2 // indirect
github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect
github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect
github.com/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/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/pkg/errors v0.9.1 // 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/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/projectdiscovery/asnmap v1.1.0 // indirect
github.com/projectdiscovery/blackrock v0.0.1 // indirect
github.com/projectdiscovery/cdncheck v1.2.31 // indirect
github.com/projectdiscovery/clistats v0.1.1 // indirect
github.com/projectdiscovery/dsl v0.8.14 // indirect
github.com/projectdiscovery/fastdialer v0.5.6 // indirect
github.com/projectdiscovery/cdncheck v1.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/fasttemplate v0.0.2 // indirect
github.com/projectdiscovery/freeport v0.0.7 // indirect
github.com/projectdiscovery/gcache v0.0.0-20241015120333-12546c6e3f4c // indirect
github.com/projectdiscovery/go-smb2 v0.0.0-20240129202741-052cc450c6cb // indirect
github.com/projectdiscovery/gologger v1.1.68 // indirect
github.com/projectdiscovery/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/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/ldapserver v1.0.2-0.20240219154113-dcc758ebc0cb // indirect
github.com/projectdiscovery/machineid v0.0.0-20250715113114-c77eb3567582 // indirect
github.com/projectdiscovery/mapcidr v1.1.97 // indirect
github.com/projectdiscovery/n3iwf v0.0.0-20230523120440-b8cd232ff1f5 // indirect
github.com/projectdiscovery/networkpolicy v0.1.36 // indirect
github.com/projectdiscovery/ratelimit v0.0.85 // indirect
github.com/projectdiscovery/rawhttp v0.1.90 // 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/rdap v0.9.1-0.20221108103045-9865884d1917 // indirect
github.com/projectdiscovery/retryablehttp-go v1.3.8 // indirect
github.com/projectdiscovery/retryabledns v1.0.62 // indirect
github.com/projectdiscovery/retryablehttp-go v1.0.63 // indirect
github.com/projectdiscovery/sarif v0.0.1 // indirect
github.com/projectdiscovery/tlsx v1.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/projectdiscovery/tlsx v1.1.4 // indirect
github.com/projectdiscovery/yamldoc-go v1.0.4 // indirect
github.com/refraction-networking/utls v1.8.1 // indirect
github.com/remeh/sizedwaitgroup v1.0.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/rivo/uniseg v0.4.4 // indirect
github.com/rogpeppe/go-internal v1.10.0 // indirect
github.com/rs/xid v1.5.0 // indirect
github.com/saintfish/chardet v0.0.0-20230101081208-5e3ef4b5456d // indirect
github.com/sashabaranov/go-openai v1.37.0 // indirect
github.com/sashabaranov/go-openai v1.14.2 // indirect
github.com/segmentio/ksuid v1.0.4 // 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/shirou/gopsutil/v3 v3.23.7 // indirect
github.com/shoenig/go-m1cpu v0.1.6 // indirect
github.com/spaolacci/murmur3 v1.1.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.9.2 // indirect
github.com/spf13/cast v1.5.1 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/syndtr/goleveldb v1.0.0 // indirect
github.com/temoto/robotstxt v1.1.2 // indirect
github.com/tidwall/btree v1.8.1 // indirect
github.com/tidwall/buntdb v1.3.2 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/btree v1.6.0 // indirect
github.com/tidwall/buntdb v1.3.0 // indirect
github.com/tidwall/gjson v1.14.4 // indirect
github.com/tidwall/grect v0.1.4 // indirect
github.com/tidwall/match v1.2.0 // indirect
github.com/tidwall/match v1.1.1 // 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/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/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/trivago/tgo v1.0.7 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/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/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/ysmood/fetchup v0.2.3 // indirect
github.com/ysmood/goob v0.4.0 // indirect
github.com/ysmood/got v0.40.0 // indirect
github.com/ysmood/got v0.34.1 // indirect
github.com/ysmood/gson v0.7.3 // 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/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/yusufpapurcu/wmi v1.2.4 // indirect
github.com/zcalusic/sysinfo v1.1.3 // indirect
github.com/zeebo/blake3 v0.2.4 // indirect
github.com/zcalusic/sysinfo v1.0.2 // indirect
github.com/zeebo/blake3 v0.2.3 // indirect
github.com/zmap/rc2 v0.0.0-20190804163417-abaa70531248 // indirect
github.com/zmap/zcrypto v0.0.0-20240803002437-3a861682ac77 // indirect
github.com/zmap/zgrab2 v0.1.8 // indirect
gitlab.com/gitlab-org/api/client-go v0.130.1 // indirect
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
github.com/zmap/zcrypto v0.0.0-20230422215203-9a665e1e9968 // indirect
go.etcd.io/bbolt v1.3.7 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.0 // indirect
go.uber.org/zap/exp v0.3.0 // indirect
go4.org v0.0.0-20230225012048-214862532bf5 // indirect
go.uber.org/zap v1.25.0 // indirect
goftp.io/server/v2 v2.0.1 // indirect
golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/crypto v0.46.0 // indirect
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect
golang.org/x/mod v0.30.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sync v0.21.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/term v0.44.0 // indirect
golang.org/x/text v0.38.0 // indirect
golang.org/x/tools v0.45.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/protobuf v1.36.10 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/term v0.38.0 // indirect
golang.org/x/text v0.32.0 // indirect
golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.39.0 // indirect
google.golang.org/appengine v1.6.7 // indirect
google.golang.org/protobuf v1.33.0 // indirect
gopkg.in/alecthomas/kingpin.v2 v2.2.6 // indirect
gopkg.in/corvus-ch/zbase32.v1 v1.0.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/djherbis/times.v1 v1.3.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
mellium.im/sasl v0.3.2 // indirect
moul.io/http2curl v1.0.0 // indirect
)
+311 -1203
View File
File diff suppressed because it is too large Load Diff
+1 -95
View File
@@ -4,7 +4,7 @@
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: (c) 2022-2025 vmfunc (vmfunc), xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
@@ -21,16 +21,7 @@ import (
type Settings struct {
Dirlist string
DirMatchCodes string // -mc dirlist: status codes to keep
DirFilterCodes string // -fc dirlist: status codes to drop
DirFilterSizes string // -fs dirlist: body sizes to drop
DirFilterWords string // -fw dirlist: word counts to drop
DirFilterRegex string // -fr dirlist: regex; body match drops response
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
@@ -48,55 +39,18 @@ type Settings struct {
Template string
CMS bool
Headers bool
SecurityHeaders bool
CloudStorage bool
SubdomainTakeover bool
Shodan bool
SecurityTrails bool
SQL bool
LFI bool
JWT bool
OpenAPI bool
Favicon bool
CORS bool
Redirect bool
XSS bool
Framework bool
Crawl bool
CrawlDepth int
Passive bool
Probe bool
SARIF string // path to write a sarif 2.1.0 report to ("" = off)
Markdown string // path to write a markdown report to ("" = off)
Silent bool // route chrome to stderr, print one finding per line to stdout
Diff bool // surface only findings added/removed vs the last snapshot
Store string // snapshot dir for diff mode ("" = default state dir)
Modules string // Comma-separated list of module IDs to run
ModuleTags string // Run modules matching these tags
AllModules bool // Run all loaded modules
ListModules bool // List available modules and exit
Proxy string
Header goflags.StringSlice // custom request headers ("Key: Value")
Cookie string
RateLimit int
Notify bool // -notify: ship findings to configured providers
NotifySeverity string // -notify-severity: minimum severity to send (info..critical)
NotifyConfig string // -notify-config: path to a notify-compatible yaml file
}
// minThreads is the floor for the worker count. Threads feeds wg.Add across the
// scanners, so 0 silently runs nothing and a negative value panics with
// "negative WaitGroup counter"; clamp the parsed value up to this.
const minThreads = 1
// defaultCrawlDepth bounds how far the spider recurses by default; deep enough
// to find linked pages without crawling an entire site.
const defaultCrawlDepth = 2
// defaultNotifySeverity is the floor notify sends at when -notify-severity is
// unset: medium drops pure recon/info noise so alerts stay actionable.
const defaultNotifySeverity = "medium"
const (
Nil goflags.EnumVariable = iota
@@ -125,16 +79,7 @@ func Parse() *Settings {
portScopes := goflags.AllowdTypes{"common": Common, "full": Full, "none": Nil}
flagSet.CreateGroup("scans", "Scans",
flagSet.EnumVar(&settings.Dirlist, "dirlist", Nil, "Directory fuzzing scan size (small/medium/large)", listSizes),
flagSet.StringVar(&settings.DirMatchCodes, "mc", "", "Dirlist: match these status codes (comma list, e.g. 200,301)"),
flagSet.StringVar(&settings.DirFilterCodes, "fc", "", "Dirlist: filter out these status codes (comma list)"),
flagSet.StringVar(&settings.DirFilterSizes, "fs", "", "Dirlist: filter out responses of these body sizes (comma list)"),
flagSet.StringVar(&settings.DirFilterWords, "fw", "", "Dirlist: filter out responses with these word counts (comma list)"),
flagSet.StringVar(&settings.DirFilterRegex, "fr", "", "Dirlist: filter out responses whose body matches this regex"),
flagSet.BoolVar(&settings.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"),
@@ -144,24 +89,12 @@ func Parse() *Settings {
flagSet.BoolVar(&settings.JavaScript, "js", false, "Enable JavaScript scans"),
flagSet.BoolVar(&settings.CMS, "cms", false, "Enable CMS detection"),
flagSet.BoolVar(&settings.Headers, "headers", false, "Enable HTTP Header Analysis"),
flagSet.BoolVarP(&settings.SecurityHeaders, "security-headers", "sh", false, "Enable security header analysis (missing/weak headers)"),
flagSet.BoolVar(&settings.CloudStorage, "c3", false, "Enable C3 Misconfiguration Scan"),
flagSet.BoolVar(&settings.SubdomainTakeover, "st", false, "Enable Subdomain Takeover Check"),
flagSet.BoolVar(&settings.Shodan, "shodan", false, "Enable Shodan lookup (requires SHODAN_API_KEY env var)"),
flagSet.BoolVar(&settings.SecurityTrails, "securitytrails", false, "Enable SecurityTrails domain discovery (requires SECURITYTRAILS_API_KEY env var)"),
flagSet.BoolVar(&settings.SQL, "sql", false, "Enable SQL reconnaissance (admin panels, error disclosure)"),
flagSet.BoolVar(&settings.LFI, "lfi", false, "Enable LFI (Local File Inclusion) reconnaissance"),
flagSet.BoolVar(&settings.JWT, "jwt", false, "Enable JWT discovery + offline weakness analysis"),
flagSet.BoolVar(&settings.OpenAPI, "openapi", false, "Enable OpenAPI/Swagger spec exposure probe"),
flagSet.BoolVar(&settings.Favicon, "favicon", false, "Enable favicon hash fingerprinting (shodan-style)"),
flagSet.BoolVar(&settings.CORS, "cors", false, "Enable CORS misconfiguration probe"),
flagSet.BoolVar(&settings.Redirect, "redirect", false, "Enable open redirect probe"),
flagSet.BoolVar(&settings.XSS, "xss", false, "Enable reflected XSS probe"),
flagSet.BoolVar(&settings.Framework, "framework", false, "Enable framework detection"),
flagSet.BoolVar(&settings.Crawl, "crawl", false, "Enable web crawling (spider same-host links/scripts/forms)"),
flagSet.IntVar(&settings.CrawlDepth, "crawl-depth", defaultCrawlDepth, "Max crawl recursion depth"),
flagSet.BoolVar(&settings.Passive, "passive", false, "Enable passive subdomain/url discovery (zero traffic to target)"),
flagSet.BoolVar(&settings.Probe, "probe", false, "Probe the target for liveness (status, title, server, redirect chain)"),
)
flagSet.CreateGroup("runtime", "Runtime",
@@ -172,27 +105,6 @@ func Parse() *Settings {
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"),
)
@@ -208,11 +120,5 @@ func Parse() *Settings {
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
}
+1 -9
View File
@@ -4,7 +4,7 @@
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: (c) 2022-2025 vmfunc (vmfunc), xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
@@ -61,14 +61,6 @@ func TestSettingsDefaults(t *testing.T) {
if settings.Ports != "" {
t.Errorf("expected Ports default to be empty, got %v", settings.Ports)
}
// diff mode is opt-in and its store dir defaults empty (resolved at runtime).
if settings.Diff != false {
t.Errorf("expected Diff default to be false, got %v", settings.Diff)
}
if settings.Store != "" {
t.Errorf("expected Store default to be empty, got %v", settings.Store)
}
}
func TestSettingsNoScanBehavior(t *testing.T) {
-270
View File
@@ -1,270 +0,0 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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
@@ -1,176 +0,0 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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
@@ -1,730 +0,0 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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
@@ -1,383 +0,0 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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
@@ -1,48 +0,0 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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
@@ -1,78 +0,0 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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
@@ -1,84 +0,0 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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
@@ -1,258 +0,0 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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
@@ -1,491 +0,0 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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()
}
+4 -7
View File
@@ -4,7 +4,7 @@
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: (c) 2022-2025 vmfunc (vmfunc), xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
@@ -37,7 +37,7 @@ var defaultLogger = &Logger{
// Init creates the log directory if it doesn't exist.
func Init(dir string) error {
if _, err := os.Stat(dir); os.IsNotExist(err) {
if err := os.Mkdir(dir, 0o750); err != nil {
if err = os.Mkdir(dir, 0755); err != nil {
return err
}
}
@@ -62,7 +62,7 @@ func (l *Logger) getWriter(path string) (*bufio.Writer, error) {
return w, nil
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
return nil, err
}
@@ -124,10 +124,7 @@ func (l *Logger) Close() error {
// CreateFile initializes a log file for the given URL and writes the header.
func CreateFile(logFiles *[]string, url string, dir string) error {
sanitizedURL := url
if _, after, ok := strings.Cut(url, "://"); ok {
sanitizedURL = after
}
sanitizedURL := strings.Split(url, "://")[1]
path := filepath.Join(dir, sanitizedURL+".log")
header := fmt.Sprintf(" _____________\n__________(_)__ __/\n__ ___/_ /__ /_ \n_(__ )_ / _ __/ \n/____/ /_/ /_/ \n\nsif log file for %s\nhttps://sif.sh\n\n", url)
+1 -1
View File
@@ -4,7 +4,7 @@
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: (c) 2022-2025 vmfunc (vmfunc), xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
+23 -22
View File
@@ -4,7 +4,7 @@
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: (c) 2022-2025 vmfunc (vmfunc), xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
@@ -14,7 +14,6 @@ package modules
import (
"context"
"errors"
"fmt"
"io"
"net/http"
@@ -27,11 +26,6 @@ import (
// 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
@@ -234,9 +228,9 @@ func checkMatchers(matchers []Matcher, resp *http.Response, body string) bool {
}
// Default to AND condition across matchers
for i := range matchers {
matched := checkMatcher(&matchers[i], resp, body)
if matchers[i].Negative {
for _, m := range matchers {
matched := checkMatcher(m, resp, body)
if m.Negative {
matched = !matched
}
if !matched {
@@ -248,7 +242,7 @@ func checkMatchers(matchers []Matcher, resp *http.Response, body string) bool {
}
// checkMatcher evaluates a single matcher.
func checkMatcher(m *Matcher, resp *http.Response, body string) bool {
func checkMatcher(m Matcher, resp *http.Response, body string) bool {
part := getPart(m.Part, resp, body)
switch m.Type {
@@ -358,7 +352,8 @@ func runExtractors(extractors []Extractor, resp *http.Response, body string) map
for _, e := range extractors {
part := getPart(e.Part, resp, body)
if e.Type == "regex" {
switch e.Type {
case "regex":
for _, pattern := range e.Regex {
re, err := regexp.Compile(pattern)
if err != nil {
@@ -385,16 +380,22 @@ func truncateEvidence(s string) string {
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)
// ExecuteDNSModule runs a DNS-based module (stub for now).
func ExecuteDNSModule(ctx context.Context, target string, def *YAMLModule, opts Options) (*Result, error) {
// TODO: Implement DNS module execution
return &Result{
ModuleID: def.ID,
Target: target,
Findings: []Finding{},
}, nil
}
// 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)
// ExecuteTCPModule runs a TCP-based module (stub for now).
func ExecuteTCPModule(ctx context.Context, target string, def *YAMLModule, opts Options) (*Result, error) {
// TODO: Implement TCP module execution
return &Result{
ModuleID: def.ID,
Target: target,
Findings: []Finding{},
}, nil
}
-270
View File
@@ -1,270 +0,0 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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:])
}
}
+1 -1
View File
@@ -4,7 +4,7 @@
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: (c) 2022-2025 vmfunc (vmfunc), xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
-269
View File
@@ -1,269 +0,0 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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
@@ -1,465 +0,0 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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))
}
})
}
+1 -1
View File
@@ -4,7 +4,7 @@
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: (c) 2022-2025 vmfunc (vmfunc), xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
+1 -1
View File
@@ -4,7 +4,7 @@
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: (c) 2022-2025 vmfunc (vmfunc), xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
+2 -2
View File
@@ -4,7 +4,7 @@
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: (c) 2022-2025 vmfunc (vmfunc), xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
@@ -16,7 +16,7 @@
: SIF - Blazing-fast pentesting suite :
: Blaze - BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: (c) 2022-2025 vmfunc (vmfunc), xyzeva, :
: lunchcat alumni & contributors :
: :
-------------------------------------------------------------------------------------------------
-119
View File
@@ -1,119 +0,0 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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
@@ -1,153 +0,0 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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
@@ -1,39 +0,0 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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
@@ -1,74 +0,0 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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
@@ -1,85 +0,0 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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
@@ -1,224 +0,0 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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
@@ -1,45 +0,0 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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
@@ -1,48 +0,0 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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
@@ -1,65 +0,0 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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)
}
+11 -9
View File
@@ -4,7 +4,7 @@
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: (c) 2022-2025 vmfunc (vmfunc), xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
@@ -13,23 +13,25 @@
package format
import (
"fmt"
"github.com/dropalldatabases/sif/internal/styles"
nucleiout "github.com/projectdiscovery/nuclei/v3/pkg/output"
"github.com/projectdiscovery/nuclei/v2/pkg/output"
)
func FormatLine(event *nucleiout.ResultEvent) string {
line := event.TemplateID
func FormatLine(event *output.ResultEvent) string {
output := event.TemplateID
if event.MatcherName != "" {
line += ":" + styles.Highlight.Render(event.MatcherName)
output += ":" + styles.Highlight.Render(event.MatcherName)
} else if event.ExtractorName != "" {
line += ":" + styles.Highlight.Render(event.ExtractorName)
output += ":" + styles.Highlight.Render(event.ExtractorName)
}
line += " [" + event.Type + "]"
line += " [" + formatSeverity(event.Info.SeverityHolder.Severity.String()) + "]"
output += " [" + event.Type + "]"
output += " [" + formatSeverity(fmt.Sprintf("%s", event.Info.SeverityHolder.Severity)) + "]"
return line
return output
}
func formatSeverity(severity string) string {
+7 -33
View File
@@ -4,7 +4,7 @@
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: (c) 2022-2025 vmfunc (vmfunc), xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
@@ -15,14 +15,11 @@ package templates
import (
"archive/tar"
"compress/gzip"
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/charmbracelet/log"
)
@@ -40,12 +37,7 @@ func Install(logger *log.Logger) error {
logger.Infof("nuclei-templates directory not found. Installing...")
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)
resp, err := http.Get(fmt.Sprintf(archive, ref))
if err != nil {
return err
}
@@ -55,55 +47,37 @@ func Install(logger *log.Logger) error {
if err != nil {
return err
}
defer func() {
if cerr := tarball.Close(); cerr != nil {
logger.Warnf("closing gzip reader: %v", cerr)
}
}()
defer tarball.Close()
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(err, io.EOF) {
if errors.Is(io.EOF, err) {
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(target, 0o750); err != nil {
if err := os.Mkdir(header.Name, 0755); err != nil {
return err
}
case tar.TypeReg:
file, err := os.Create(target)
file, err := os.Create(header.Name)
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
}
+26 -62
View File
@@ -4,7 +4,7 @@
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: (c) 2022-2025 vmfunc (vmfunc), xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
@@ -14,7 +14,6 @@ package output
import (
"fmt"
"io"
"os"
"strings"
@@ -127,47 +126,13 @@ 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)
fmt.Printf("%s %s\n", prefixInfo.Render("[*]"), msg)
}
// Success prints a success message with [+] prefix
@@ -176,7 +141,7 @@ func Success(format string, args ...interface{}) {
return
}
msg := fmt.Sprintf(format, args...)
fmt.Fprintf(sink, "%s %s\n", prefixSuccess.Render("[+]"), msg)
fmt.Printf("%s %s\n", prefixSuccess.Render("[+]"), msg)
}
// Warn prints a warning message with [!] prefix
@@ -185,7 +150,7 @@ func Warn(format string, args ...interface{}) {
return
}
msg := fmt.Sprintf(format, args...)
fmt.Fprintf(sink, "%s %s\n", prefixWarning.Render("[!]"), msg)
fmt.Printf("%s %s\n", prefixWarning.Render("[!]"), msg)
}
// Error prints an error message with [-] prefix
@@ -194,7 +159,7 @@ func Error(format string, args ...interface{}) {
return
}
msg := fmt.Sprintf(format, args...)
fmt.Fprintf(sink, "%s %s\n", prefixError.Render("[-]"), msg)
fmt.Printf("%s %s\n", prefixError.Render("[-]"), msg)
}
// ScanStart prints a styled scan start message
@@ -202,7 +167,7 @@ func ScanStart(scanName string) {
if apiMode {
return
}
fmt.Fprintf(sink, "%s starting %s\n", prefixInfo.Render("[*]"), scanName)
fmt.Printf("%s starting %s\n", prefixInfo.Render("[*]"), scanName)
}
// ScanComplete prints a styled scan completion message
@@ -210,7 +175,7 @@ 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)
fmt.Printf("%s %s complete (%d %s)\n", prefixInfo.Render("[*]"), scanName, resultCount, resultType)
}
// Module creates a prefixed logger for a specific module/tool
@@ -237,7 +202,7 @@ func (m *ModuleLogger) Info(format string, args ...interface{}) {
return
}
msg := fmt.Sprintf(format, args...)
fmt.Fprintf(sink, "%s %s\n", m.prefix(), msg)
fmt.Printf("%s %s\n", m.prefix(), msg)
}
// Success prints a success message with module prefix
@@ -246,7 +211,7 @@ func (m *ModuleLogger) Success(format string, args ...interface{}) {
return
}
msg := fmt.Sprintf(format, args...)
fmt.Fprintf(sink, "%s %s %s\n", m.prefix(), prefixSuccess.Render("✓"), msg)
fmt.Printf("%s %s %s\n", m.prefix(), prefixSuccess.Render("✓"), msg)
}
// Warn prints a warning message with module prefix
@@ -255,7 +220,7 @@ func (m *ModuleLogger) Warn(format string, args ...interface{}) {
return
}
msg := fmt.Sprintf(format, args...)
fmt.Fprintf(sink, "%s %s %s\n", m.prefix(), prefixWarning.Render("!"), msg)
fmt.Printf("%s %s %s\n", m.prefix(), prefixWarning.Render("!"), msg)
}
// Error prints an error message with module prefix
@@ -264,7 +229,7 @@ func (m *ModuleLogger) Error(format string, args ...interface{}) {
return
}
msg := fmt.Sprintf(format, args...)
fmt.Fprintf(sink, "%s %s %s\n", m.prefix(), prefixError.Render("✗"), msg)
fmt.Printf("%s %s %s\n", m.prefix(), prefixError.Render("✗"), msg)
}
// Start prints a scan start message with module prefix (adds newline before for separation)
@@ -272,7 +237,7 @@ func (m *ModuleLogger) Start() {
if apiMode {
return
}
fmt.Fprintf(sink, "\n%s starting scan\n", m.prefix())
fmt.Printf("\n%s starting scan\n", m.prefix())
}
// Complete prints a scan complete message with module prefix
@@ -280,16 +245,15 @@ func (m *ModuleLogger) Complete(resultCount int, resultType string) {
if apiMode {
return
}
fmt.Fprintf(sink, "%s complete (%d %s)\n", m.prefix(), resultCount, resultType)
fmt.Printf("%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.
// ClearLine clears the current line (for progress bar updates)
func ClearLine() {
if !IsTTY || silent {
if !IsTTY {
return
}
fmt.Fprint(sink, "\033[2K\r")
fmt.Print("\033[2K\r")
}
// Summary styles
@@ -310,22 +274,22 @@ func PrintSummary(scans []string, logFiles []string) {
return
}
fmt.Fprintln(sink)
fmt.Fprintln(sink, summaryLine.Render("────────────────────────────────────────────────────────────"))
fmt.Fprintln(sink)
fmt.Fprintf(sink, " %s\n", summaryHeader.Render("SCAN COMPLETE"))
fmt.Fprintln(sink)
fmt.Println()
fmt.Println(summaryLine.Render("────────────────────────────────────────────────────────────"))
fmt.Println()
fmt.Printf(" %s\n", summaryHeader.Render("SCAN COMPLETE"))
fmt.Println()
// Print scans
scanList := strings.Join(scans, ", ")
fmt.Fprintf(sink, " %s %s\n", Muted.Render("Scans:"), scanList)
fmt.Printf(" %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.Printf(" %s %s\n", Muted.Render("Output:"), strings.Join(logFiles, ", "))
}
fmt.Fprintln(sink)
fmt.Fprintln(sink, summaryLine.Render("────────────────────────────────────────────────────────────"))
fmt.Fprintln(sink)
fmt.Println()
fmt.Println(summaryLine.Render("────────────────────────────────────────────────────────────"))
fmt.Println()
}
+15 -42
View File
@@ -4,7 +4,7 @@
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: (c) 2022-2025 vmfunc (vmfunc), xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
@@ -28,13 +28,12 @@ const (
// 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
total int64
current int64
message string
lastItem string
mu sync.Mutex
paused bool
}
// NewProgress creates a new progress bar
@@ -98,7 +97,7 @@ func (p *Progress) Done() {
}
func (p *Progress) render() {
if apiMode || silent {
if apiMode {
return
}
@@ -106,36 +105,11 @@ func (p *Progress) render() {
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)
// Print at 0%, 25%, 50%, 75%, 100%
if current == 1 || percent == 25 || percent == 50 || percent == 75 || current == total {
fmt.Printf(" [%d%%] %d/%d\n", percent, current, total)
}
return
}
@@ -164,12 +138,11 @@ func (p *Progress) render() {
bar := ""
for i := 0; i < progressWidth; i++ {
switch {
case i < filled:
if i < filled {
bar += progressFilled
case i == filled && current < total:
} else if i == filled && current < total {
bar += progressCurrent
default:
} else {
bar += progressEmpty
}
}
@@ -190,5 +163,5 @@ func (p *Progress) render() {
)
ClearLine()
fmt.Fprint(sink, line)
fmt.Print(line)
}
-96
View File
@@ -1,96 +0,0 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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
@@ -1,113 +0,0 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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
}
+7 -6
View File
@@ -4,7 +4,7 @@
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: (c) 2022-2025 vmfunc (vmfunc), xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
@@ -14,6 +14,7 @@ package output
import (
"fmt"
"os"
"sync"
"time"
)
@@ -41,7 +42,7 @@ func NewSpinner(message string) *Spinner {
// Start begins the spinner animation
func (s *Spinner) Start() {
if apiMode || silent {
if apiMode {
return
}
@@ -56,7 +57,7 @@ func (s *Spinner) Start() {
// In non-TTY mode, just print the message once
if !IsTTY {
fmt.Fprintf(sink, " %s...\n", s.message)
fmt.Printf(" %s...\n", s.message)
return
}
@@ -65,7 +66,7 @@ func (s *Spinner) Start() {
// Stop halts the spinner and clears the line
func (s *Spinner) Stop() {
if apiMode || silent {
if apiMode {
return
}
@@ -111,8 +112,8 @@ func (s *Spinner) animate() {
spinnerChar := prefixInfo.Render(spinnerFrames[frame])
line := fmt.Sprintf("\r %s %s", spinnerChar, msg)
fmt.Fprint(sink, "\033[2K") // Clear line
fmt.Fprint(sink, line)
fmt.Fprint(os.Stdout, "\033[2K") // Clear line
fmt.Fprint(os.Stdout, line)
frame = (frame + 1) % len(spinnerFrames)
}
-145
View File
@@ -1,145 +0,0 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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
@@ -1,42 +0,0 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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
@@ -1,57 +0,0 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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
@@ -1,145 +0,0 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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
@@ -1,74 +0,0 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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
@@ -1,26 +0,0 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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
@@ -1,172 +0,0 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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
@@ -1,133 +0,0 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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))
}
+2 -3
View File
@@ -4,7 +4,7 @@
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: (c) 2022-2025 vmfunc (vmfunc), xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
@@ -15,10 +15,9 @@ package builtin
import (
"context"
"fmt"
"strings"
"github.com/dropalldatabases/sif/internal/modules"
"github.com/dropalldatabases/sif/internal/scan/frameworks"
"strings"
)
type FrameworksModule struct{}
+2 -4
View File
@@ -4,7 +4,7 @@
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: (c) 2022-2025 vmfunc (vmfunc), xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
@@ -15,7 +15,6 @@ package builtin
import (
"context"
"fmt"
"github.com/dropalldatabases/sif/internal/modules"
"github.com/dropalldatabases/sif/internal/scan"
)
@@ -52,8 +51,7 @@ func (m *NucleiModule) Execute(ctx context.Context, target string, opts modules.
}
// Process nuclei results into module findings
for i := range nucleiResults {
event := &nucleiResults[i]
for _, event := range nucleiResults {
severity := "info"
switch event.Info.SeverityHolder.Severity.String() {
+1 -3
View File
@@ -4,7 +4,7 @@
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: (c) 2022-2025 vmfunc (vmfunc), xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
@@ -17,9 +17,7 @@ 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{})
}
@@ -1,79 +0,0 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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
@@ -1,158 +0,0 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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
}
+1 -2
View File
@@ -4,7 +4,7 @@
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: (c) 2022-2025 vmfunc (vmfunc), xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
@@ -14,7 +14,6 @@ package builtin
import (
"context"
"github.com/dropalldatabases/sif/internal/modules"
"github.com/dropalldatabases/sif/internal/scan"
)
+20 -25
View File
@@ -4,7 +4,7 @@
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: (c) 2022-2025 vmfunc (vmfunc), xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
@@ -13,7 +13,6 @@
package scan
import (
"context"
"fmt"
"net/http"
"os"
@@ -21,24 +20,19 @@ import (
"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") + "..."))
fmt.Println(styles.Separator.Render("☁️ Starting " + styles.Status.Render("Cloud Storage Misconfiguration Scan") + "..."))
sanitizedURL := stripScheme(url)
sanitizedURL := strings.Split(url, "://")[1]
if logdir != "" {
if err := logger.WriteHeader(sanitizedURL, logdir, "Cloud Storage Misconfiguration Scan"); err != nil {
@@ -48,17 +42,19 @@ func CloudStorage(url string, timeout time.Duration, logdir string) ([]CloudStor
}
cloudlog := log.NewWithOptions(os.Stderr, log.Options{
Prefix: "C3",
Prefix: "C3 ☁️",
}).With("url", url)
client := httpx.Client(timeout)
client := &http.Client{
Timeout: timeout,
}
potentialBuckets := extractPotentialBuckets(sanitizedURL)
var results []CloudStorageResult
for _, bucket := range potentialBuckets {
isPublic, err := checkS3Bucket(context.TODO(), bucket, client)
isPublic, err := checkS3Bucket(bucket, client)
if err != nil {
cloudlog.Errorf("Error checking S3 bucket %s: %v", bucket, err)
continue
@@ -73,7 +69,7 @@ func CloudStorage(url string, timeout time.Duration, logdir string) ([]CloudStor
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))
logger.Write(sanitizedURL, logdir, fmt.Sprintf("Public S3 bucket found: %s\n", bucket))
}
} else {
cloudlog.Infof("S3 bucket is not public/found: %s", bucket)
@@ -84,32 +80,31 @@ func CloudStorage(url string, timeout time.Duration, logdir string) ([]CloudStor
}
func extractPotentialBuckets(url string) []string {
// TODO: handle non-adjacent label combos and strip the tld
// This is a simple implementation.
// TODO: add more cases
parts := strings.Split(url, ".")
var buckets []string
for i, part := range parts {
buckets = append(buckets, part, part+"-s3", "s3-"+part)
buckets = append(buckets, part)
buckets = append(buckets, part+"-s3")
buckets = append(buckets, "s3-"+part)
if i < len(parts)-1 {
domainExtension := part + "-" + parts[i+1]
buckets = append(buckets, domainExtension, parts[i+1]+"-"+part)
buckets = append(buckets, domainExtension)
buckets = append(buckets, 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)
func checkS3Bucket(bucket string, client *http.Client) (bool, error) {
url := fmt.Sprintf("https://%s.s3.amazonaws.com", bucket)
resp, err := client.Get(url)
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)
defer resp.Body.Close()
// If we can access the bucket listing, it's public
return resp.StatusCode == http.StatusOK, nil
+8 -19
View File
@@ -4,7 +4,7 @@
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: (c) 2022-2025 vmfunc (vmfunc), xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
@@ -13,13 +13,11 @@
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"
)
@@ -36,7 +34,7 @@ func CMS(url string, timeout time.Duration, logdir string) (*CMSResult, error) {
spin := output.NewSpinner("Detecting content management system")
spin.Start()
sanitizedURL := stripScheme(url)
sanitizedURL := strings.Split(url, "://")[1]
if logdir != "" {
if err := logger.WriteHeader(sanitizedURL, logdir, "CMS detection"); err != nil {
@@ -46,15 +44,11 @@ func CMS(url string, timeout time.Duration, logdir string) (*CMSResult, error) {
}
}
client := httpx.Client(timeout)
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, http.NoBody)
if err != nil {
spin.Stop()
return nil, err
client := &http.Client{
Timeout: timeout,
}
resp, err := client.Do(req)
resp, err := client.Get(url)
if err != nil {
spin.Stop()
return nil, err
@@ -98,7 +92,7 @@ func CMS(url string, timeout time.Duration, logdir string) (*CMSResult, error) {
spin.Stop()
log.Info("No CMS detected")
log.Complete(0, "detected")
return nil, nil //nolint:nilnil // no CMS found is not an error
return nil, nil
}
func detectWordPress(url string, client *http.Client, bodyString string) bool {
@@ -124,15 +118,10 @@ func detectWordPress(url string, client *http.Client, bodyString string) bool {
}
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
resp, err := client.Get(url + file)
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)
resp.Body.Close()
if found {
return true
}
-236
View File
@@ -1,236 +0,0 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · 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)
-140
View File
@@ -1,140 +0,0 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package scan
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
// reflectingCORS echoes the Origin into Access-Control-Allow-Origin and sets
// credentials, the exploitable misconfiguration.
func reflectingCORS() *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if origin := r.Header.Get("Origin"); origin != "" {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
}
w.WriteHeader(http.StatusOK)
}))
}
func TestCORS_ReflectsArbitraryOrigin(t *testing.T) {
srv := reflectingCORS()
defer srv.Close()
result, err := CORS(srv.URL, 5*time.Second, 3, "")
if err != nil {
t.Fatalf("CORS: %v", err)
}
if result == nil || len(result.Findings) == 0 {
t.Fatalf("expected cors findings on reflecting server, got %+v", result)
}
// the reflecting server echoes every crafted origin with credentials,
// so each finding should be high severity.
var sawEvil bool
for _, f := range result.Findings {
if f.OriginTested == corsEvilOrigin {
sawEvil = true
if !f.AllowCredentials {
t.Errorf("expected credentials flagged for evil origin, got %+v", f)
}
if f.Severity != "high" {
t.Errorf("expected high severity for reflection+creds, got %s", f.Severity)
}
}
}
if !sawEvil {
t.Errorf("expected the sentinel evil origin to be reflected, got %+v", result.Findings)
}
}
func TestCORS_SeverityWithoutCredentials(t *testing.T) {
// reflects the origin but never grants credentials - medium, not high.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if origin := r.Header.Get("Origin"); origin != "" {
w.Header().Set("Access-Control-Allow-Origin", origin)
}
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
result, err := CORS(srv.URL, 5*time.Second, 3, "")
if err != nil {
t.Fatalf("CORS: %v", err)
}
if result == nil || len(result.Findings) == 0 {
t.Fatalf("expected reflection findings, got %+v", result)
}
for _, f := range result.Findings {
if f.AllowCredentials {
t.Errorf("did not expect credentials, got %+v", f)
}
if f.Severity != "medium" {
t.Errorf("expected medium severity without creds, got %s", f.Severity)
}
}
}
func TestCORS_NoFalsePositiveOnSafeServer(t *testing.T) {
tests := []struct {
name string
handler http.HandlerFunc
}{
{
name: "ignores origin entirely",
handler: func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
},
},
{
name: "returns its own fixed origin",
handler: func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "https://trusted.example.com")
w.WriteHeader(http.StatusOK)
},
},
{
name: "plain wildcard, no credentials",
handler: func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.WriteHeader(http.StatusOK)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
srv := httptest.NewServer(tt.handler)
defer srv.Close()
result, err := CORS(srv.URL, 5*time.Second, 3, "")
if err != nil {
t.Fatalf("CORS: %v", err)
}
if result != nil && len(result.Findings) > 0 {
t.Errorf("expected no findings on safe server, got %+v", result.Findings)
}
})
}
}
func TestCORSResult_ResultType(t *testing.T) {
r := &CORSResult{}
if r.ResultType() != "cors" {
t.Errorf("expected result type 'cors', got %q", r.ResultType())
}
}
-137
View File
@@ -1,137 +0,0 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package scan
import (
"fmt"
"net/url"
"sort"
"sync"
"time"
"github.com/gocolly/colly/v2"
"github.com/dropalldatabases/sif/internal/httpx"
"github.com/dropalldatabases/sif/internal/logger"
"github.com/dropalldatabases/sif/internal/output"
)
// CrawlResult holds the deduped set of urls discovered by the spider.
type CrawlResult struct {
URLs []string `json:"urls"`
}
func (r *CrawlResult) ResultType() string { return "crawl" }
// compile-time check so a result-type drift fails the build, not a run.
var _ ScanResult = (*CrawlResult)(nil)
// Crawl spiders the target up to depth, following same-host links/scripts/forms.
// all traffic flows through the shared httpx client so proxy/headers/rate-limit
// apply, and robots.txt is respected (colly honors it by default).
func Crawl(targetURL string, depth int, timeout time.Duration, logdir string) (*CrawlResult, error) {
log := output.Module("CRAWL")
log.Start()
sanitizedURL := stripScheme(targetURL)
if logdir != "" {
if err := logger.WriteHeader(sanitizedURL, logdir, "web crawl"); err != nil {
log.Error("error creating log file: %v", err)
return nil, fmt.Errorf("create crawl log: %w", err)
}
}
// the host bounds the crawl; without it colly would wander the whole web.
parsed, err := url.Parse(targetURL)
if err != nil {
return nil, fmt.Errorf("parse target url %q: %w", targetURL, err)
}
host := parsed.Hostname()
if host == "" {
return nil, fmt.Errorf("target url %q has no host", targetURL)
}
collector := colly.NewCollector(
colly.MaxDepth(depth),
colly.AllowedDomains(host),
)
// reuse the shared client so proxy/cookie/-H/rate-limit are honored and the
// configured timeout applies to every fetch, robots.txt included.
collector.SetClient(httpx.Client(timeout))
// dedupe across the concurrent callbacks colly may fire.
var mu sync.Mutex
seen := make(map[string]struct{})
record := func(raw string) {
if raw == "" {
return
}
// keep the result set scoped to the target host; off-host assets
// (cdns, third-party links) are noise for an in-scope crawl.
if u, err := url.Parse(raw); err != nil || u.Hostname() != host {
return
}
mu.Lock()
if _, ok := seen[raw]; !ok {
seen[raw] = struct{}{}
log.Success("found: %s", output.Highlight.Render(raw))
if logdir != "" {
_ = logger.Write(sanitizedURL, logdir, raw+"\n")
}
}
mu.Unlock()
}
// links drive recursion; scripts/forms are recorded but not followed.
collector.OnHTML("a[href]", func(e *colly.HTMLElement) {
link := e.Request.AbsoluteURL(e.Attr("href"))
record(link)
// Visit enforces AllowedDomains/MaxDepth itself, so off-host or
// too-deep links are dropped without us re-checking.
_ = e.Request.Visit(link)
})
collector.OnHTML("script[src]", func(e *colly.HTMLElement) {
record(e.Request.AbsoluteURL(e.Attr("src")))
})
collector.OnHTML("form[action]", func(e *colly.HTMLElement) {
record(e.Request.AbsoluteURL(e.Attr("action")))
})
collector.OnError(func(_ *colly.Response, e error) {
// a single bad page shouldn't abort the crawl; note it and move on.
log.Warn("crawl error: %v", e)
})
if err := collector.Visit(targetURL); err != nil {
log.Error("crawl failed: %v", err)
return nil, fmt.Errorf("visit %q: %w", targetURL, err)
}
collector.Wait()
result := &CrawlResult{URLs: sortedKeys(seen)}
log.Complete(len(result.URLs), "urls")
return result, nil
}
// sortedKeys returns the map keys in a stable order so output is deterministic.
func sortedKeys(set map[string]struct{}) []string {
keys := make([]string, 0, len(set))
for k := range set {
keys = append(keys, k)
}
sort.Strings(keys)
return keys
}
-158
View File
@@ -1,158 +0,0 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package scan
import (
"net/http"
"net/http/httptest"
"testing"
"time"
)
// crawlSite serves a small link graph:
//
// / -> links /a and an off-host page; references script.js, form action /submit
// /a -> links /b
// /b -> links /c (only reachable at depth 3)
// /c -> leaf
func crawlSite(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
// no robots restrictions; colly fetches this before crawling.
mux.HandleFunc("/robots.txt", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
_, _ = w.Write([]byte(`<html><body>
<a href="/a">a</a>
<a href="https://off-host.example/x">off</a>
<script src="/script.js"></script>
<form action="/submit"></form>
</body></html>`))
})
mux.HandleFunc("/a", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`<a href="/b">b</a>`))
})
mux.HandleFunc("/b", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`<a href="/c">c</a>`))
})
mux.HandleFunc("/c", func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`leaf`))
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
func urlsContain(urls []string, want string) bool {
for i := 0; i < len(urls); i++ {
if urls[i] == want {
return true
}
}
return false
}
func TestCrawl_FindsLinkedPagesAndAssets(t *testing.T) {
srv := crawlSite(t)
result, err := Crawl(srv.URL, 3, 5*time.Second, "")
if err != nil {
t.Fatalf("Crawl: %v", err)
}
// links, scripts and forms must all be recorded, resolved to absolute urls.
wants := []string{
srv.URL + "/a",
srv.URL + "/b",
srv.URL + "/c",
srv.URL + "/script.js",
srv.URL + "/submit",
}
for _, w := range wants {
if !urlsContain(result.URLs, w) {
t.Errorf("expected crawl to find %q, got %v", w, result.URLs)
}
}
// AllowedDomains must keep the off-host link out of the result set.
if urlsContain(result.URLs, "https://off-host.example/x") {
t.Errorf("off-host link should be excluded, got %v", result.URLs)
}
}
func TestCrawl_RespectsDepth(t *testing.T) {
srv := crawlSite(t)
// depth 1: only links found on the root page (/a, /script.js, /submit) are
// recorded; /b lives one hop deeper and must not appear.
result, err := Crawl(srv.URL, 1, 5*time.Second, "")
if err != nil {
t.Fatalf("Crawl: %v", err)
}
if !urlsContain(result.URLs, srv.URL+"/a") {
t.Errorf("depth 1 should find /a, got %v", result.URLs)
}
if urlsContain(result.URLs, srv.URL+"/b") {
t.Errorf("depth 1 must not reach /b, got %v", result.URLs)
}
if urlsContain(result.URLs, srv.URL+"/c") {
t.Errorf("depth 1 must not reach /c, got %v", result.URLs)
}
}
func TestCrawl_Dedupes(t *testing.T) {
// a page that links the same target twice must yield a single entry.
mux := http.NewServeMux()
mux.HandleFunc("/robots.txt", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/dup" {
_, _ = w.Write([]byte(`leaf`))
return
}
_, _ = w.Write([]byte(`<a href="/dup">1</a><a href="/dup">2</a>`))
})
srv := httptest.NewServer(mux)
defer srv.Close()
result, err := Crawl(srv.URL, 2, 5*time.Second, "")
if err != nil {
t.Fatalf("Crawl: %v", err)
}
count := 0
for _, u := range result.URLs {
if u == srv.URL+"/dup" {
count++
}
}
if count != 1 {
t.Errorf("expected /dup once after dedupe, got %d in %v", count, result.URLs)
}
}
func TestCrawl_ResultType(t *testing.T) {
r := &CrawlResult{}
if r.ResultType() != "crawl" {
t.Errorf("ResultType = %q, want crawl", r.ResultType())
}
}
+64 -400
View File
@@ -4,7 +4,7 @@
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: (c) 2022-2025 vmfunc (vmfunc), xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
@@ -14,373 +14,36 @@ package scan
import (
"bufio"
"context"
"fmt"
"io"
"net/http"
"os"
"regexp"
"strconv"
"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"
"github.com/dropalldatabases/sif/internal/pool"
)
// directoryURL is a var so integration tests can repoint it at a fixture.
var directoryURL = "https://raw.githubusercontent.com/dropalldatabases/sif-runtime/main/dirlist/"
const (
smallFile = "directory-list-2.3-small.txt"
mediumFile = "directory-list-2.3-medium.txt"
bigFile = "directory-list-2.3-big.txt"
)
// dirlistBodyCap bounds how many bytes we read per response before computing
// size/word counts. modern apps stream large html; capping keeps memory flat
// and makes size/word matching deterministic against arbitrarily large bodies.
const dirlistBodyCap = 512 * 1024
// soft-404 calibration probes. we ask for a handful of deterministic paths that
// cannot exist, then treat any response shape they share as the wildcard
// baseline. deterministic (no rng) so the workflow stays reproducible.
const (
calibrationProbes = 3
calibrationPrefix = "/sif-cal-"
)
// statusNotFound / statusForbidden are the historical default "not interesting"
// codes; they seed the filter set when no explicit -mc/-fc is given.
const (
statusNotFound = 404
statusForbidden = 403
directoryURL = "https://raw.githubusercontent.com/dropalldatabases/sif-runtime/main/dirlist/"
smallFile = "directory-list-2.3-small.txt"
mediumFile = "directory-list-2.3-medium.txt"
bigFile = "directory-list-2.3-big.txt"
)
type DirectoryResult struct {
Url string `json:"url"`
StatusCode int `json:"status_code"`
Size int `json:"size"`
Words int `json:"words"`
}
// DirlistOptions carries the ffuf-style matcher knobs. the zero value reproduces
// the legacy behavior (report everything that isn't 404/403), so callers that
// don't set anything keep the old defaults.
type DirlistOptions struct {
MatchCodes string // -mc comma list of status codes to keep
FilterCodes string // -fc comma list of status codes to drop
FilterSizes string // -fs comma list of body sizes to drop
FilterWords string // -fw comma list of word counts to drop
FilterRegex string // -fr regex; a body match drops the response
Calibrate bool // -ac auto-calibrate the soft-404 wildcard baseline
Wordlist string // -w local path or url; overrides the size switch
Extensions string // -e comma list appended to each word (php,bak,env)
}
// responseMeta is the shape we match on: just enough of the response to decide
// keep/drop without holding the whole body.
type responseMeta struct {
status int
size int
words int
}
// matcher decides whether a response is "interesting" using the same precedence
// as ffuf/feroxbuster: an explicit filter (-fc/-fs/-fw/-fr or a calibrated
// baseline) drops the response, otherwise the match-code set decides.
type matcher struct {
matchCodes map[int]struct{}
filterCodes map[int]struct{}
filterSizes map[int]struct{}
filterWords map[int]struct{}
filterRe *regexp.Regexp
baselines []responseMeta // calibrated soft-404 shapes to suppress
}
// newMatcher builds the matcher from raw flag strings. when -mc is empty the
// match set is left nil, which Matches reads as "keep anything not explicitly
// filtered" - i.e. the legacy behavior minus the hardcoded 404/403, which move
// into the filter set instead.
func newMatcher(opts *DirlistOptions) (*matcher, error) {
m := &matcher{
filterSizes: make(map[int]struct{}),
filterWords: make(map[int]struct{}),
}
codes, err := parseIntSet(opts.MatchCodes)
if err != nil {
return nil, fmt.Errorf("parse -mc: %w", err)
}
m.matchCodes = codes
m.filterCodes, err = parseIntSet(opts.FilterCodes)
if err != nil {
return nil, fmt.Errorf("parse -fc: %w", err)
}
// no explicit match set means we fall back to the historical "drop 404/403"
// behavior; encode it as filters so the rest of the logic is uniform.
if len(m.matchCodes) == 0 && len(m.filterCodes) == 0 {
m.filterCodes[statusNotFound] = struct{}{}
m.filterCodes[statusForbidden] = struct{}{}
}
m.filterSizes, err = parseIntSet(opts.FilterSizes)
if err != nil {
return nil, fmt.Errorf("parse -fs: %w", err)
}
m.filterWords, err = parseIntSet(opts.FilterWords)
if err != nil {
return nil, fmt.Errorf("parse -fw: %w", err)
}
if opts.FilterRegex != "" {
re, err := regexp.Compile(opts.FilterRegex)
if err != nil {
return nil, fmt.Errorf("parse -fr: %w", err)
}
m.filterRe = re
}
return m, nil
}
// Matches reports whether the response should surface as a finding. filters win
// over matches: a calibrated baseline, an -fc/-fs/-fw hit, or an -fr body match
// always drops the response; otherwise the -mc set (when set) gates it.
func (m *matcher) Matches(meta responseMeta, body []byte) bool {
// a calibrated soft-404 shape is the same response the catch-all hands every
// bogus path, so drop anything that matches a baseline exactly.
for i := 0; i < len(m.baselines); i++ {
b := m.baselines[i]
if b.status == meta.status && b.size == meta.size && b.words == meta.words {
return false
}
}
if _, drop := m.filterCodes[meta.status]; drop {
return false
}
if _, drop := m.filterSizes[meta.size]; drop {
return false
}
if _, drop := m.filterWords[meta.words]; drop {
return false
}
if m.filterRe != nil && m.filterRe.Match(body) {
return false
}
// an explicit -mc set is allow-list semantics; without it we keep whatever
// survived the filters above.
if len(m.matchCodes) > 0 {
_, keep := m.matchCodes[meta.status]
return keep
}
return true
}
// parseIntSet turns a comma list like "200,301,500" into a set. empty input is a
// nil set, not an error, so unset flags are a no-op.
func parseIntSet(raw string) (map[int]struct{}, error) {
set := make(map[int]struct{})
if raw == "" {
return set, nil
}
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part == "" {
continue
}
n, err := strconv.Atoi(part)
if err != nil {
return nil, fmt.Errorf("invalid integer %q: %w", part, err)
}
set[n] = struct{}{}
}
return set, nil
}
// readMeta drains the response (capped) and returns its match shape plus the
// body bytes the regex filter needs. it never returns the raw resp; callers
// close the body before this returns.
func readMeta(resp *http.Response) (responseMeta, []byte) {
body, err := io.ReadAll(io.LimitReader(resp.Body, dirlistBodyCap))
if err != nil {
// a truncated/aborted body still has a usable status; treat what we read
// as the body rather than dropping the whole response.
charmlog.Debugf("dirlist: read body: %v", err)
}
return responseMeta{
status: resp.StatusCode,
size: len(body),
words: countWords(body),
}, body
}
// countWords counts whitespace-separated tokens; the cheap proxy ffuf uses to
// tell a soft-404 stub apart from a real page of the same byte size.
func countWords(body []byte) int {
return len(strings.Fields(string(body)))
}
// expandWords appends each extension to every base word, keeping the bare word
// too. an empty extensions list returns the words unchanged.
func expandWords(words []string, extensions string) []string {
exts := splitExtensions(extensions)
if len(exts) == 0 {
return words
}
// each word yields itself plus one entry per extension.
expanded := make([]string, 0, len(words)*(len(exts)+1))
for i := 0; i < len(words); i++ {
expanded = append(expanded, words[i])
for j := 0; j < len(exts); j++ {
expanded = append(expanded, words[i]+"."+exts[j])
}
}
return expanded
}
// splitExtensions normalizes "php, .bak ,env" into ["php","bak","env"]; a
// leading dot is tolerated so both "php" and ".php" work.
func splitExtensions(raw string) []string {
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
exts := make([]string, 0, len(parts))
for i := 0; i < len(parts); i++ {
ext := strings.TrimSpace(parts[i])
ext = strings.TrimPrefix(ext, ".")
if ext != "" {
exts = append(exts, ext)
}
}
return exts
}
// loadWordlist reads the fuzzing words. a custom -w overrides the size switch:
// an http(s) value is fetched through the shared client, anything else is a
// local file. with no -w it downloads the size-selected sif-runtime list.
func loadWordlist(opts *DirlistOptions, size string, client *http.Client) ([]string, error) {
if opts.Wordlist != "" {
if strings.HasPrefix(opts.Wordlist, "http://") || strings.HasPrefix(opts.Wordlist, "https://") {
return fetchWordlist(opts.Wordlist, client)
}
return readWordlistFile(opts.Wordlist)
}
var file string
switch size {
case "small":
file = smallFile
case "medium":
file = mediumFile
case "large":
file = bigFile
default:
return nil, fmt.Errorf("unknown dirlist size %q", size)
}
return fetchWordlist(directoryURL+file, client)
}
// fetchWordlist downloads a remote wordlist through the shared client so proxy
// and rate-limit settings apply to the fetch too.
func fetchWordlist(listURL string, client *http.Client) ([]string, error) {
req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, listURL, http.NoBody)
if err != nil {
return nil, fmt.Errorf("build wordlist request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("download wordlist %q: %w", listURL, err)
}
defer resp.Body.Close()
return scanLines(resp.Body), nil
}
// readWordlistFile loads a local wordlist file.
func readWordlistFile(path string) ([]string, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open wordlist %q: %w", path, err)
}
defer f.Close()
return scanLines(f), nil
}
// scanLines reads non-empty lines into a slice.
func scanLines(r io.Reader) []string {
var lines []string
scanner := bufio.NewScanner(r)
scanner.Split(bufio.ScanLines)
for scanner.Scan() {
line := scanner.Text()
if line != "" {
lines = append(lines, line)
}
}
return lines
}
// calibrate probes a few paths that cannot exist and records the response shapes
// the catch-all hands them. those baselines feed the matcher so a soft-404 200
// (the SPA wildcard) is suppressed before the real run. deterministic by design:
// the probe paths come from the loop index, never a random source.
func calibrate(m *matcher, baseURL string, client *http.Client) {
for i := 0; i < calibrationProbes; i++ {
probe := baseURL + calibrationPrefix + strconv.Itoa(i)
req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, probe, http.NoBody)
if err != nil {
charmlog.Debugf("dirlist: build calibration request: %v", err)
continue
}
resp, err := client.Do(req)
if err != nil {
charmlog.Debugf("dirlist: calibration probe %s: %v", probe, err)
continue
}
meta, _ := readMeta(resp)
resp.Body.Close()
// a genuine hard 404 already gets filtered by code; only soft responses
// (a 200/30x catch-all) need a size/word baseline to suppress them.
if meta.status == statusNotFound {
continue
}
if !containsBaseline(m.baselines, meta) {
m.baselines = append(m.baselines, meta)
}
}
}
// containsBaseline reports whether the shape is already recorded, so repeated
// probes returning the same soft-404 don't bloat the baseline set.
func containsBaseline(baselines []responseMeta, meta responseMeta) bool {
for i := 0; i < len(baselines); i++ {
if baselines[i] == meta {
return true
}
}
return false
}
// Dirlist performs directory fuzzing on the target URL with ffuf-style response
// filtering, soft-404 calibration and custom wordlists.
//
//nolint:gocritic // opts is the scanner's stable public config; passed by value to match the other scanners' entry points.
func Dirlist(size string, url string, timeout time.Duration, threads int, logdir string, opts DirlistOptions) (DirectoryResults, error) {
// Dirlist performs directory fuzzing on the target URL.
func Dirlist(size string, url string, timeout time.Duration, threads int, logdir string) ([]DirectoryResult, error) {
log := output.Module("DIRLIST")
log.Start()
sanitizedURL := stripScheme(url)
sanitizedURL := strings.Split(url, "://")[1]
if logdir != "" {
if err := logger.WriteHeader(sanitizedURL, logdir, size+" directory fuzzing"); err != nil {
@@ -389,79 +52,80 @@ func Dirlist(size string, url string, timeout time.Duration, threads int, logdir
}
}
matcher, err := newMatcher(&opts)
if err != nil {
log.Error("invalid matcher flags: %v", err)
return nil, err
var list string
switch size {
case "small":
list = directoryURL + smallFile
case "medium":
list = directoryURL + mediumFile
case "large":
list = directoryURL + bigFile
}
client := httpx.Client(timeout)
directories, err := loadWordlist(&opts, size, client)
resp, err := http.Get(list)
if err != nil {
log.Error("Error loading directory list: %s", err)
log.Error("Error downloading directory list: %s", err)
return nil, err
}
directories = expandWords(directories, opts.Extensions)
defer resp.Body.Close()
// -ac learns the wildcard baseline before the run so catch-all 200s drop.
if opts.Calibrate {
calibrate(matcher, url, client)
if len(matcher.baselines) > 0 {
log.Info("calibrated %d soft-404 baseline(s)", len(matcher.baselines))
}
var directories []string
scanner := bufio.NewScanner(resp.Body)
scanner.Split(bufio.ScanLines)
for scanner.Scan() {
directories = append(directories, scanner.Text())
}
client := &http.Client{
Timeout: timeout,
}
progress := output.NewProgress(len(directories), "fuzzing")
var wg sync.WaitGroup
var mu sync.Mutex
wg.Add(threads)
results := make(DirectoryResults, 0, 64)
pool.Each(directories, threads, func(directory string) {
progress.Increment(directory)
results := make([]DirectoryResult, 0, 64)
for thread := 0; thread < threads; thread++ {
go func(thread int) {
defer wg.Done()
charmlog.Debugf("%s", directory)
dirReq, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, url+"/"+directory, http.NoBody)
if err != nil {
charmlog.Debugf("Error creating request for %s: %s", directory, err)
return
}
resp, err := client.Do(dirReq)
if err != nil {
charmlog.Debugf("Error %s: %s", directory, err)
return
}
for i, directory := range directories {
if i%threads != thread {
continue
}
meta, body := readMeta(resp)
reqURL := resp.Request.URL.String()
resp.Body.Close()
progress.Increment(directory)
if !matcher.Matches(meta, body) {
return
}
charmlog.Debugf("%s", directory)
resp, err := client.Get(url + "/" + directory)
if err != nil {
charmlog.Debugf("Error %s: %s", directory, err)
continue
}
progress.Pause()
log.Success("found: %s [%s] (size=%d words=%d)",
output.Highlight.Render(directory),
output.Status.Render(strconv.Itoa(meta.status)),
meta.size, meta.words)
progress.Resume()
if resp.StatusCode != 404 && resp.StatusCode != 403 {
progress.Pause()
log.Success("found: %s [%s]", output.Highlight.Render(directory), output.Status.Render(strconv.Itoa(resp.StatusCode)))
progress.Resume()
if logdir != "" {
_ = logger.Write(sanitizedURL, logdir,
fmt.Sprintf("%s [%s] size=%d words=%d\n", strconv.Itoa(meta.status), directory, meta.size, meta.words))
}
if logdir != "" {
logger.Write(sanitizedURL, logdir, fmt.Sprintf("%s [%s]\n", strconv.Itoa(resp.StatusCode), directory))
}
result := DirectoryResult{
Url: reqURL,
StatusCode: meta.status,
Size: meta.size,
Words: meta.words,
}
mu.Lock()
results = append(results, result)
mu.Unlock()
})
result := DirectoryResult{
Url: resp.Request.URL.String(),
StatusCode: resp.StatusCode,
}
mu.Lock()
results = append(results, result)
mu.Unlock()
}
}
}(thread)
}
wg.Wait()
progress.Done()
log.Complete(len(results), "found")
-360
View File
@@ -1,360 +0,0 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package scan
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
func TestMatcher_Matches(t *testing.T) {
tests := []struct {
name string
opts DirlistOptions
meta responseMeta
body string
want bool
}{
{
// default behavior: 404/403 drop, everything else surfaces
name: "default keeps 200",
opts: DirlistOptions{},
meta: responseMeta{status: 200, size: 10, words: 2},
want: true,
},
{
name: "default drops 404",
opts: DirlistOptions{},
meta: responseMeta{status: 404, size: 9, words: 1},
want: false,
},
{
name: "default drops 403",
opts: DirlistOptions{},
meta: responseMeta{status: 403, size: 9, words: 1},
want: false,
},
{
// -mc is allow-list: only listed codes survive
name: "mc allowlist keeps listed",
opts: DirlistOptions{MatchCodes: "200,301"},
meta: responseMeta{status: 301, size: 0, words: 0},
want: true,
},
{
name: "mc allowlist drops unlisted 200 already excluded",
opts: DirlistOptions{MatchCodes: "301"},
meta: responseMeta{status: 200, size: 5, words: 1},
want: false,
},
{
name: "fc drops listed code",
opts: DirlistOptions{FilterCodes: "500"},
meta: responseMeta{status: 500, size: 5, words: 1},
want: false,
},
{
// with an explicit -fc and no -mc, the implicit 404/403 filter is not
// added, so a 200 still surfaces
name: "fc leaves others",
opts: DirlistOptions{FilterCodes: "500"},
meta: responseMeta{status: 200, size: 5, words: 1},
want: true,
},
{
name: "fs drops listed size",
opts: DirlistOptions{FilterSizes: "1024"},
meta: responseMeta{status: 200, size: 1024, words: 50},
want: false,
},
{
name: "fw drops listed word count",
opts: DirlistOptions{FilterWords: "7"},
meta: responseMeta{status: 200, size: 40, words: 7},
want: false,
},
{
name: "fr drops body match",
opts: DirlistOptions{FilterRegex: "not found"},
meta: responseMeta{status: 200, size: 9, words: 2},
body: "page not found",
want: false,
},
{
name: "fr keeps non-match",
opts: DirlistOptions{FilterRegex: "not found"},
meta: responseMeta{status: 200, size: 5, words: 1},
body: "welcome",
want: true,
},
{
// filter precedence: -mc would keep it, but a size filter drops it
name: "filter wins over match",
opts: DirlistOptions{MatchCodes: "200", FilterSizes: "12"},
meta: responseMeta{status: 200, size: 12, words: 3},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m, err := newMatcher(&tt.opts)
if err != nil {
t.Fatalf("newMatcher: %v", err)
}
if got := m.Matches(tt.meta, []byte(tt.body)); got != tt.want {
t.Errorf("Matches(%+v, %q) = %v, want %v", tt.meta, tt.body, got, tt.want)
}
})
}
}
func TestMatcher_BaselineSuppresses(t *testing.T) {
m, err := newMatcher(&DirlistOptions{})
if err != nil {
t.Fatalf("newMatcher: %v", err)
}
// a calibrated soft-404 shape drops an identical response
m.baselines = []responseMeta{{status: 200, size: 42, words: 5}}
soft := responseMeta{status: 200, size: 42, words: 5}
if m.Matches(soft, nil) {
t.Error("baseline-matching response should be suppressed")
}
// a real page with a different size must still surface
livePage := responseMeta{status: 200, size: 99, words: 12}
if !m.Matches(livePage, nil) {
t.Error("distinct response should not be suppressed by baseline")
}
}
func TestNewMatcher_InvalidFlags(t *testing.T) {
tests := []struct {
name string
opts DirlistOptions
}{
{"bad mc", DirlistOptions{MatchCodes: "abc"}},
{"bad fc", DirlistOptions{FilterCodes: "20x"}},
{"bad fs", DirlistOptions{FilterSizes: "big"}},
{"bad fw", DirlistOptions{FilterWords: "-"}},
{"bad regex", DirlistOptions{FilterRegex: "("}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if _, err := newMatcher(&tt.opts); err == nil {
t.Errorf("newMatcher(%+v) expected error, got nil", tt.opts)
}
})
}
}
func TestExpandWords(t *testing.T) {
tests := []struct {
name string
words []string
exts string
want []string
}{
{
name: "no extensions unchanged",
words: []string{"admin", "login"},
exts: "",
want: []string{"admin", "login"},
},
{
name: "appends each extension and keeps bare",
words: []string{"config"},
exts: "php,bak,env",
want: []string{"config", "config.php", "config.bak", "config.env"},
},
{
name: "tolerates leading dot and spaces",
words: []string{"db"},
exts: " .sql , bak ",
want: []string{"db", "db.sql", "db.bak"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := expandWords(tt.words, tt.exts)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("expandWords(%v, %q) = %v, want %v", tt.words, tt.exts, got, tt.want)
}
})
}
}
// softWildcardApp serves a couple of real paths and a catch-all soft-404: every
// unknown path returns a fixed 200 body, the SPA pattern that floods dirlist.
func softWildcardApp() *httptest.Server {
const softBody = "<html><body>app shell - route handled client side</body></html>"
mux := http.NewServeMux()
mux.HandleFunc("/admin", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("<html><body>admin control panel dashboard here</body></html>"))
})
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("<html><body>please sign in with your account credentials now</body></html>"))
})
// catch-all: anything else gets the identical soft-404 shell
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/admin" || r.URL.Path == "/login" {
return
}
w.Write([]byte(softBody))
})
return httptest.NewServer(mux)
}
func TestDirlist_CalibrationSuppressesWildcard(t *testing.T) {
srv := softWildcardApp()
defer srv.Close()
// the wordlist mixes the two real paths with several bogus ones the catch-all
// answers with the soft-404 shell.
dir := t.TempDir()
wordlist := filepath.Join(dir, "words.txt")
if err := os.WriteFile(wordlist, []byte("admin\nlogin\nnope\nbogus\nmissing\n"), 0o600); err != nil {
t.Fatalf("write wordlist: %v", err)
}
// without calibration every bogus path is a soft-404 200 and floods output
noAC, err := Dirlist("small", srv.URL, 5*time.Second, 3, "", DirlistOptions{Wordlist: wordlist})
if err != nil {
t.Fatalf("Dirlist (no -ac): %v", err)
}
if len(noAC) < 5 {
t.Fatalf("expected the wildcard to flood all 5 paths without -ac, got %d", len(noAC))
}
// with -ac the soft-404 baseline is learned and the bogus paths drop
withAC, err := Dirlist("small", srv.URL, 5*time.Second, 3, "", DirlistOptions{
Wordlist: wordlist,
Calibrate: true,
})
if err != nil {
t.Fatalf("Dirlist (-ac): %v", err)
}
got := pathSet(withAC)
if !has(got, "/admin") || !has(got, "/login") {
t.Errorf("real paths admin/login must still surface with -ac, got %v", sortedKeys(got))
}
for _, bogus := range []string{"/nope", "/bogus", "/missing"} {
if has(got, bogus) {
t.Errorf("soft-404 path %s should be suppressed by -ac, got %v", bogus, sortedKeys(got))
}
}
}
func TestDirlist_ExtensionExpansion(t *testing.T) {
// the server only answers config.php; the bare word and other extensions hit
// the catch-all soft-404, so -e must be what surfaces config.php.
const realBody = "<?php // database connection settings live here ?>"
mux := http.NewServeMux()
mux.HandleFunc("/config.php", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(realBody))
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r) // hard 404 for everything but config.php
})
srv := httptest.NewServer(mux)
defer srv.Close()
dir := t.TempDir()
wordlist := filepath.Join(dir, "words.txt")
if err := os.WriteFile(wordlist, []byte("config\n"), 0o600); err != nil {
t.Fatalf("write wordlist: %v", err)
}
results, err := Dirlist("small", srv.URL, 5*time.Second, 2, "", DirlistOptions{
Wordlist: wordlist,
Extensions: "php,bak",
})
if err != nil {
t.Fatalf("Dirlist: %v", err)
}
got := pathSet(results)
if !has(got, "/config.php") {
t.Errorf("expected -e to surface config.php, got %v", sortedKeys(got))
}
if has(got, "/config") || has(got, "/config.bak") {
t.Errorf("only config.php exists; bare word and .bak are 404s, got %v", sortedKeys(got))
}
}
func TestDirlist_LocalWordlistOverridesSize(t *testing.T) {
// a local -w must be used verbatim and never touch directoryURL; point the
// remote at a sink that fails the test if it's ever hit.
orig := directoryURL
directoryURL = "http://127.0.0.1:0/should-not-be-fetched/"
defer func() { directoryURL = orig }()
mux := http.NewServeMux()
mux.HandleFunc("/secret", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte("<html>top secret area found</html>"))
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
})
srv := httptest.NewServer(mux)
defer srv.Close()
dir := t.TempDir()
wordlist := filepath.Join(dir, "custom.txt")
if err := os.WriteFile(wordlist, []byte("secret\nabsent\n"), 0o600); err != nil {
t.Fatalf("write wordlist: %v", err)
}
results, err := Dirlist("large", srv.URL, 5*time.Second, 2, "", DirlistOptions{Wordlist: wordlist})
if err != nil {
t.Fatalf("Dirlist: %v", err)
}
got := pathSet(results)
if !has(got, "/secret") {
t.Errorf("expected the custom wordlist to find /secret, got %v", sortedKeys(got))
}
if has(got, "/absent") {
t.Errorf("/absent is a 404 and should not surface, got %v", sortedKeys(got))
}
}
// pathSet collects each result's url path for membership checks. it reuses the
// package-level sortedKeys (crawl.go) for deterministic failure output.
func pathSet(results DirectoryResults) map[string]struct{} {
set := make(map[string]struct{}, len(results))
for i := 0; i < len(results); i++ {
if idx := strings.Index(results[i].Url, "://"); idx >= 0 {
rest := results[i].Url[idx+len("://"):]
if slash := strings.Index(rest, "/"); slash >= 0 {
set[rest[slash:]] = struct{}{}
continue
}
}
set[results[i].Url] = struct{}{}
}
return set
}
// has is a tiny readability helper for set membership in assertions.
func has(set map[string]struct{}, key string) bool {
_, ok := set[key]
return ok
}
+56 -152
View File
@@ -4,7 +4,7 @@
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: (c) 2022-2025 vmfunc (vmfunc), xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
@@ -14,76 +14,26 @@ package scan
import (
"bufio"
"context"
"fmt"
"net/http"
"strings"
"sync"
"time"
charmlog "github.com/charmbracelet/log"
"github.com/dropalldatabases/sif/internal/dnsx"
"github.com/dropalldatabases/sif/internal/httpx"
"github.com/dropalldatabases/sif/internal/logger"
"github.com/dropalldatabases/sif/internal/output"
"github.com/dropalldatabases/sif/internal/pool"
)
// dnsURL is a var so integration tests can repoint it at a fixture.
var dnsURL = "https://raw.githubusercontent.com/dropalldatabases/sif-runtime/main/dnslist/"
// dnsTransport is a var so integration tests can route the per-host probes at a
// local server instead of resolving real DNS. nil keeps http.DefaultTransport.
var dnsTransport http.RoundTripper
// hostResolver is the small slice of dnsx the dnslist worker needs: resolve a
// candidate and report whether it's a real, non-wildcard hit.
type hostResolver interface {
Resolve(host string) (bool, error)
}
// newDNSResolver builds the resolver for one run; it's a var so integration
// tests inject a fake that answers without touching real dns. the apex is
// fingerprinted for wildcards before any candidate is checked.
var newDNSResolver = func(apex string, resolvers []string) (hostResolver, error) {
r, err := dnsx.NewResolver(resolvers)
if err != nil {
return nil, fmt.Errorf("dns resolver: %w", err)
}
if err := r.FingerprintWildcard(apex); err != nil {
return nil, fmt.Errorf("wildcard fingerprint: %w", err)
}
return r, nil
}
const (
dnsURL = "https://raw.githubusercontent.com/dropalldatabases/sif-runtime/main/dnslist/"
dnsSmallFile = "subdomains-100.txt"
dnsMediumFile = "subdomains-1000.txt"
dnsBigFile = "subdomains-10000.txt"
)
// dnsScheme labels which url won a subdomain so we don't probe the second
// scheme once the first already counted it.
type dnsScheme string
const (
dnsSchemeHTTP dnsScheme = "http"
dnsSchemeHTTPS dnsScheme = "https"
)
// meaningfulStatus reports whether a probe response is a real "this host
// exists" signal rather than a 404 or a wildcard catch-all redirect. a
// wildcard-DNS host answers every candidate with the same redirect/404, so
// gating on a successful, non-redirect status keeps it from flooding results.
func meaningfulStatus(code int) bool {
return code >= http.StatusOK && code < http.StatusMultipleChoices
}
// Dnslist performs DNS subdomain enumeration on the target domain. each
// candidate is resolved first; only names that actually resolve (and aren't a
// wildcard catch-all) are http-probed, so a big wordlist no longer means a
// http request per dead name.
func Dnslist(size string, url string, timeout time.Duration, threads int, logdir string, resolvers []string) ([]string, error) {
// Dnslist performs DNS subdomain enumeration on the target domain.
func Dnslist(size string, url string, timeout time.Duration, threads int, logdir string) ([]string, error) {
log := output.Module("DNS")
log.Start()
@@ -97,12 +47,7 @@ func Dnslist(size string, url string, timeout time.Duration, threads int, logdir
list = dnsURL + dnsBigFile
}
req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, list, http.NoBody)
if err != nil {
log.Error("Error creating request: %s", err)
return nil, err
}
resp, err := httpx.Client(timeout).Do(req)
resp, err := http.Get(list)
if err != nil {
log.Error("Error downloading DNS list: %s", err)
return nil, err
@@ -116,16 +61,7 @@ func Dnslist(size string, url string, timeout time.Duration, threads int, logdir
dns = append(dns, scanner.Text())
}
sanitizedURL := stripScheme(url)
// resolve against dns first, fingerprinting the apex for wildcards so a
// catch-all zone can't flood the probe step. build it once and share across
// the workers - the underlying client is concurrency-safe.
resolver, err := newDNSResolver(sanitizedURL, resolvers)
if err != nil {
log.Error("Error building DNS resolver: %s", err)
return nil, err
}
sanitizedURL := strings.Split(url, "://")[1]
if logdir != "" {
if err := logger.WriteHeader(sanitizedURL, logdir, size+" subdomain fuzzing"); err != nil {
@@ -134,104 +70,72 @@ func Dnslist(size string, url string, timeout time.Duration, threads int, logdir
}
}
// per-host probe client. dnsTransport pins every dial at a fixture in
// integration tests; nil keeps the shared transport for real runs.
client := httpx.Client(timeout)
if dnsTransport != nil {
client.Transport = dnsTransport
}
// don't chase redirects: a wildcard catch-all that 301s every candidate to
// the same landing page must read as a redirect status, not a 200, so it
// gets gated out instead of counting as a found host.
client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
client := &http.Client{
Timeout: timeout,
}
progress := output.NewProgress(len(dns), "enumerating")
var wg sync.WaitGroup
var mu sync.Mutex
wg.Add(threads)
urls := make([]string, 0, 64)
pool.Each(dns, threads, func(domain string) {
progress.Increment(domain)
for thread := 0; thread < threads; thread++ {
go func(thread int) {
defer wg.Done()
charmlog.Debugf("Looking up: %s", domain)
for i, domain := range dns {
if i%threads != thread {
continue
}
host := domain + "." + sanitizedURL
progress.Increment(domain)
// dns gate: skip the http probe entirely for names that don't
// resolve or that a wildcard zone answers. this is the whole point -
// no request per dead candidate.
ok, err := resolver.Resolve(host)
if err != nil {
charmlog.Debugf("resolve %s: %s", host, err)
return
}
if !ok {
return
}
charmlog.Debugf("Looking up: %s", domain)
// probe http first, then https - but a subdomain is recorded at
// most once. firing both schemes and appending on each is what
// double-counted every host on the old path.
foundURL, scheme := probeSubdomain(client, host)
if foundURL == "" {
return
}
// Check HTTP
resp, err := client.Get("http://" + domain + "." + sanitizedURL)
if err != nil {
charmlog.Debugf("Error %s: %s", domain, err)
} else {
mu.Lock()
urls = append(urls, resp.Request.URL.String())
mu.Unlock()
mu.Lock()
urls = append(urls, foundURL)
mu.Unlock()
progress.Pause()
log.Success("found: %s.%s [http]", output.Highlight.Render(domain), sanitizedURL)
progress.Resume()
progress.Pause()
log.Success("found: %s [%s]", output.Highlight.Render(host), scheme)
progress.Resume()
if logdir != "" {
logger.Write(sanitizedURL, logdir, fmt.Sprintf("[http] %s.%s\n", domain, sanitizedURL))
}
}
if logdir != "" {
_ = logger.Write(sanitizedURL, logdir, fmt.Sprintf("[%s] %s\n", scheme, host))
}
})
// Check HTTPS
resp, err = client.Get("https://" + domain + "." + sanitizedURL)
if err != nil {
charmlog.Debugf("Error %s: %s", domain, err)
} else {
mu.Lock()
urls = append(urls, resp.Request.URL.String())
mu.Unlock()
progress.Pause()
log.Success("found: %s.%s [https]", output.Highlight.Render(domain), sanitizedURL)
progress.Resume()
if logdir != "" {
logger.Write(sanitizedURL, logdir, fmt.Sprintf("[https] %s.%s\n", domain, sanitizedURL))
}
}
}
}(thread)
}
wg.Wait()
progress.Done()
log.Complete(len(urls), "found")
return urls, nil
}
// probeSubdomain tries http then https for one host and returns the resolved
// url + winning scheme on the first meaningful hit, or "" if neither scheme
// gave a real signal. trying https only when http didn't already count is the
// per-subdomain dedupe.
func probeSubdomain(client *http.Client, host string) (string, dnsScheme) {
schemes := []struct {
prefix string
label dnsScheme
}{
{"http://", dnsSchemeHTTP},
{"https://", dnsSchemeHTTPS},
}
for i := 0; i < len(schemes); i++ {
req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, schemes[i].prefix+host, http.NoBody)
if err != nil {
charmlog.Debugf("Error %s: %s", host, err)
continue
}
resp, err := client.Do(req) //nolint:bodyclose // drained and closed via httpx.DrainClose
if err != nil {
charmlog.Debugf("Error %s: %s", host, err)
continue
}
code := resp.StatusCode
resolved := resp.Request.URL.String()
// status/url only; drain so the conn returns to the pool.
httpx.DrainClose(resp)
if meaningfulStatus(code) {
return resolved, schemes[i].label
}
charmlog.Debugf("skip %s [%s]: status %d", host, schemes[i].label, code)
}
return "", ""
}
-98
View File
@@ -1,98 +0,0 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package scan
import (
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"time"
)
func TestMeaningfulStatus(t *testing.T) {
tests := []struct {
name string
code int
want bool
}{
{"ok counts", http.StatusOK, true},
{"204 counts", http.StatusNoContent, true},
{"301 catch-all redirect dropped", http.StatusMovedPermanently, false},
{"302 catch-all redirect dropped", http.StatusFound, false},
{"404 dropped", http.StatusNotFound, false},
{"500 dropped", http.StatusInternalServerError, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := meaningfulStatus(tt.code); got != tt.want {
t.Errorf("meaningfulStatus(%d) = %v, want %v", tt.code, got, tt.want)
}
})
}
}
// a host that answers 200 over http should count exactly once, not once per
// scheme - the old path appended on both http and https.
func TestProbeSubdomain_DedupesAcrossSchemes(t *testing.T) {
var hits int32
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt32(&hits, 1)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
host := strings.TrimPrefix(srv.URL, "http://")
client := &http.Client{Timeout: 5 * time.Second}
url, scheme := probeSubdomain(client, host)
if url == "" {
t.Fatal("expected http probe to count the host")
}
if scheme != dnsSchemeHTTP {
t.Errorf("expected http scheme to win, got %q", scheme)
}
// http already counted, so https must not be tried - one request total.
if got := atomic.LoadInt32(&hits); got != 1 {
t.Errorf("expected exactly 1 probe request, got %d", got)
}
}
// a wildcard catch-all that 404s (or 301s) every candidate must not be reported
// as found - that's the flood the gating closes.
func TestProbeSubdomain_WildcardCatchAllNotFound(t *testing.T) {
for _, code := range []int{http.StatusNotFound, http.StatusMovedPermanently} {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if code == http.StatusMovedPermanently {
w.Header().Set("Location", "https://catch-all.example/")
}
w.WriteHeader(code)
}))
host := strings.TrimPrefix(srv.URL, "http://")
client := &http.Client{
Timeout: 5 * time.Second,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
url, _ := probeSubdomain(client, host)
if url != "" {
t.Errorf("status %d should not count as found, got %q", code, url)
}
srv.Close()
}
}
+38 -36
View File
@@ -4,7 +4,7 @@
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: (c) 2022-2025 vmfunc (vmfunc), xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
@@ -17,18 +17,16 @@ package scan
import (
"bufio"
"context"
"fmt"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/charmbracelet/log"
"github.com/dropalldatabases/sif/internal/httpx"
"github.com/dropalldatabases/sif/internal/logger"
"github.com/dropalldatabases/sif/internal/output"
"github.com/dropalldatabases/sif/internal/pool"
googlesearch "github.com/rocketlaunchr/google-search"
)
@@ -61,7 +59,7 @@ func Dork(url string, timeout time.Duration, threads int, logdir string) ([]Dork
spin := output.NewSpinner("Running Google dorks")
spin.Start()
sanitizedURL := stripScheme(url)
sanitizedURL := strings.Split(url, "://")[1]
if logdir != "" {
if err := logger.WriteHeader(sanitizedURL, logdir, "URL dorking"); err != nil {
@@ -71,14 +69,7 @@ func Dork(url string, timeout time.Duration, threads int, logdir string) ([]Dork
}
}
ctx := context.TODO()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, dorkURL+dorkFile, http.NoBody)
if err != nil {
spin.Stop()
output.Error("Error creating dork list request: %s", err)
return nil, err
}
resp, err := httpx.Client(timeout).Do(req)
resp, err := http.Get(dorkURL + dorkFile)
if err != nil {
spin.Stop()
output.Error("Error downloading dork list: %s", err)
@@ -93,33 +84,44 @@ func Dork(url string, timeout time.Duration, threads int, logdir string) ([]Dork
}
// util.InitProgressBar()
var mu sync.Mutex
var wg sync.WaitGroup
wg.Add(threads)
dorkResults := []DorkResult{}
pool.Each(dorks, threads, func(dork string) {
results, err := googlesearch.Search(context.TODO(), fmt.Sprintf("%s %s", dork, sanitizedURL))
if err != nil {
log.Debugf("error searching for dork %s: %v", dork, err)
return
}
if len(results) > 0 {
spin.Stop()
output.Success("%s dork results found for dork %s", output.Status.Render(strconv.Itoa(len(results))), output.Highlight.Render(dork))
spin.Start()
if logdir != "" {
_ = logger.Write(sanitizedURL, logdir, strconv.Itoa(len(results))+" dork results found for dork ["+dork+"]\n")
}
for thread := 0; thread < threads; thread++ {
go func(thread int) {
defer wg.Done()
result := DorkResult{
Url: dork,
Count: len(results),
}
for i, dork := range dorks {
mu.Lock()
dorkResults = append(dorkResults, result)
mu.Unlock()
}
})
if i%threads != thread {
continue
}
results, err := googlesearch.Search(nil, fmt.Sprintf("%s %s", dork, sanitizedURL))
if err != nil {
log.Debugf("error searching for dork %s: %v", dork, err)
continue
}
if len(results) > 0 {
spin.Stop()
output.Success("%s dork results found for dork %s", output.Status.Render(strconv.Itoa(len(results))), output.Highlight.Render(dork))
spin.Start()
if logdir != "" {
logger.Write(sanitizedURL, logdir, strconv.Itoa(len(results))+" dork results found for dork ["+dork+"]\n")
}
result := DorkResult{
Url: dork,
Count: len(results),
}
dorkResults = append(dorkResults, result)
}
}
}(thread)
}
wg.Wait()
spin.Stop()
output.ScanComplete("URL dorking", len(dorkResults), "found")
-254
View File
@@ -1,254 +0,0 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package scan
import (
"context"
"encoding/base64"
"fmt"
"io"
"net/http"
"regexp"
"strings"
"time"
"github.com/dropalldatabases/sif/internal/httpx"
"github.com/dropalldatabases/sif/internal/logger"
"github.com/dropalldatabases/sif/internal/output"
"github.com/twmb/murmur3"
)
// FaviconResult is the computed shodan-style favicon hash plus the pivot query
// and any matched tech.
type FaviconResult struct {
FaviconURL string `json:"favicon_url"` // where the icon was fetched
Hash int32 `json:"hash"` // shodan mmh3 hash (signed int32)
Tech string `json:"tech"` // matched technology, empty when unknown
ShodanQ string `json:"shodan_query"`
}
// faviconBodyReadCap bounds the icon read. real favicons are tens of kilobytes;
// a megabyte ceiling covers oversized ones without letting a hostile endpoint
// stream forever.
const faviconBodyReadCap = 1 << 20
// b64LineLen is python's base64.encodebytes line width. mmh3/shodan hash the
// chunked base64 (newline every 76 chars, trailing newline), so we must wrap at
// exactly this width to land on the same hash.
const b64LineLen = 76
// faviconLinkRegex pulls the href off a <link rel="...icon..."> tag so we can
// fall back to a declared icon when /favicon.ico is absent.
var faviconLinkRegex = regexp.MustCompile(`(?i)<link[^>]+rel=["'][^"']*icon[^"']*["'][^>]*>`)
// faviconHrefRegex extracts the href attribute value from a matched link tag.
var faviconHrefRegex = regexp.MustCompile(`(?i)href=["']([^"']+)["']`)
// faviconHashes maps a known shodan favicon hash to the tech that ships it.
// these are stable default icons for panels/frameworks/c2; a hit is a strong
// fingerprint. kept small on purpose - high-signal defaults, not an exhaustive db.
var faviconHashes = map[int32]string{
116323821: "Apache Tomcat",
81586312: "Spring Boot (default whitelabel)",
-235701012: "Jenkins",
-1255347784: "GitLab",
1278322581: "Grafana",
743365239: "Kibana",
-1462443472: "phpMyAdmin",
999357577: "Cobalt Strike (default beacon)",
-1521704893: "Metasploit",
-1893514588: "Gitea",
}
// Favicon fetches the target's favicon, computes the shodan mmh3 hash and matches
// it against the bundled fingerprint map.
func Favicon(targetURL string, timeout time.Duration, logdir string) (*FaviconResult, error) {
log := output.Module("FAVICON")
log.Start()
sanitizedURL := stripScheme(targetURL)
if logdir != "" {
if err := logger.WriteHeader(sanitizedURL, logdir, "Favicon hash fingerprint"); err != nil {
log.Error("error creating log file: %v", err)
return nil, fmt.Errorf("create favicon log: %w", err)
}
}
client := httpx.Client(timeout)
base := strings.TrimRight(targetURL, "/")
iconURL, data, err := fetchFavicon(client, base)
if err != nil {
log.Info("no favicon found: %v", err)
log.Complete(0, "found")
return nil, nil //nolint:nilnil // a missing favicon is not an error
}
hash := FaviconHash(data)
result := &FaviconResult{
FaviconURL: iconURL,
Hash: hash,
Tech: faviconHashes[hash],
ShodanQ: fmt.Sprintf("http.favicon.hash:%d", hash),
}
if result.Tech != "" {
log.Warn("favicon hash %d matches %s", hash, output.Highlight.Render(result.Tech))
} else {
log.Info("favicon hash %d (no fingerprint match)", hash)
}
log.Info("shodan pivot: %s", output.Highlight.Render(result.ShodanQ))
if logdir != "" {
_ = logger.Write(sanitizedURL, logdir,
fmt.Sprintf("Favicon %s hash=%d tech=%q query=%s\n", iconURL, hash, result.Tech, result.ShodanQ))
}
log.Complete(1, "hashed")
return result, nil
}
// fetchFavicon tries /favicon.ico first, then the <link rel=icon> declared in the
// homepage html. it returns the url it pulled the bytes from so the report shows
// exactly which icon was hashed.
func fetchFavicon(client *http.Client, base string) (string, []byte, error) {
iconURL := base + "/favicon.ico"
if data, err := getFaviconBytes(client, iconURL); err == nil {
return iconURL, data, nil
}
// no /favicon.ico; parse the homepage for a declared icon link.
href, err := declaredFaviconHref(client, base)
if err != nil {
return "", nil, err
}
iconURL = resolveFaviconURL(base, href)
data, err := getFaviconBytes(client, iconURL)
if err != nil {
return "", nil, err
}
return iconURL, data, nil
}
// getFaviconBytes GETs an icon url and returns the body, erroring on a non-200 or
// an empty body so a soft-404 html page isn't hashed as if it were an icon.
func getFaviconBytes(client *http.Client, iconURL string) ([]byte, error) {
req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, iconURL, http.NoBody)
if err != nil {
return nil, fmt.Errorf("build favicon request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch favicon: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("favicon status %d", resp.StatusCode)
}
data, err := io.ReadAll(io.LimitReader(resp.Body, faviconBodyReadCap))
if err != nil {
return nil, fmt.Errorf("read favicon: %w", err)
}
if len(data) == 0 {
return nil, fmt.Errorf("empty favicon body")
}
return data, nil
}
// declaredFaviconHref fetches the homepage and extracts the href of the first
// <link rel="...icon..."> tag.
func declaredFaviconHref(client *http.Client, base string) (string, error) {
req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, base, http.NoBody)
if err != nil {
return "", fmt.Errorf("build homepage request: %w", err)
}
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("fetch homepage: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(io.LimitReader(resp.Body, faviconBodyReadCap))
if err != nil {
return "", fmt.Errorf("read homepage: %w", err)
}
link := faviconLinkRegex.Find(body)
if link == nil {
return "", fmt.Errorf("no favicon link in homepage")
}
href := faviconHrefRegex.FindSubmatch(link)
if href == nil {
return "", fmt.Errorf("favicon link has no href")
}
return string(href[1]), nil
}
// resolveFaviconURL turns a possibly-relative href into an absolute url against
// the target base. an absolute href is returned as-is.
func resolveFaviconURL(base, href string) string {
if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") {
return href
}
if strings.HasPrefix(href, "//") {
// scheme-relative; inherit the base scheme.
scheme := "https:"
if strings.HasPrefix(base, "http://") {
scheme = "http:"
}
return scheme + href
}
if strings.HasPrefix(href, "/") {
return base + href
}
return base + "/" + href
}
// FaviconHash computes shodan's favicon hash: murmur3 32-bit over the python
// base64.encodebytes encoding of the raw icon (newline every 76 chars plus a
// trailing newline), reinterpreted as a signed int32. the chunking and the sign
// are both load-bearing - shodan stores the value python's mmh3.hash() returns,
// which is signed, over the wrapped base64, not the raw bytes. the golden test
// pins this exactly.
func FaviconHash(data []byte) int32 {
encoded := encodeFaviconBase64(data)
return int32(murmur3.Sum32(encoded)) //nolint:gosec // shodan stores the signed reinterpretation on purpose
}
// encodeFaviconBase64 mirrors python's base64.encodebytes: standard base64 with
// a newline inserted every 76 output characters and a trailing newline. this is
// the exact byte stream shodan feeds to mmh3, so it must match byte-for-byte.
func encodeFaviconBase64(data []byte) []byte {
raw := base64.StdEncoding.EncodeToString(data)
var b strings.Builder
// final size: the base64 body plus one '\n' per (full or partial) 76-char
// line. preallocate so the builder never regrows mid-loop.
b.Grow(len(raw) + len(raw)/b64LineLen + 1)
for i := 0; i < len(raw); i += b64LineLen {
end := i + b64LineLen
if end > len(raw) {
end = len(raw)
}
b.WriteString(raw[i:end])
b.WriteByte('\n')
}
return []byte(b.String())
}
// ResultType identifies favicon findings for the result registry.
func (r *FaviconResult) ResultType() string { return "favicon" }
var _ ScanResult = (*FaviconResult)(nil)
-160
View File
@@ -1,160 +0,0 @@
/*
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
: :
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·
*/
package scan
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// goldenFaviconBytes is a fixed payload long enough to span multiple base64
// lines, so the python-style 76-char chunking is actually exercised by the hash.
var goldenFaviconBytes = []byte(strings.Repeat("sif-favicon-golden-test-bytes-", 8))
// goldenFaviconHash is the shodan mmh3 hash of goldenFaviconBytes. it is pinned:
// the value comes from feeding the python base64.encodebytes byte stream (newline
// every 76 chars + trailing newline) through murmur3-32 and reinterpreting the
// result as a signed int32 - exactly what shodan stores. if the chunking or the
// signedness regress, this number changes and the test fails.
const goldenFaviconHash int32 = -1554620260
// goldenHelloHash pins a short single-line case so a regression in the trailing
// newline (which the small case still has) is caught independently.
const goldenHelloHash int32 = 1155597304
func TestFaviconHash_Golden(t *testing.T) {
tests := []struct {
name string
in []byte
want int32
}{
{name: "multi-line fixture", in: goldenFaviconBytes, want: goldenFaviconHash},
{name: "single-line hello", in: []byte("hello"), want: goldenHelloHash},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := FaviconHash(tt.in)
if got != tt.want {
t.Errorf("FaviconHash = %d, want %d", got, tt.want)
}
})
}
}
// TestFaviconBase64Chunking pins the encode step against python's
// base64.encodebytes: a 50-byte input encodes to >76 base64 chars, so it must
// wrap into two newline-terminated lines.
func TestFaviconBase64Chunking(t *testing.T) {
in := []byte(strings.Repeat("A", 60)) // 60 bytes -> 80 base64 chars -> two lines
got := string(encodeFaviconBase64(in))
lines := strings.Split(strings.TrimRight(got, "\n"), "\n")
if len(lines) != 2 {
t.Fatalf("expected 2 wrapped lines, got %d: %q", len(lines), got)
}
if len(lines[0]) != b64LineLen {
t.Errorf("first line = %d chars, want %d", len(lines[0]), b64LineLen)
}
if !strings.HasSuffix(got, "\n") {
t.Errorf("encoding must end in a trailing newline, got %q", got)
}
}
// fixtureFaviconServer serves the golden bytes at /favicon.ico.
func fixtureFaviconServer() *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/favicon.ico" {
w.Header().Set("Content-Type", "image/x-icon")
_, _ = w.Write(goldenFaviconBytes)
return
}
w.WriteHeader(http.StatusNotFound)
}))
}
func TestFavicon_FetchAndHash(t *testing.T) {
srv := fixtureFaviconServer()
defer srv.Close()
result, err := Favicon(srv.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("Favicon: %v", err)
}
if result == nil {
t.Fatal("expected a favicon result, got nil")
}
if result.Hash != goldenFaviconHash {
t.Errorf("Hash = %d, want %d", result.Hash, goldenFaviconHash)
}
wantQ := "http.favicon.hash:-1554620260"
if result.ShodanQ != wantQ {
t.Errorf("ShodanQ = %q, want %q", result.ShodanQ, wantQ)
}
}
// TestFavicon_LinkFallback covers the <link rel=icon> path when /favicon.ico is
// absent: the homepage points at /static/icon.png and that's what gets hashed.
func TestFavicon_LinkFallback(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/favicon.ico":
w.WriteHeader(http.StatusNotFound)
case "/static/icon.png":
_, _ = w.Write(goldenFaviconBytes)
default:
_, _ = w.Write([]byte(`<html><head><link rel="icon" href="/static/icon.png"></head></html>`))
}
}))
defer srv.Close()
result, err := Favicon(srv.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("Favicon: %v", err)
}
if result == nil {
t.Fatal("expected a favicon result via link fallback, got nil")
}
if !strings.HasSuffix(result.FaviconURL, "/static/icon.png") {
t.Errorf("FaviconURL = %q, want it to end in /static/icon.png", result.FaviconURL)
}
if result.Hash != goldenFaviconHash {
t.Errorf("Hash = %d, want %d", result.Hash, goldenFaviconHash)
}
}
// TestFavicon_NoIcon confirms a target with no favicon at all yields no result
// and no error.
func TestFavicon_NoIcon(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
}))
defer srv.Close()
result, err := Favicon(srv.URL, 5*time.Second, "")
if err != nil {
t.Fatalf("Favicon: %v", err)
}
if result != nil {
t.Errorf("expected nil result for missing favicon, got %+v", result)
}
}
func TestFaviconResult_ResultType(t *testing.T) {
r := &FaviconResult{}
if r.ResultType() != "favicon" {
t.Errorf("expected result type 'favicon', got %q", r.ResultType())
}
}
+1 -1
View File
@@ -4,7 +4,7 @@
: █▀ █ █▀▀ · Blazing-fast pentesting suite :
: ▄█ █ █▀ · BSD 3-Clause License :
: :
: (c) 2022-2026 vmfunc, xyzeva, :
: (c) 2022-2025 vmfunc (vmfunc), xyzeva, :
: lunchcat alumni & contributors :
: :
·━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━·

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