├── web ├── ui │ ├── src │ │ ├── vite-env.d.ts │ │ ├── lib │ │ │ ├── utils.ts │ │ │ ├── common.ts │ │ │ └── api │ │ │ │ ├── types.ts │ │ │ │ └── api.ts │ │ ├── components │ │ │ ├── ui │ │ │ │ ├── skeleton.tsx │ │ │ │ ├── collapsible.tsx │ │ │ │ ├── label.tsx │ │ │ │ ├── input.tsx │ │ │ │ ├── toaster.tsx │ │ │ │ ├── tooltip.tsx │ │ │ │ ├── switch.tsx │ │ │ │ ├── badge.tsx │ │ │ │ ├── popover.tsx │ │ │ │ ├── scroll-area.tsx │ │ │ │ ├── button.tsx │ │ │ │ ├── tabs.tsx │ │ │ │ ├── card.tsx │ │ │ │ ├── accordion.tsx │ │ │ │ ├── table.tsx │ │ │ │ └── dialog.tsx │ │ │ ├── loading.tsx │ │ │ ├── mode-toggle.tsx │ │ │ └── theme-provider.tsx │ │ ├── pages │ │ │ ├── search │ │ │ │ ├── loader.ts │ │ │ │ ├── action.ts │ │ │ │ └── Search.tsx │ │ │ ├── table │ │ │ │ └── data.tsx │ │ │ ├── dashboard │ │ │ │ ├── data.tsx │ │ │ │ └── Dashboard.tsx │ │ │ ├── detail │ │ │ │ ├── actions.ts │ │ │ │ └── data.tsx │ │ │ ├── App.tsx │ │ │ ├── gallery │ │ │ │ └── data.tsx │ │ │ ├── submit │ │ │ │ └── action.ts │ │ │ └── Error.tsx │ │ ├── main.tsx │ │ ├── index.css │ │ └── hooks │ │ │ └── use-toast.ts │ ├── dist │ │ ├── favico.ico │ │ └── index.html │ ├── public │ │ └── favico.ico │ ├── postcss.config.js │ ├── tsconfig.json │ ├── vite.config.ts │ ├── .gitignore │ ├── index.html │ ├── components.json │ ├── README.md │ ├── tsconfig.node.json │ ├── tsconfig.app.json │ ├── eslint.config.js │ ├── package.json │ └── tailwind.config.js ├── templates │ └── templates.go ├── api │ ├── ping.go │ ├── handler.go │ ├── wappalyzer.go │ ├── technology.go │ ├── gallery_detail.go │ ├── delete.go │ ├── list.go │ ├── submit_single.go │ ├── statistics.go │ ├── submit.go │ └── gallery.go ├── spa.go └── server.go ├── images ├── gowitness-logo.png ├── gowitness-detail.png ├── gowitness-gallery.png └── gowitness-terminal.png ├── main.go ├── internal ├── version │ └── version.go ├── islazy │ ├── string.go │ ├── time.go │ ├── net.go │ ├── slices.go │ ├── hamming.go │ └── fs.go └── ascii │ ├── logo.go │ └── markdown.go ├── pkg ├── writers │ ├── writer.go │ ├── none.go │ ├── stdout.go │ ├── json.go │ ├── memory.go │ ├── db.go │ └── csv.go ├── models │ ├── oldv2 │ │ ├── doc.go │ │ └── models.go │ └── models.go ├── runner │ ├── driver.go │ ├── drivers │ │ └── restricted_ports.go │ └── options.go ├── readers │ ├── reader.go │ ├── file_test.go │ ├── cidr.go │ ├── nmap.go │ ├── nessus.go │ └── file.go ├── log │ └── log.go ├── imagehash │ └── perception.go └── database │ └── db.go ├── cmd ├── report.go ├── version.go ├── scan_single.go ├── report_server.go ├── scan_file.go ├── scan_cidr.go ├── scan_nessus.go ├── scan_nmap.go └── root.go ├── .gitignore ├── .github ├── ISSUE_TEMPLATE │ ├── feature_request.md │ └── bug_report.md └── workflows │ ├── install-check.yml │ └── docker.yml ├── Dockerfile ├── docker-compose.yml ├── Makefile ├── README.md └── go.mod /web/ui/src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /web/ui/dist/favico.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sensepost/gowitness/HEAD/web/ui/dist/favico.ico -------------------------------------------------------------------------------- /web/ui/public/favico.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sensepost/gowitness/HEAD/web/ui/public/favico.ico -------------------------------------------------------------------------------- /images/gowitness-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sensepost/gowitness/HEAD/images/gowitness-logo.png -------------------------------------------------------------------------------- /images/gowitness-detail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sensepost/gowitness/HEAD/images/gowitness-detail.png -------------------------------------------------------------------------------- /images/gowitness-gallery.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sensepost/gowitness/HEAD/images/gowitness-gallery.png -------------------------------------------------------------------------------- /images/gowitness-terminal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sensepost/gowitness/HEAD/images/gowitness-terminal.png -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "github.com/sensepost/gowitness/cmd" 4 | 5 | func main() { 6 | cmd.Execute() 7 | } 8 | -------------------------------------------------------------------------------- /web/templates/templates.go: -------------------------------------------------------------------------------- 1 | package templates 2 | 3 | import "embed" 4 | 5 | //go:embed * 6 | var ReportTemplate embed.FS 7 | -------------------------------------------------------------------------------- /web/ui/postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /internal/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | var ( 4 | Version = "3.1.1" 5 | 6 | GitHash = "dev" 7 | GoBuildEnv = "dev" 8 | GoBuildTime = "dev" 9 | ) 10 | -------------------------------------------------------------------------------- /pkg/writers/writer.go: -------------------------------------------------------------------------------- 1 | package writers 2 | 3 | import "github.com/sensepost/gowitness/pkg/models" 4 | 5 | // Writer is a results writer 6 | type Writer interface { 7 | Write(*models.Result) error 8 | } 9 | -------------------------------------------------------------------------------- /web/ui/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx" 2 | import { twMerge } from "tailwind-merge" 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)) 6 | } 7 | -------------------------------------------------------------------------------- /internal/islazy/string.go: -------------------------------------------------------------------------------- 1 | package islazy 2 | 3 | // LeftTrucate a string if its more than max 4 | func LeftTrucate(s string, max int) string { 5 | if len(s) <= max { 6 | return s 7 | } 8 | 9 | return s[max:] 10 | } 11 | -------------------------------------------------------------------------------- /pkg/models/oldv2/doc.go: -------------------------------------------------------------------------------- 1 | package oldv2 2 | 3 | // These are the old gowitness v2 models. 4 | // 5 | // You should *definitely* not be using these anywhere in the v3 code base. 6 | // They simply exist here to support the report migrate command. 7 | -------------------------------------------------------------------------------- /web/ui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { "path": "./tsconfig.app.json" }, 5 | { "path": "./tsconfig.node.json" } 6 | ], 7 | "compilerOptions": { 8 | "baseUrl": ".", 9 | "paths": { 10 | "@/*": ["./src/*"] 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /web/ui/vite.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { defineConfig } from 'vite'; 3 | import react from '@vitejs/plugin-react'; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react()], 8 | resolve: { 9 | alias: { 10 | "@": path.resolve(__dirname, "./src"), 11 | }, 12 | }, 13 | }); 14 | -------------------------------------------------------------------------------- /web/ui/src/components/ui/skeleton.tsx: -------------------------------------------------------------------------------- 1 | import { cn } from "@/lib/utils" 2 | 3 | function Skeleton({ 4 | className, 5 | ...props 6 | }: React.HTMLAttributes) { 7 | return ( 8 |
12 | ) 13 | } 14 | 15 | export { Skeleton } 16 | -------------------------------------------------------------------------------- /web/ui/src/components/ui/collapsible.tsx: -------------------------------------------------------------------------------- 1 | import * as CollapsiblePrimitive from "@radix-ui/react-collapsible" 2 | 3 | const Collapsible = CollapsiblePrimitive.Root 4 | 5 | const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger 6 | 7 | const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent 8 | 9 | export { Collapsible, CollapsibleTrigger, CollapsibleContent } 10 | -------------------------------------------------------------------------------- /web/ui/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist-ssr 12 | *.local 13 | *.tsbuildinfo 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | .vite/ 26 | -------------------------------------------------------------------------------- /cmd/report.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/sensepost/gowitness/internal/ascii" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | var reportCmd = &cobra.Command{ 9 | Use: "report", 10 | Short: "Work with gowitness reports", 11 | Long: ascii.LogoHelp(ascii.Markdown(` 12 | # report 13 | 14 | Work with gowitness reports. 15 | `)), 16 | } 17 | 18 | func init() { 19 | rootCmd.AddCommand(reportCmd) 20 | } 21 | -------------------------------------------------------------------------------- /pkg/writers/none.go: -------------------------------------------------------------------------------- 1 | package writers 2 | 3 | import ( 4 | "github.com/sensepost/gowitness/pkg/models" 5 | ) 6 | 7 | // NoneWriter is a None writer 8 | type NoneWriter struct { 9 | } 10 | 11 | // NewNoneWriter initialises a none writer 12 | func NewNoneWriter() (*NoneWriter, error) { 13 | return &NoneWriter{}, nil 14 | } 15 | 16 | // Write does nothing 17 | func (s *NoneWriter) Write(result *models.Result) error { 18 | return nil 19 | } 20 | -------------------------------------------------------------------------------- /internal/ascii/logo.go: -------------------------------------------------------------------------------- 1 | package ascii 2 | 3 | // Logo returns the gowitness ascii logo 4 | func Logo() string { 5 | return ` _ _ 6 | ___ ___ _ _ _|_| |_ __ ___ ___ ___ 7 | | . | . | | | | | _| | -_|_ -|_ -| 8 | |_ |___|_____|_|_||_|_|___|___|___| 9 | |___| v3, with <3 by @leonjza` 10 | } 11 | 12 | // LogoHelp returns the logo, with help 13 | func LogoHelp(s string) string { 14 | return Logo() + "\n\n" + s 15 | } 16 | -------------------------------------------------------------------------------- /web/ui/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | gowitness - a golang screenshotting tool by @leonjza 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /web/ui/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": false, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.js", 8 | "css": "src/App.css", 9 | "baseColor": "slate", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | } 20 | } -------------------------------------------------------------------------------- /web/ui/src/pages/search/loader.ts: -------------------------------------------------------------------------------- 1 | import * as api from "@/lib/api/api"; 2 | 3 | // searchLoader loads search data from the api 4 | const searchLoader = async ({ request }: { request: Request; }) => { 5 | const url = new URL(request.url); 6 | const searchQuery = url.searchParams.get("query"); 7 | 8 | if (!searchQuery) { 9 | return { error: "No search query provided" }; 10 | } 11 | 12 | return await api.post('search', { query: decodeURIComponent(searchQuery) }); 13 | }; 14 | 15 | export { searchLoader }; -------------------------------------------------------------------------------- /internal/islazy/time.go: -------------------------------------------------------------------------------- 1 | package islazy 2 | 3 | import "time" 4 | 5 | // Float64ToTime takes a float64 as number of seconds since unix epoch and returns time.Time 6 | // 7 | // example field where this is used (expires field): 8 | // 9 | // https://chromedevtools.github.io/devtools-protocol/tot/Network/#type-Cookie 10 | func Float64ToTime(f float64) time.Time { 11 | if f == 0 { 12 | // Return zero value for session cookies 13 | return time.Time{} 14 | } 15 | return time.Unix(0, int64(f*float64(time.Second))) 16 | } 17 | -------------------------------------------------------------------------------- /pkg/writers/stdout.go: -------------------------------------------------------------------------------- 1 | package writers 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/sensepost/gowitness/pkg/models" 8 | ) 9 | 10 | // StdoutWriter is a Stdout writer 11 | type StdoutWriter struct { 12 | } 13 | 14 | // NewStdoutWriter initialises a stdout writer 15 | func NewStdoutWriter() (*StdoutWriter, error) { 16 | 17 | return &StdoutWriter{}, nil 18 | } 19 | 20 | // Write results to stdout 21 | func (s *StdoutWriter) Write(result *models.Result) error { 22 | fmt.Fprintln(os.Stdout, result.URL) 23 | return nil 24 | } 25 | -------------------------------------------------------------------------------- /pkg/runner/driver.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/sensepost/gowitness/pkg/models" 7 | ) 8 | 9 | // ChromeNotFoundError signals that chrome is not available 10 | type ChromeNotFoundError struct { 11 | Err error 12 | } 13 | 14 | func (e ChromeNotFoundError) Error() string { 15 | return fmt.Sprintf("chrome not found: %v", e.Err) 16 | } 17 | 18 | // Driver is the interface browser drivers will implement. 19 | type Driver interface { 20 | Witness(target string, runner *Runner) (*models.Result, error) 21 | Close() 22 | } 23 | -------------------------------------------------------------------------------- /web/ui/README.md: -------------------------------------------------------------------------------- 1 | # ui 2 | 3 | This is the gowitness user interface source. 4 | 5 | ## development notes 6 | 7 | Typically, you'd have the API server running using `go run main.go report server`. That starts the API server on port 7171. `npm run dev` will start another web server on another port which means you will struggle to connect to the API. to help, I added an environment variable you can set. In the default case, it would look something like this: 8 | 9 | ```text 10 | VITE_GOWITNESS_API_BASE_URL=http://localhost:7171 npm run dev 11 | ``` 12 | 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.dll 4 | *.so 5 | *.dylib 6 | *.sqlite3 7 | *.jsonl 8 | gowitness 9 | # dep vendor/ 10 | vendor/ 11 | 12 | # build artifacts 13 | build/ 14 | 15 | # screenshots dir 16 | screenshots/ 17 | 18 | # Test binary, build with `go test -c` 19 | *.test 20 | 21 | # Output of the go coverage tool, specifically when used with LiteIDE 22 | *.out 23 | 24 | # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 25 | .glide/ 26 | .DS_Store 27 | .idea/ 28 | 29 | # profiles 30 | profiles/ 31 | -------------------------------------------------------------------------------- /web/ui/dist/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | gowitness - a golang screenshotting tool by @leonjza 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | -------------------------------------------------------------------------------- /internal/ascii/markdown.go: -------------------------------------------------------------------------------- 1 | package ascii 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/charmbracelet/glamour" 7 | ) 8 | 9 | var renderer *glamour.TermRenderer 10 | 11 | // Markdown renders markdown 12 | func Markdown(s string) string { 13 | r, err := renderer.Render(strings.TrimSpace(s)) 14 | if err != nil { 15 | panic(err) 16 | } 17 | 18 | return r 19 | } 20 | 21 | func init() { 22 | var err error 23 | renderer, err = glamour.NewTermRenderer( 24 | glamour.WithAutoStyle(), 25 | glamour.WithPreservedNewLines(), 26 | ) 27 | if err != nil { 28 | panic(err) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /web/ui/tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "lib": ["ES2023"], 5 | "module": "ESNext", 6 | "skipLibCheck": true, 7 | 8 | /* Bundler mode */ 9 | "moduleResolution": "bundler", 10 | "allowImportingTsExtensions": true, 11 | "isolatedModules": true, 12 | "moduleDetection": "force", 13 | "noEmit": true, 14 | 15 | /* Linting */ 16 | "strict": true, 17 | "noUnusedLocals": true, 18 | "noUnusedParameters": true, 19 | "noFallthroughCasesInSwitch": true 20 | }, 21 | "include": ["vite.config.ts"] 22 | } 23 | -------------------------------------------------------------------------------- /web/ui/src/pages/search/action.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from "react-router-dom"; 2 | 3 | // searchAction grabs the form to search, encodes the data and 4 | // redirects to the search URI that will trigger the loader 5 | const searchAction = async ({ request }: { request: Request; }) => { 6 | const formData = await request.formData(); 7 | const searchQuery = formData.get("query"); // Extract the search term 8 | 9 | if (!searchQuery) { 10 | return { error: "Search query is missing" }; 11 | } 12 | 13 | return redirect(`/search?query=${encodeURIComponent(searchQuery as string)}`); 14 | }; 15 | 16 | export { searchAction }; -------------------------------------------------------------------------------- /pkg/readers/reader.go: -------------------------------------------------------------------------------- 1 | package readers 2 | 3 | // Reader defines a reader. 4 | // NOTE: The Reader needs to close the channel when done to stop the runner. 5 | // You would typically do this with a "defer close(ch)" at the start of your 6 | // Read() implementation. 7 | type Reader interface { 8 | Read(chan<- string) error 9 | } 10 | 11 | // port collections that readers can refer to 12 | var ( 13 | small = []int{8080, 8443} 14 | medium = append(small, []int{81, 90, 591, 3000, 3128, 8000, 8008, 8081, 8082, 8834, 8888, 7015, 8800, 8990, 10000}...) 15 | large = append(medium, []int{300, 2082, 2087, 2095, 4243, 4993, 5000, 7000, 7171, 7396, 7474, 8090, 8280, 8880, 9443}...) 16 | ) 17 | -------------------------------------------------------------------------------- /cmd/version.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/sensepost/gowitness/internal/ascii" 7 | "github.com/sensepost/gowitness/internal/version" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | var versionCmd = &cobra.Command{ 12 | Use: "version", 13 | Short: "Get the gowitness version", 14 | Long: ascii.LogoHelp(`Get the gowitness version.`), 15 | Run: func(cmd *cobra.Command, args []string) { 16 | fmt.Println(ascii.Logo()) 17 | fmt.Printf("\ngowitness: %s\ngit hash: %s\nbuild env: %s\nbuild time: %s\n", 18 | version.Version, version.GitHash, version.GoBuildEnv, version.GoBuildTime) 19 | }, 20 | } 21 | 22 | func init() { 23 | rootCmd.AddCommand(versionCmd) 24 | } 25 | -------------------------------------------------------------------------------- /web/ui/src/pages/table/data.tsx: -------------------------------------------------------------------------------- 1 | import * as api from "@/lib/api/api"; 2 | import * as apitypes from "@/lib/api/types"; 3 | import { toast } from "@/hooks/use-toast"; 4 | 5 | const getData = async ( 6 | setLoading: React.Dispatch>, 7 | setList: React.Dispatch> 8 | ) => { 9 | setLoading(true); 10 | try { 11 | const s = await api.get('list'); 12 | setList(s); 13 | } catch (err) { 14 | toast({ 15 | title: "API Error", 16 | variant: "destructive", 17 | description: `Failed to get list: ${err}` 18 | }); 19 | } finally { 20 | setLoading(false); 21 | } 22 | }; 23 | 24 | export { getData }; 25 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /web/api/ping.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | ) 7 | 8 | // PingHandler handles ping requests 9 | // 10 | // @Summary Ping the server 11 | // @Description Returns a simple "pong" response to test server availability. 12 | // @Tags Health 13 | // @Accept json 14 | // @Produce json 15 | // @Success 200 {string} string "pong" 16 | // @Router /api/ping [get] 17 | func (h *ApiHandler) PingHandler(w http.ResponseWriter, r *http.Request) { 18 | response := `pong` 19 | 20 | jsonData, err := json.Marshal(response) 21 | if err != nil { 22 | http.Error(w, "Error creating JSON response", http.StatusInternalServerError) 23 | return 24 | } 25 | 26 | w.Write(jsonData) 27 | } 28 | -------------------------------------------------------------------------------- /web/ui/src/pages/dashboard/data.tsx: -------------------------------------------------------------------------------- 1 | import * as api from "@/lib/api/api"; 2 | import * as apitypes from "@/lib/api/types"; 3 | import { toast } from "@/hooks/use-toast"; 4 | 5 | const getData = async ( 6 | setLoading: React.Dispatch>, 7 | setStats: React.Dispatch>, 8 | ) => { 9 | setLoading(true); 10 | try { 11 | const s = await api.get('statistics'); 12 | setStats(s); 13 | } catch (err) { 14 | toast({ 15 | title: "API Error", 16 | variant: "destructive", 17 | description: `Failed to get statistics: ${err}` 18 | }); 19 | } finally { 20 | setLoading(false); 21 | } 22 | }; 23 | 24 | export { getData }; 25 | -------------------------------------------------------------------------------- /web/spa.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "embed" 5 | "fmt" 6 | "io/fs" 7 | "net/http" 8 | "os" 9 | "path" 10 | "strings" 11 | ) 12 | 13 | //go:embed ui/dist/* 14 | var ui embed.FS 15 | 16 | // SpaHandler handles request to the SPA 17 | func SpaHandler() http.HandlerFunc { 18 | spaFS, err := fs.Sub(ui, "ui/dist") 19 | if err != nil { 20 | panic(fmt.Errorf("failed getting the sub tree for the site files: %w", err)) 21 | } 22 | 23 | return func(w http.ResponseWriter, r *http.Request) { 24 | f, err := spaFS.Open(strings.TrimPrefix(path.Clean(r.URL.Path), "/")) 25 | if err == nil { 26 | defer f.Close() 27 | } 28 | if os.IsNotExist(err) { 29 | r.URL.Path = "/" 30 | } 31 | http.FileServer(http.FS(spaFS)).ServeHTTP(w, r) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Version Information:** 27 | - OS: [e.g. iOS] 28 | - gowitness: [e.g. 1.3.3] 29 | 30 | **Additional context** 31 | Add any other context about the problem here. 32 | -------------------------------------------------------------------------------- /web/ui/src/pages/detail/actions.ts: -------------------------------------------------------------------------------- 1 | import { ActionFunction, redirect } from "react-router-dom"; 2 | import { toast } from "@/hooks/use-toast"; 3 | import * as api from "@/lib/api/api"; 4 | 5 | 6 | const deleteAction: ActionFunction = async ({ params }) => { 7 | const id = params?.id; 8 | 9 | if (!id) throw new Error("id was not set"); 10 | 11 | try { 12 | await api.post('delete', { id: parseInt(id) }); 13 | } catch (err) { 14 | toast({ 15 | title: "Error", 16 | description: `Could not delete result: ${err}`, 17 | variant: "destructive" 18 | }); 19 | 20 | return null; 21 | } 22 | 23 | toast({ 24 | description: "Result deleted" 25 | }); 26 | 27 | return redirect("/gallery"); 28 | }; 29 | 30 | export { deleteAction }; -------------------------------------------------------------------------------- /web/ui/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "noEmit": true, 15 | "jsx": "react-jsx", 16 | 17 | /* Linting */ 18 | "strict": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noFallthroughCasesInSwitch": true, 22 | 23 | /* shadcn */ 24 | "baseUrl": ".", 25 | "paths": { 26 | "@/*": [ 27 | "./src/*" 28 | ] 29 | } 30 | }, 31 | "include": ["src"] 32 | } 33 | -------------------------------------------------------------------------------- /web/ui/src/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as LabelPrimitive from "@radix-ui/react-label" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const labelVariants = cva( 8 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 9 | ) 10 | 11 | const Label = React.forwardRef< 12 | React.ElementRef, 13 | React.ComponentPropsWithoutRef & 14 | VariantProps 15 | >(({ className, ...props }, ref) => ( 16 | 21 | )) 22 | Label.displayName = LabelPrimitive.Root.displayName 23 | 24 | export { Label } 25 | -------------------------------------------------------------------------------- /web/api/handler.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | wappalyzer "github.com/projectdiscovery/wappalyzergo" 5 | "github.com/sensepost/gowitness/pkg/database" 6 | "gorm.io/gorm" 7 | ) 8 | 9 | // ApiHandler is an API handler 10 | type ApiHandler struct { 11 | DbURI string 12 | ScreenshotPath string 13 | DB *gorm.DB 14 | Wappalyzer *wappalyzer.Wappalyze 15 | } 16 | 17 | // NewApiHandler returns a new ApiHandler 18 | func NewApiHandler(uri string, screenshotPath string) (*ApiHandler, error) { 19 | 20 | // get a db handle 21 | conn, err := database.Connection(uri, false, false) 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | wap, _ := wappalyzer.New() 27 | 28 | return &ApiHandler{ 29 | DbURI: uri, 30 | ScreenshotPath: screenshotPath, 31 | DB: conn, 32 | Wappalyzer: wap, 33 | }, nil 34 | } 35 | -------------------------------------------------------------------------------- /web/ui/eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | }, 27 | }, 28 | ) 29 | -------------------------------------------------------------------------------- /web/ui/src/components/ui/input.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | export interface InputProps 6 | extends React.InputHTMLAttributes {} 7 | 8 | const Input = React.forwardRef( 9 | ({ className, type, ...props }, ref) => { 10 | return ( 11 | 20 | ) 21 | } 22 | ) 23 | Input.displayName = "Input" 24 | 25 | export { Input } 26 | -------------------------------------------------------------------------------- /web/ui/src/components/ui/toaster.tsx: -------------------------------------------------------------------------------- 1 | import { useToast } from "@/hooks/use-toast" 2 | import { 3 | Toast, 4 | ToastClose, 5 | ToastDescription, 6 | ToastProvider, 7 | ToastTitle, 8 | ToastViewport, 9 | } from "@/components/ui/toast" 10 | 11 | export function Toaster() { 12 | const { toasts } = useToast() 13 | 14 | return ( 15 | 16 | {toasts.map(function ({ id, title, description, action, ...props }) { 17 | return ( 18 | 19 |
20 | {title && {title}} 21 | {description && ( 22 | {description} 23 | )} 24 |
25 | {action} 26 | 27 |
28 | ) 29 | })} 30 | 31 |
32 | ) 33 | } 34 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1-bookworm AS build 2 | 3 | RUN apt-get update && \ 4 | apt-get install -y npm 5 | 6 | ADD . /src 7 | WORKDIR /src 8 | 9 | RUN cd web/ui && \ 10 | rm -Rf node_modules && \ 11 | npm i && \ 12 | npm run build && \ 13 | cd ../.. 14 | RUN go install github.com/swaggo/swag/cmd/swag@latest && \ 15 | swag i --exclude ./web/ui --output web/docs && \ 16 | go build -trimpath -ldflags="-s -w \ 17 | -X=github.com/sensepost/gowitness/internal/version.GitHash=$(git rev-parse --short HEAD) \ 18 | -X=github.com/sensepost/gowitness/internal/version.GoBuildEnv=$(go version | cut -d' ' -f 3,4 | sed 's/ /_/g') \ 19 | -X=github.com/sensepost/gowitness/internal/version.GoBuildTime=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" \ 20 | -o gowitness 21 | 22 | FROM ghcr.io/go-rod/rod 23 | 24 | COPY --from=build /src/gowitness /usr/local/bin/gowitness 25 | 26 | EXPOSE 7171 27 | 28 | VOLUME ["/data"] 29 | WORKDIR /data 30 | 31 | ENTRYPOINT ["dumb-init", "--"] -------------------------------------------------------------------------------- /internal/islazy/net.go: -------------------------------------------------------------------------------- 1 | package islazy 2 | 3 | import ( 4 | "encoding/binary" 5 | "net" 6 | ) 7 | 8 | // IpsInCIDR returns a list of usable IP addresses in a given CIDR block 9 | // excluding network and broadcast addresses for CIDRs larger than /31. 10 | func IpsInCIDR(cidr string) ([]string, error) { 11 | _, ipnet, err := net.ParseCIDR(cidr) 12 | if err != nil { 13 | return nil, err 14 | } 15 | 16 | mask := binary.BigEndian.Uint32(ipnet.Mask) 17 | start := binary.BigEndian.Uint32(ipnet.IP) 18 | end := (start & mask) | (mask ^ 0xFFFFFFFF) 19 | 20 | var ips []string 21 | ip := make(net.IP, 4) // Preallocate buffer 22 | 23 | // Iterate over the range of IPs 24 | for i := start; i <= end; i++ { 25 | // Exclude network and broadcast addresses in larger CIDR ranges 26 | if !(i&0xFF == 255 || i&0xFF == 0) || ipnet.Mask[3] >= 30 { 27 | binary.BigEndian.PutUint32(ip, i) 28 | ips = append(ips, ip.String()) 29 | } 30 | } 31 | 32 | return ips, nil 33 | } 34 | -------------------------------------------------------------------------------- /web/ui/src/components/loading.tsx: -------------------------------------------------------------------------------- 1 | import { Skeleton } from "./ui/skeleton"; 2 | 3 | const WideSkeleton = () => { 4 | return ( 5 |
6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 |
14 |
15 | ); 16 | }; 17 | 18 | const SmallSkeleton = () => { 19 | return ( 20 |
21 | 22 |
23 | 24 | 25 |
26 |
27 | ); 28 | }; 29 | 30 | export { SmallSkeleton, WideSkeleton }; -------------------------------------------------------------------------------- /pkg/runner/drivers/restricted_ports.go: -------------------------------------------------------------------------------- 1 | package driver 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | ) 7 | 8 | // src: https://chromium.googlesource.com/chromium/src.git/+/refs/heads/master/net/base/port_util.cc#27 9 | var kRestrictedPorts = []int{ 10 | 1, 7, 9, 11, 13, 15, 17, 19, 20, 21, 22, 23, 25, 37, 42, 43, 53, 69, 77, 79, 11 | 87, 95, 101, 102, 103, 104, 109, 110, 111, 113, 115, 117, 119, 123, 135, 137, 12 | 139, 143, 161, 179, 389, 427, 465, 512, 513, 514, 515, 526, 530, 531, 532, 13 | 540, 548, 554, 556, 563, 587, 601, 636, 989, 990, 993, 995, 1719, 1720, 1723, 14 | 2049, 3659, 4045, 5060, 5061, 6000, 6566, 6665, 6666, 6667, 6668, 6669, 6697, 15 | 10080, 16 | } 17 | 18 | // restrictedPorts returns a a string of Chrome's restricted ports as a comma 19 | // separated list of integers. 20 | func restrictedPorts() string { 21 | var strPorts []string 22 | for _, port := range kRestrictedPorts { 23 | strPorts = append(strPorts, strconv.Itoa(port)) 24 | } 25 | 26 | return strings.Join(strPorts, ",") 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/install-check.yml: -------------------------------------------------------------------------------- 1 | name: Go Install Check 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | pull_request: 7 | branches: [master] 8 | schedule: 9 | - cron: "0 0 * * 1" 10 | workflow_dispatch: 11 | 12 | jobs: 13 | build: 14 | runs-on: ubuntu-latest 15 | 16 | steps: 17 | - name: Checkout Repository 18 | uses: actions/checkout@v3 19 | 20 | - name: Set up Go 21 | uses: actions/setup-go@v4 22 | with: 23 | go-version: "1.25" 24 | 25 | - name: Install chromium 26 | run: | 27 | sudo apt-get update && \ 28 | sudo apt-get install -y \ 29 | ca-certificates chromium \ 30 | 31 | - name: Install gowitness 32 | run: | 33 | go install ./... 34 | 35 | - name: Verify Installation 36 | run: | 37 | gowitness scan single -u https://sensepost.com --write-jsonl 38 | 39 | - name: Generate a static report 40 | run: | 41 | gowitness report generate --json-file gowitness.jsonl 42 | -------------------------------------------------------------------------------- /web/api/wappalyzer.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | ) 8 | 9 | // probably not the smartest idea, but he 10 | const iconBase = `https://raw.githubusercontent.com/enthec/webappanalyzer/main/src/images/icons/` 11 | 12 | // WappalyzerHandler returns wappalyzer data 13 | // 14 | // @Summary Get wappalyzer data 15 | // @Description Get all of the available wappalyzer data. 16 | // @Tags Results 17 | // @Accept json 18 | // @Produce json 19 | // @Success 200 {object} map[string]string 20 | // @Router /wappalyzer [get] 21 | func (h *ApiHandler) WappalyzerHandler(w http.ResponseWriter, r *http.Request) { 22 | response := make(map[string]string) 23 | 24 | for name, finger := range h.Wappalyzer.GetFingerprints().Apps { 25 | if finger.Icon == "" { 26 | continue 27 | } 28 | 29 | response[name] = fmt.Sprintf(`%s%s`, iconBase, finger.Icon) 30 | } 31 | 32 | jsonData, err := json.Marshal(response) 33 | if err != nil { 34 | http.Error(w, err.Error(), http.StatusInternalServerError) 35 | return 36 | } 37 | 38 | w.Write(jsonData) 39 | } 40 | -------------------------------------------------------------------------------- /web/ui/src/lib/common.ts: -------------------------------------------------------------------------------- 1 | import { toast } from "@/hooks/use-toast"; 2 | import * as apitypes from "@/lib/api/types"; 3 | 4 | const copyToClipboard = (content: string, type: string) => { 5 | navigator.clipboard.writeText(content).then(() => { 6 | toast({ 7 | description: `${type} copied to clipboard`, 8 | }); 9 | }).catch((err) => { 10 | console.error('Failed to copy content: ', err); 11 | toast({ 12 | title: "Error", 13 | description: "Failed to copy content", 14 | variant: "destructive", 15 | }); 16 | }); 17 | }; 18 | 19 | const getIconUrl = (tech: string, wappalyzer: apitypes.wappalyzer | undefined): string | undefined => { 20 | if (!wappalyzer || !(tech in wappalyzer)) return undefined; 21 | 22 | return wappalyzer[tech]; 23 | }; 24 | 25 | const getStatusColor = (code: number) => { 26 | if (code >= 200 && code < 300) return "bg-green-500 text-white"; 27 | if (code >= 400 && code < 500) return "bg-yellow-500 text-black"; 28 | if (code >= 500) return "bg-red-500 text-white"; 29 | return "bg-gray-500 text-white"; 30 | }; 31 | 32 | export { copyToClipboard, getIconUrl, getStatusColor }; -------------------------------------------------------------------------------- /web/api/technology.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/sensepost/gowitness/pkg/log" 8 | "github.com/sensepost/gowitness/pkg/models" 9 | ) 10 | 11 | type technologyListResponse struct { 12 | Value []string `json:"technologies"` 13 | } 14 | 15 | // TechnologyListHandler lists technologies 16 | // 17 | // @Summary Get technology results 18 | // @Description Get all the unique technology detected. 19 | // @Tags Results 20 | // @Accept json 21 | // @Produce json 22 | // @Success 200 {object} technologyListResponse 23 | // @Router /results/technology [get] 24 | func (h *ApiHandler) TechnologyListHandler(w http.ResponseWriter, r *http.Request) { 25 | var results = &technologyListResponse{} 26 | 27 | if err := h.DB.Model(&models.Technology{}).Distinct("value"). 28 | Find(&results.Value).Error; err != nil { 29 | 30 | log.Error("could not find distinct technologies", "err", err) 31 | return 32 | } 33 | 34 | jsonData, err := json.Marshal(results) 35 | if err != nil { 36 | http.Error(w, err.Error(), http.StatusInternalServerError) 37 | return 38 | } 39 | 40 | w.Write(jsonData) 41 | } 42 | -------------------------------------------------------------------------------- /web/api/gallery_detail.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/go-chi/chi/v5" 8 | "github.com/sensepost/gowitness/pkg/log" 9 | "github.com/sensepost/gowitness/pkg/models" 10 | "gorm.io/gorm/clause" 11 | ) 12 | 13 | // DetailHandler returns the detail for a screenshot 14 | // 15 | // @Summary Results detail 16 | // @Description Get details for a result. 17 | // @Tags Results 18 | // @Accept json 19 | // @Produce json 20 | // @Param id path int true "The screenshot ID to load." 21 | // @Success 200 {object} models.Result 22 | // @Router /results/detail/{id} [get] 23 | func (h *ApiHandler) DetailHandler(w http.ResponseWriter, r *http.Request) { 24 | var response = &models.Result{} 25 | 26 | if err := h.DB.Model(&models.Result{}). 27 | Preload(clause.Associations). 28 | Preload("TLS.SanList"). 29 | First(&response, chi.URLParam(r, "id")).Error; err != nil { 30 | 31 | log.Error("could not get detail for id", "err", err) 32 | return 33 | } 34 | 35 | jsonData, err := json.Marshal(response) 36 | if err != nil { 37 | http.Error(w, err.Error(), http.StatusInternalServerError) 38 | return 39 | } 40 | 41 | w.Write(jsonData) 42 | } 43 | -------------------------------------------------------------------------------- /internal/islazy/slices.go: -------------------------------------------------------------------------------- 1 | package islazy 2 | 3 | import ( 4 | "time" 5 | 6 | "math/rand" 7 | ) 8 | 9 | // SliceHasStr checks if a slice has a string 10 | func SliceHasStr(slice []string, item string) bool { 11 | for _, s := range slice { 12 | if s == item { 13 | return true 14 | } 15 | } 16 | 17 | return false 18 | } 19 | 20 | // SliceHasInt checks if a slice has an int 21 | func SliceHasInt(slice []int, item int) bool { 22 | for _, s := range slice { 23 | if s == item { 24 | return true 25 | } 26 | } 27 | 28 | return false 29 | } 30 | 31 | // UniqueIntSlice returns a slice of unique ints 32 | func UniqueIntSlice(slice []int) []int { 33 | seen := make(map[int]bool) 34 | result := []int{} 35 | 36 | for _, num := range slice { 37 | if !seen[num] { 38 | seen[num] = true 39 | result = append(result, num) 40 | } 41 | } 42 | 43 | return result 44 | } 45 | 46 | // ShuffleStr shuffles a slice of strings 47 | func ShuffleStr(slice []string) { 48 | source := rand.NewSource(time.Now().UnixNano()) 49 | rng := rand.New(source) 50 | 51 | // Fisher-Yates shuffle algorithm 52 | for i := len(slice) - 1; i > 0; i-- { 53 | j := rng.Intn(i + 1) 54 | slice[i], slice[j] = slice[j], slice[i] 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /pkg/writers/json.go: -------------------------------------------------------------------------------- 1 | package writers 2 | 3 | import ( 4 | "encoding/json" 5 | "os" 6 | 7 | "github.com/sensepost/gowitness/internal/islazy" 8 | "github.com/sensepost/gowitness/pkg/models" 9 | ) 10 | 11 | // JsonWriter is a JSON lines writer 12 | type JsonWriter struct { 13 | FilePath string 14 | } 15 | 16 | // NewJsonWriter return a new Json lines writer 17 | func NewJsonWriter(destination string) (*JsonWriter, error) { 18 | // check if the destination exists, if not, create it 19 | dst, err := islazy.CreateFileWithDir(destination) 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | return &JsonWriter{ 25 | FilePath: dst, 26 | }, nil 27 | } 28 | 29 | // Write JSON lines to a file 30 | func (jw *JsonWriter) Write(result *models.Result) error { 31 | j, err := json.Marshal(result) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | // Open the file in append mode, create it if it doesn't exist 37 | file, err := os.OpenFile(jw.FilePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 38 | if err != nil { 39 | return err 40 | } 41 | defer file.Close() 42 | 43 | // Append the JSON data as a new line 44 | if _, err := file.Write(append(j, '\n')); err != nil { 45 | return err 46 | } 47 | 48 | return nil 49 | } 50 | -------------------------------------------------------------------------------- /web/ui/src/pages/App.tsx: -------------------------------------------------------------------------------- 1 | import Navigation from "@/components/navigation"; 2 | import { ThemeProvider } from "@/components/theme-provider"; 3 | import { Toaster } from "@/components/ui/toaster"; 4 | import { Outlet } from "react-router-dom"; 5 | 6 | const App = () => { 7 | return ( 8 | 9 |
10 |
11 | 12 |
13 |
14 | 15 |
16 | 25 |
26 | 27 |
28 | ); 29 | }; 30 | 31 | export default App; -------------------------------------------------------------------------------- /internal/islazy/hamming.go: -------------------------------------------------------------------------------- 1 | package islazy 2 | 3 | import ( 4 | "encoding/hex" 5 | "errors" 6 | "strings" 7 | ) 8 | 9 | // HammingGroup represents a hash -> group assignment used for 10 | // inmemory hammingdistance calulations. 11 | type HammingGroup struct { 12 | GroupID uint 13 | Hash []byte 14 | } 15 | 16 | // HammingDistance calculates the number of differing bits between two byte slices. 17 | func HammingDistance(hash1, hash2 []byte) (int, error) { 18 | if len(hash1) != len(hash2) { 19 | return 0, errors.New("hash lengths do not match") 20 | } 21 | 22 | distance := 0 23 | for i := 0; i < len(hash1); i++ { 24 | x := hash1[i] ^ hash2[i] 25 | for x != 0 { 26 | distance++ 27 | x &= x - 1 28 | } 29 | } 30 | 31 | return distance, nil 32 | } 33 | 34 | // ParsePerceptionHash converts a perception hash string "p:" to a byte slice. 35 | func ParsePerceptionHash(hashStr string) ([]byte, error) { 36 | if !strings.HasPrefix(hashStr, "p:") { 37 | return nil, errors.New("invalid perception hash format: missing 'p:' prefix") 38 | } 39 | 40 | hexPart := strings.TrimPrefix(hashStr, "p:") 41 | 42 | bytes, err := hex.DecodeString(hexPart) 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | return bytes, nil 48 | } 49 | -------------------------------------------------------------------------------- /web/ui/src/components/ui/tooltip.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as TooltipPrimitive from "@radix-ui/react-tooltip" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const TooltipProvider = TooltipPrimitive.Provider 7 | 8 | const Tooltip = TooltipPrimitive.Root 9 | 10 | const TooltipTrigger = TooltipPrimitive.Trigger 11 | 12 | const TooltipContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, sideOffset = 4, ...props }, ref) => ( 16 | 25 | )) 26 | TooltipContent.displayName = TooltipPrimitive.Content.displayName 27 | 28 | export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } 29 | -------------------------------------------------------------------------------- /web/ui/src/components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as SwitchPrimitives from "@radix-ui/react-switch" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const Switch = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, ...props }, ref) => ( 10 | 18 | 23 | 24 | )) 25 | Switch.displayName = SwitchPrimitives.Root.displayName 26 | 27 | export { Switch } 28 | -------------------------------------------------------------------------------- /web/ui/src/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /web/ui/src/components/mode-toggle.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Moon, 3 | Sun 4 | } from "lucide-react"; 5 | import { Button } from "@/components/ui/button"; 6 | import { 7 | DropdownMenu, 8 | DropdownMenuContent, 9 | DropdownMenuItem, 10 | DropdownMenuTrigger, 11 | } from "@/components/ui/dropdown-menu"; 12 | import { useTheme } from "@/components/theme-provider"; 13 | 14 | export function ModeToggle() { 15 | const { setTheme } = useTheme(); 16 | 17 | return ( 18 | 19 | 20 | 25 | 26 | 27 | setTheme("light")}> 28 | Light 29 | 30 | setTheme("dark")}> 31 | Dark 32 | 33 | setTheme("system")}> 34 | System 35 | 36 | 37 | 38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /web/ui/src/components/ui/popover.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as PopoverPrimitive from "@radix-ui/react-popover" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const Popover = PopoverPrimitive.Root 7 | 8 | const PopoverTrigger = PopoverPrimitive.Trigger 9 | 10 | const PopoverAnchor = PopoverPrimitive.Anchor 11 | 12 | const PopoverContent = React.forwardRef< 13 | React.ElementRef, 14 | React.ComponentPropsWithoutRef 15 | >(({ className, align = "center", sideOffset = 4, ...props }, ref) => ( 16 | 17 | 27 | 28 | )) 29 | PopoverContent.displayName = PopoverPrimitive.Content.displayName 30 | 31 | export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor } 32 | -------------------------------------------------------------------------------- /web/api/delete.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/sensepost/gowitness/pkg/log" 8 | "github.com/sensepost/gowitness/pkg/models" 9 | ) 10 | 11 | type deleteResultRequest struct { 12 | ID int `json:"id"` 13 | } 14 | 15 | // DeleteResultHandler deletes results from the database 16 | // 17 | // @Summary Delete a result 18 | // @Description Deletes a result, by id, and all of its associated data from the database. 19 | // @Tags Results 20 | // @Accept json 21 | // @Produce json 22 | // @Param query body deleteResultRequest true "The result ID to delete" 23 | // @Success 200 {string} string "ok" 24 | // @Router /results/delete [post] 25 | func (h *ApiHandler) DeleteResultHandler(w http.ResponseWriter, r *http.Request) { 26 | var request deleteResultRequest 27 | if err := json.NewDecoder(r.Body).Decode(&request); err != nil { 28 | log.Error("failed to read json request", "err", err) 29 | http.Error(w, "Error reading JSON request", http.StatusInternalServerError) 30 | return 31 | } 32 | 33 | log.Info("deleting id", "id", request.ID) 34 | 35 | if err := h.DB.Delete(&models.Result{}, request.ID).Error; err != nil { 36 | log.Error("failed to delete result", "err", err) 37 | return 38 | } 39 | 40 | response := `ok` 41 | jsonData, err := json.Marshal(response) 42 | if err != nil { 43 | http.Error(w, "Error creating JSON response", http.StatusInternalServerError) 44 | return 45 | } 46 | 47 | w.Write(jsonData) 48 | } 49 | -------------------------------------------------------------------------------- /web/api/list.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/sensepost/gowitness/pkg/log" 8 | "github.com/sensepost/gowitness/pkg/models" 9 | ) 10 | 11 | type listResponse struct { 12 | ID uint `json:"id" gorm:"primarykey"` 13 | 14 | URL string `json:"url"` 15 | FinalURL string `json:"final_url"` 16 | ResponseCode int `json:"response_code"` 17 | ResponseReason string `json:"response_reason"` 18 | Protocol string `json:"protocol"` 19 | ContentLength int64 `json:"content_length"` 20 | Title string `json:"title"` 21 | 22 | // Failed flag set if the result should be considered failed 23 | Failed bool `json:"failed"` 24 | FailedReason string `json:"failed_reason"` 25 | } 26 | 27 | // ListHandler returns a simple list of results 28 | // 29 | // @Summary Results list 30 | // @Description Get a simple list of all results. 31 | // @Tags Results 32 | // @Accept json 33 | // @Produce json 34 | // @Success 200 {object} listResponse 35 | // @Router /results/list [get] 36 | func (h *ApiHandler) ListHandler(w http.ResponseWriter, r *http.Request) { 37 | var results = []*listResponse{} 38 | 39 | if err := h.DB.Model(&models.Result{}).Find(&results).Error; err != nil { 40 | log.Error("could not get list", "err", err) 41 | return 42 | } 43 | 44 | jsonData, err := json.Marshal(results) 45 | if err != nil { 46 | http.Error(w, err.Error(), http.StatusInternalServerError) 47 | return 48 | } 49 | 50 | w.Write(jsonData) 51 | } 52 | -------------------------------------------------------------------------------- /cmd/scan_single.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/sensepost/gowitness/internal/ascii" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | var singleCmdOptions = struct { 11 | URL string 12 | }{} 13 | 14 | var singleCmd = &cobra.Command{ 15 | Use: "single", 16 | Short: "Scan a single URL target", 17 | Long: ascii.LogoHelp(ascii.Markdown(` 18 | # scan single 19 | 20 | Scan a single URL target. 21 | 22 | **Note**: By default, no metadata is saved except for screenshots that are 23 | stored in the configured --screenshot-path. For later parsing (i.e., using the 24 | gowitness reporting feature), you need to specify where to write results (db, 25 | csv, jsonl) using the _--write-*_ set of flags. See _--help_ for available 26 | flags.`)), 27 | Example: ascii.Markdown(` 28 | - gowitness scan single -u https://sensepost.com 29 | - gowitness scan single -u https://sensepost.com --write-jsonl 30 | `), 31 | PreRunE: func(cmd *cobra.Command, args []string) error { 32 | if singleCmdOptions.URL == "" { 33 | return errors.New("a URL must be specified") 34 | } 35 | return nil 36 | }, 37 | Run: func(cmd *cobra.Command, args []string) { 38 | url, _ := cmd.Flags().GetString("url") 39 | 40 | go func() { 41 | scanRunner.Targets <- url 42 | close(scanRunner.Targets) 43 | }() 44 | 45 | scanRunner.Run() 46 | scanRunner.Close() 47 | }, 48 | } 49 | 50 | func init() { 51 | scanCmd.AddCommand(singleCmd) 52 | 53 | singleCmd.Flags().StringVarP(&singleCmdOptions.URL, "url", "u", "", "The target to screenshot") 54 | } 55 | -------------------------------------------------------------------------------- /cmd/report_server.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/sensepost/gowitness/internal/ascii" 5 | "github.com/sensepost/gowitness/web" 6 | "github.com/spf13/cobra" 7 | ) 8 | 9 | var serverCmdFlags = struct { 10 | Host string 11 | Port int 12 | DbUri string 13 | ScreenshotPath string 14 | }{} 15 | var serverCmd = &cobra.Command{ 16 | Use: "server", 17 | Short: "Start the web user interface", 18 | Long: ascii.LogoHelp(ascii.Markdown(` 19 | # report server 20 | 21 | Start the web user interface.`)), 22 | Example: ascii.Markdown(` 23 | - gowitness report server 24 | - gowitness report server --port 8080 --db-uri /tmp/gowitness.sqlite3 25 | - gowitness report server --screenshot-path /tmp/screenshots`), 26 | Run: func(cmd *cobra.Command, args []string) { 27 | server := web.NewServer( 28 | serverCmdFlags.Host, 29 | serverCmdFlags.Port, 30 | serverCmdFlags.DbUri, 31 | serverCmdFlags.ScreenshotPath, 32 | ) 33 | server.Run() 34 | }, 35 | } 36 | 37 | func init() { 38 | reportCmd.AddCommand(serverCmd) 39 | 40 | serverCmd.Flags().StringVar(&serverCmdFlags.Host, "host", "127.0.0.1", "The host address to bind the webserver to") 41 | serverCmd.Flags().IntVar(&serverCmdFlags.Port, "port", 7171, "The port to start the web server on") 42 | serverCmd.Flags().StringVar(&serverCmdFlags.DbUri, "db-uri", "sqlite://gowitness.sqlite3", "The database URI to use. Supports SQLite, MySQL, and PostgreSQL. Examples: sqlite://gowitness.sqlite3, mysql://user:pass@localhost:3306/gowitness, postgres://user:pass@localhost:5432/gowitness") 43 | serverCmd.Flags().StringVar(&serverCmdFlags.ScreenshotPath, "screenshot-path", "./screenshots", "The path where screenshots are stored") 44 | } 45 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | gowitness: 5 | image: ghcr.io/sensepost/gowitness:latest 6 | restart: unless-stopped 7 | command: gowitness report server --host 0.0.0.0 --screenshot-path /data/screenshots --db-uri sqlite:///data/gowitness.sqlite3 8 | volumes: 9 | - ./gowitness.sqlite3:/data/gowitness.sqlite3 10 | - ./screenshots:/data/screenshots 11 | labels: 12 | traefik.enable: true 13 | traefik.http.routers.gowitness.rule: Host(`gowitness.local`) 14 | traefik.http.routers.gowitness.entryPoints: web-secure 15 | traefik.http.routers.gowitness.tls.certResolver: default 16 | traefik.http.routers.gowitness.middlewares: basic-auth 17 | 18 | traefik: 19 | image: traefik:3.1 20 | restart: unless-stopped 21 | command: 22 | - --log.level=INFO 23 | - --api.dashboard=true 24 | - --providers.docker 25 | - --providers.docker.exposedByDefault=false 26 | - --entryPoints.web-secure.address=:443 27 | ports: 28 | - 443:443 29 | volumes: 30 | - /var/run/docker.sock:/var/run/docker.sock:ro 31 | labels: 32 | traefik.enable: true 33 | # configure http basic auth to protect the UI. either set it statically, or 34 | # consider using something like trauth[1] to manage it. 35 | # [1] https://github.com/leonjza/trauth 36 | # 37 | # example credentials here are: 38 | # gowitness:gowitness 39 | # use `htpasswd` to get a hash for a different password. also note you need to escape $ with another $ to $$'s 40 | traefik.http.middlewares.basic-auth.basicauth.users: gowitness:$$2y$$05$$7ADiPfM1kMABSUjofyUTOuJ7U6RcGi5fXeecyYnxjYwRluRRIO1.. 41 | traefik.http.middlewares.basic-auth.basicauth.realm: gowitness 42 | -------------------------------------------------------------------------------- /web/ui/src/components/ui/scroll-area.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const ScrollArea = React.forwardRef< 7 | React.ElementRef, 8 | React.ComponentPropsWithoutRef 9 | >(({ className, children, ...props }, ref) => ( 10 | 15 | 16 | {children} 17 | 18 | 19 | 20 | 21 | )) 22 | ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName 23 | 24 | const ScrollBar = React.forwardRef< 25 | React.ElementRef, 26 | React.ComponentPropsWithoutRef 27 | >(({ className, orientation = "vertical", ...props }, ref) => ( 28 | 41 | 42 | 43 | )) 44 | ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName 45 | 46 | export { ScrollArea, ScrollBar } 47 | -------------------------------------------------------------------------------- /web/ui/src/pages/gallery/data.tsx: -------------------------------------------------------------------------------- 1 | import * as api from "@/lib/api/api"; 2 | import * as apitypes from "@/lib/api/types"; 3 | import { toast } from "@/hooks/use-toast"; 4 | 5 | const getWappalyzerData = async ( 6 | setWappalyzer: React.Dispatch>, 7 | setTechnology: React.Dispatch> 8 | ) => { 9 | try { 10 | const [wappalyzerData, technologyData] = await Promise.all([ 11 | await api.get('wappalyzer'), 12 | await api.get('technology') 13 | ]); 14 | setWappalyzer(wappalyzerData); 15 | setTechnology(technologyData); 16 | } catch (err) { 17 | toast({ 18 | title: "API Error", 19 | variant: "destructive", 20 | description: `Failed to get wappalyzer / technology data: ${err}` 21 | }); 22 | } 23 | }; 24 | 25 | const getData = async ( 26 | setLoading: React.Dispatch>, 27 | setGallery: React.Dispatch>, 28 | setTotalPages: React.Dispatch>, 29 | page: number, 30 | limit: number, 31 | technologyFilter: string, 32 | statusFilter: string, 33 | perceptionGroup: boolean, 34 | showFailed: boolean, 35 | ) => { 36 | setLoading(true); 37 | try { 38 | const s = await api.get('gallery', { 39 | page, 40 | limit, 41 | technologies: technologyFilter, 42 | status: statusFilter, 43 | perception: perceptionGroup ? 'true' : 'false', 44 | failed: showFailed ? 'true' : 'false', 45 | }); 46 | setGallery(s.results); 47 | setTotalPages(Math.ceil(s.total_count / limit)); 48 | } catch (err) { 49 | toast({ 50 | title: "API Error", 51 | variant: "destructive", 52 | description: `Failed to get gallery: ${err}` 53 | }); 54 | } finally { 55 | setLoading(false); 56 | } 57 | }; 58 | 59 | export { getWappalyzerData, getData }; 60 | -------------------------------------------------------------------------------- /pkg/writers/memory.go: -------------------------------------------------------------------------------- 1 | package writers 2 | 3 | import ( 4 | "errors" 5 | "sync" 6 | 7 | "github.com/sensepost/gowitness/pkg/models" 8 | ) 9 | 10 | // MemoryWriter is a memory-based results queue with a maximum slot count 11 | type MemoryWriter struct { 12 | slots int 13 | results []*models.Result 14 | mutex sync.Mutex 15 | } 16 | 17 | // NewMemoryWriter initializes a MemoryWriter with the specified number of slots 18 | func NewMemoryWriter(slots int) (*MemoryWriter, error) { 19 | if slots <= 0 { 20 | return nil, errors.New("slots need to be a positive integer") 21 | } 22 | 23 | return &MemoryWriter{ 24 | slots: slots, 25 | results: make([]*models.Result, 0, slots), 26 | mutex: sync.Mutex{}, 27 | }, nil 28 | } 29 | 30 | // Write adds a new result to the MemoryWriter. 31 | func (s *MemoryWriter) Write(result *models.Result) error { 32 | s.mutex.Lock() 33 | defer s.mutex.Unlock() 34 | 35 | if len(s.results) >= s.slots { 36 | s.results = s.results[1:] 37 | } 38 | 39 | s.results = append(s.results, result) 40 | 41 | return nil 42 | } 43 | 44 | // GetLatest retrieves the most recently added result. 45 | func (s *MemoryWriter) GetLatest() *models.Result { 46 | s.mutex.Lock() 47 | defer s.mutex.Unlock() 48 | 49 | if len(s.results) == 0 { 50 | return nil 51 | } 52 | 53 | return s.results[len(s.results)-1] 54 | } 55 | 56 | // GetFirst retrieves the oldest result in the MemoryWriter. 57 | func (s *MemoryWriter) GetFirst() *models.Result { 58 | s.mutex.Lock() 59 | defer s.mutex.Unlock() 60 | 61 | if len(s.results) == 0 { 62 | return nil 63 | } 64 | 65 | return s.results[0] 66 | } 67 | 68 | // GetAllResults returns a copy of all current results. 69 | func (s *MemoryWriter) GetAllResults() []*models.Result { 70 | s.mutex.Lock() 71 | defer s.mutex.Unlock() 72 | 73 | // Create a copy to prevent external modification 74 | resultsCopy := make([]*models.Result, len(s.results)) 75 | copy(resultsCopy, s.results) 76 | 77 | return resultsCopy 78 | } 79 | -------------------------------------------------------------------------------- /web/ui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "gowitness", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "echo 'remember to set VITE_GOWITNESS_API_BASE_URL if its not this current server' && sleep 4 && vite", 8 | "build": "tsc -b && vite build", 9 | "lint": "eslint .", 10 | "preview": "vite preview" 11 | }, 12 | "dependencies": { 13 | "@radix-ui/react-accordion": "^1.2.0", 14 | "@radix-ui/react-collapsible": "^1.1.0", 15 | "@radix-ui/react-dialog": "^1.1.1", 16 | "@radix-ui/react-dropdown-menu": "^2.1.1", 17 | "@radix-ui/react-icons": "^1.3.0", 18 | "@radix-ui/react-label": "^2.1.0", 19 | "@radix-ui/react-popover": "^1.1.1", 20 | "@radix-ui/react-scroll-area": "^1.1.0", 21 | "@radix-ui/react-select": "^2.1.1", 22 | "@radix-ui/react-slot": "^1.1.0", 23 | "@radix-ui/react-switch": "^1.1.0", 24 | "@radix-ui/react-tabs": "^1.1.0", 25 | "@radix-ui/react-toast": "^1.2.1", 26 | "@radix-ui/react-tooltip": "^1.1.2", 27 | "class-variance-authority": "^0.7.0", 28 | "clsx": "^2.1.1", 29 | "cmdk": "^1.0.0", 30 | "date-fns": "^3.6.0", 31 | "lucide-react": "^0.439.0", 32 | "react": "^18.3.1", 33 | "react-dom": "^18.3.1", 34 | "react-router-dom": "^6.26.1", 35 | "recharts": "^2.13.0-alpha.5", 36 | "tailwind-merge": "^2.5.2", 37 | "tailwindcss-animate": "^1.0.7" 38 | }, 39 | "devDependencies": { 40 | "@eslint/js": "^9.9.0", 41 | "@types/node": "^22.5.4", 42 | "@types/react": "^18.3.3", 43 | "@types/react-dom": "^18.3.0", 44 | "@vitejs/plugin-react": "^4.3.1", 45 | "autoprefixer": "^10.4.20", 46 | "eslint": "^9.9.0", 47 | "eslint-plugin-react-hooks": "^5.1.0-rc.0", 48 | "eslint-plugin-react-refresh": "^0.4.9", 49 | "globals": "^15.9.0", 50 | "postcss": "^8.4.45", 51 | "tailwindcss": "^3.4.10", 52 | "typescript": "^5.5.3", 53 | "typescript-eslint": "^8.0.1", 54 | "vite": "^7" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /web/ui/src/components/theme-provider.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | createContext, 3 | useContext, 4 | useEffect, 5 | useState 6 | } from "react"; 7 | 8 | type Theme = "dark" | "light" | "system"; 9 | 10 | type ThemeProviderProps = { 11 | children: React.ReactNode; 12 | defaultTheme?: Theme; 13 | storageKey?: string; 14 | }; 15 | 16 | type ThemeProviderState = { 17 | theme: Theme; 18 | setTheme: (theme: Theme) => void; 19 | }; 20 | 21 | const initialState: ThemeProviderState = { 22 | theme: "system", 23 | setTheme: () => null, 24 | }; 25 | 26 | const ThemeProviderContext = createContext(initialState); 27 | 28 | export function ThemeProvider({ 29 | children, 30 | defaultTheme = "system", 31 | storageKey = "vite-ui-theme", 32 | ...props 33 | }: ThemeProviderProps) { 34 | const [theme, setTheme] = useState( 35 | () => (localStorage.getItem(storageKey) as Theme) || defaultTheme 36 | ); 37 | 38 | useEffect(() => { 39 | const root = window.document.documentElement; 40 | 41 | root.classList.remove("light", "dark"); 42 | 43 | if (theme === "system") { 44 | const systemTheme = window.matchMedia("(prefers-color-scheme: dark)") 45 | .matches 46 | ? "dark" 47 | : "light"; 48 | 49 | root.classList.add(systemTheme); 50 | return; 51 | } 52 | 53 | root.classList.add(theme); 54 | }, [theme]); 55 | 56 | const value = { 57 | theme, 58 | setTheme: (theme: Theme) => { 59 | localStorage.setItem(storageKey, theme); 60 | setTheme(theme); 61 | }, 62 | }; 63 | 64 | return ( 65 | 66 | {children} 67 | 68 | ); 69 | } 70 | 71 | export const useTheme = () => { 72 | const context = useContext(ThemeProviderContext); 73 | 74 | if (context === undefined) 75 | throw new Error("useTheme must be used within a ThemeProvider"); 76 | 77 | return context; 78 | }; 79 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | G := $(shell go version | cut -d' ' -f 3,4 | sed 's/ /_/g') 2 | V := $(shell git rev-parse --short HEAD) 3 | APPVER := $(shell grep 'Version =' internal/version/version.go | cut -d \" -f2) 4 | PWD := $(shell pwd) 5 | GOPATH := $(shell go env GOPATH) 6 | BUILD_TIME := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ") 7 | LD_FLAGS := -trimpath \ 8 | -ldflags="-s -w \ 9 | -X=github.com/sensepost/gowitness/internal/version.GitHash=$(V) \ 10 | -X=github.com/sensepost/gowitness/internal/version.GoBuildEnv=$(G) \ 11 | -X=github.com/sensepost/gowitness/internal/version.GoBuildTime=$(BUILD_TIME)" 12 | BIN_DIR := build 13 | PLATFORMS := darwin/amd64 darwin/arm64 linux/amd64 linux/arm64 linux/arm windows/amd64 windows/arm64 14 | CGO := CGO_ENABLED=0 15 | 16 | # Default target 17 | default: clean test frontend api-doc build integrity 18 | 19 | # Clean up build artifacts 20 | clean: 21 | find $(BIN_DIR) -type f -name 'gowitness-*' -delete || true 22 | go clean -x 23 | 24 | # Build frontend 25 | frontend: check-npm 26 | @echo "Building frontend..." 27 | cd web/ui && npm i && npm run build 28 | 29 | # Check if npm is installed 30 | check-npm: 31 | @command -v npm >/dev/null 2>&1 || { echo >&2 "npm is not installed. Please install npm first."; exit 1; } 32 | 33 | # Generate a swagger.json used for the api documentation 34 | api-doc: 35 | go install github.com/swaggo/swag/cmd/swag@latest 36 | $(GOPATH)/bin/swag i --exclude ./web/ui --output web/docs 37 | $(GOPATH)/bin/swag f 38 | 39 | # Run any tests 40 | test: 41 | @echo "Running tests..." 42 | go test ./... 43 | 44 | # Build for all platforms 45 | build: $(PLATFORMS) 46 | 47 | # Generic build target for platforms 48 | $(PLATFORMS): 49 | $(eval GOOS=$(firstword $(subst /, ,$@))) 50 | $(eval GOARCH=$(lastword $(subst /, ,$@))) 51 | $(CGO) GOOS=$(GOOS) GOARCH=$(GOARCH) go build $(LD_FLAGS) -o '$(BIN_DIR)/gowitness-$(APPVER)-$(GOOS)-$(GOARCH)$(if $(filter windows,$(GOOS)),.exe)' 52 | 53 | # Checksum integrity 54 | integrity: 55 | cd $(BIN_DIR) && shasum * 56 | -------------------------------------------------------------------------------- /web/ui/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | export default { 3 | darkMode: ["class"], 4 | content: [ 5 | './pages/**/*.{ts,tsx}', 6 | './components/**/*.{ts,tsx}', 7 | './app/**/*.{ts,tsx}', 8 | './src/**/*.{ts,tsx}', 9 | ], 10 | theme: { 11 | extend: { 12 | borderRadius: { 13 | lg: 'var(--radius)', 14 | md: 'calc(var(--radius) - 2px)', 15 | sm: 'calc(var(--radius) - 4px)' 16 | }, 17 | colors: { 18 | background: 'hsl(var(--background))', 19 | foreground: 'hsl(var(--foreground))', 20 | card: { 21 | DEFAULT: 'hsl(var(--card))', 22 | foreground: 'hsl(var(--card-foreground))' 23 | }, 24 | popover: { 25 | DEFAULT: 'hsl(var(--popover))', 26 | foreground: 'hsl(var(--popover-foreground))' 27 | }, 28 | primary: { 29 | DEFAULT: 'hsl(var(--primary))', 30 | foreground: 'hsl(var(--primary-foreground))' 31 | }, 32 | secondary: { 33 | DEFAULT: 'hsl(var(--secondary))', 34 | foreground: 'hsl(var(--secondary-foreground))' 35 | }, 36 | muted: { 37 | DEFAULT: 'hsl(var(--muted))', 38 | foreground: 'hsl(var(--muted-foreground))' 39 | }, 40 | accent: { 41 | DEFAULT: 'hsl(var(--accent))', 42 | foreground: 'hsl(var(--accent-foreground))' 43 | }, 44 | destructive: { 45 | DEFAULT: 'hsl(var(--destructive))', 46 | foreground: 'hsl(var(--destructive-foreground))' 47 | }, 48 | border: 'hsl(var(--border))', 49 | input: 'hsl(var(--input))', 50 | ring: 'hsl(var(--ring))', 51 | chart: { 52 | '1': 'hsl(var(--chart-1))', 53 | '2': 'hsl(var(--chart-2))', 54 | '3': 'hsl(var(--chart-3))', 55 | '4': 'hsl(var(--chart-4))', 56 | '5': 'hsl(var(--chart-5))' 57 | } 58 | } 59 | } 60 | }, 61 | plugins: [require("tailwindcss-animate")], 62 | } 63 | 64 | -------------------------------------------------------------------------------- /web/ui/src/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /pkg/log/log.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/charmbracelet/lipgloss" 7 | "github.com/charmbracelet/log" 8 | ) 9 | 10 | // LLogger is a charmbracelet logger type redefinition 11 | type LLogger = log.Logger 12 | 13 | // Logger is this package level logger 14 | var Logger *LLogger 15 | 16 | func init() { 17 | styles := log.DefaultStyles() 18 | styles.Keys["err"] = lipgloss.NewStyle().Foreground(lipgloss.Color("204")) 19 | styles.Values["err"] = lipgloss.NewStyle().Bold(true) 20 | 21 | Logger = log.NewWithOptions(os.Stderr, log.Options{ 22 | ReportTimestamp: true, 23 | }) 24 | Logger.SetStyles(styles) 25 | Logger.SetLevel(log.InfoLevel) 26 | } 27 | 28 | // EnableDebug enabled debug logging and caller reporting 29 | func EnableDebug() { 30 | Logger.SetLevel(log.DebugLevel) 31 | Logger.SetReportCaller(true) 32 | } 33 | 34 | // EnableSilence will silence most logs, except this written with Print 35 | func EnableSilence() { 36 | Logger.SetLevel(log.FatalLevel + 100) 37 | } 38 | 39 | // Debug logs debug messages 40 | func Debug(msg string, keyvals ...interface{}) { 41 | Logger.Helper() 42 | Logger.Debug(msg, keyvals...) 43 | } 44 | 45 | // Info logs info messages 46 | func Info(msg string, keyvals ...interface{}) { 47 | Logger.Helper() 48 | Logger.Info(msg, keyvals...) 49 | } 50 | 51 | // Warn logs warning messages 52 | func Warn(msg string, keyvals ...interface{}) { 53 | Logger.Helper() 54 | Logger.Warn(msg, keyvals...) 55 | } 56 | 57 | // Error logs error messages 58 | func Error(msg string, keyvals ...interface{}) { 59 | Logger.Helper() 60 | Logger.Error(msg, keyvals...) 61 | } 62 | 63 | // Fatal logs fatal messages and panics 64 | func Fatal(msg string, keyvals ...interface{}) { 65 | Logger.Helper() 66 | Logger.Fatal(msg, keyvals...) 67 | } 68 | 69 | // Print logs messages regardless of level 70 | func Print(msg string, keyvals ...interface{}) { 71 | Logger.Helper() 72 | Logger.Print(msg, keyvals...) 73 | } 74 | 75 | // With returns a sublogger with a prefix 76 | func With(keyvals ...interface{}) *LLogger { 77 | return Logger.With(keyvals...) 78 | } 79 | -------------------------------------------------------------------------------- /web/ui/src/components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as TabsPrimitive from "@radix-ui/react-tabs" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const Tabs = TabsPrimitive.Root 7 | 8 | const TabsList = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | )) 21 | TabsList.displayName = TabsPrimitive.List.displayName 22 | 23 | const TabsTrigger = React.forwardRef< 24 | React.ElementRef, 25 | React.ComponentPropsWithoutRef 26 | >(({ className, ...props }, ref) => ( 27 | 35 | )) 36 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName 37 | 38 | const TabsContent = React.forwardRef< 39 | React.ElementRef, 40 | React.ComponentPropsWithoutRef 41 | >(({ className, ...props }, ref) => ( 42 | 50 | )) 51 | TabsContent.displayName = TabsPrimitive.Content.displayName 52 | 53 | export { Tabs, TabsList, TabsTrigger, TabsContent } 54 | -------------------------------------------------------------------------------- /web/ui/src/pages/detail/data.tsx: -------------------------------------------------------------------------------- 1 | import { toast } from "@/hooks/use-toast"; 2 | import * as api from "@/lib/api/api"; 3 | import * as apitypes from "@/lib/api/types"; 4 | import { differenceInMilliseconds, formatDuration, intervalToDuration, } from 'date-fns'; 5 | 6 | const getData = async ( 7 | setLoading: React.Dispatch>, 8 | setDetail: React.Dispatch>, 9 | setWappalyzer: React.Dispatch>, 10 | setDuration: React.Dispatch>, 11 | // args 12 | id: string | number, 13 | ) => { 14 | setLoading(true); 15 | try { 16 | const [detailData, wappalyzerData] = await Promise.all([ 17 | api.get('detail', { id }), 18 | api.get('wappalyzer') 19 | ]); 20 | setDetail(detailData); 21 | setWappalyzer(wappalyzerData); 22 | 23 | // calculate duration 24 | if (detailData.network && detailData.network.length > 0) { 25 | const probedAt = new Date(detailData.probed_at); 26 | const lastNetworkEntry = new Date(detailData.network[detailData.network.length - 1].time); 27 | const durationMs = differenceInMilliseconds(lastNetworkEntry, probedAt); 28 | const durationObj = intervalToDuration({ start: 0, end: durationMs }); 29 | setDuration(formatDuration(durationObj, { format: ['minutes', 'seconds'] })); 30 | } 31 | } catch (err) { 32 | toast({ 33 | title: "API Error", 34 | variant: "destructive", 35 | description: `Failed to get detail: ${err}` 36 | }); 37 | } finally { 38 | setLoading(false); 39 | } 40 | }; 41 | 42 | const deleteResult = async (id: string): Promise => { 43 | try { 44 | await api.post('delete', { id }); 45 | } catch (error) { 46 | toast({ 47 | title: "API Error", 48 | variant: "destructive", 49 | description: `Failed to delete result: ${error}` 50 | }); 51 | 52 | return false; 53 | } 54 | toast({ 55 | description: "Result deleted" 56 | }); 57 | 58 | return true; 59 | }; 60 | 61 | export { getData, deleteResult }; 62 | -------------------------------------------------------------------------------- /web/ui/src/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

41 | )) 42 | CardTitle.displayName = "CardTitle" 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLParagraphElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |

53 | )) 54 | CardDescription.displayName = "CardDescription" 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |

61 | )) 62 | CardContent.displayName = "CardContent" 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )) 74 | CardFooter.displayName = "CardFooter" 75 | 76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 77 | -------------------------------------------------------------------------------- /web/ui/src/pages/submit/action.ts: -------------------------------------------------------------------------------- 1 | import { toast } from "@/hooks/use-toast"; 2 | import * as api from "@/lib/api/api"; 3 | import { redirect } from "react-router-dom"; 4 | 5 | const submitJobAction = async ({ formData }: { formData: FormData; }) => { 6 | 7 | // grab submitted urls 8 | const urls = Array.from(formData.entries()) 9 | .filter(([key]) => key.startsWith('url-')) 10 | .map(([, value]) => value as string) 11 | .filter(url => url.trim() !== ''); 12 | 13 | if (urls.length === 0) { 14 | return { error: "Please enter at least one URL" }; 15 | } 16 | 17 | const options = { 18 | format: formData.get('format'), 19 | timeout: parseInt(formData.get('timeout') as string), 20 | delay: parseInt(formData.get('delay') as string), 21 | user_agent: formData.get('user_agent'), 22 | window_x: parseInt(formData.get('window_x') as string), 23 | window_y: parseInt(formData.get('window_y') as string), 24 | }; 25 | 26 | try { 27 | await api.post('submit', { urls, options }); 28 | } catch (err) { 29 | toast({ 30 | title: "Error", 31 | description: `Could not submit new probe: ${err}`, 32 | variant: "destructive" 33 | }); 34 | return null; 35 | } 36 | 37 | toast({ 38 | title: "Success!", 39 | description: "Probe has been submitted" 40 | }); 41 | 42 | return redirect("/submit"); 43 | }; 44 | 45 | const submitImmediateAction = async ({ formData }: { formData: FormData; }) => { 46 | const url = formData.get('immediate-url') as string; 47 | const options = { 48 | format: formData.get('format'), 49 | timeout: parseInt(formData.get('timeout') as string), 50 | delay: parseInt(formData.get('delay') as string), 51 | user_agent: formData.get('user_agent'), 52 | window_x: parseInt(formData.get('window_x') as string), 53 | window_y: parseInt(formData.get('window_y') as string), 54 | }; 55 | 56 | try { 57 | return await api.post('submitsingle', { url, options }); 58 | } catch (err) { 59 | toast({ 60 | title: "Error", 61 | description: `Could not submit new probe: ${err}`, 62 | variant: "destructive" 63 | }); 64 | return null; 65 | } 66 | }; 67 | 68 | export { submitJobAction, submitImmediateAction }; -------------------------------------------------------------------------------- /web/ui/src/components/ui/accordion.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as AccordionPrimitive from "@radix-ui/react-accordion" 3 | import { ChevronDownIcon } from "@radix-ui/react-icons" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Accordion = AccordionPrimitive.Root 8 | 9 | const AccordionItem = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 18 | )) 19 | AccordionItem.displayName = "AccordionItem" 20 | 21 | const AccordionTrigger = React.forwardRef< 22 | React.ElementRef, 23 | React.ComponentPropsWithoutRef 24 | >(({ className, children, ...props }, ref) => ( 25 | 26 | svg]:rotate-180", 30 | className 31 | )} 32 | {...props} 33 | > 34 | {children} 35 | 36 | 37 | 38 | )) 39 | AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName 40 | 41 | const AccordionContent = React.forwardRef< 42 | React.ElementRef, 43 | React.ComponentPropsWithoutRef 44 | >(({ className, children, ...props }, ref) => ( 45 | 50 |
{children}
51 |
52 | )) 53 | AccordionContent.displayName = AccordionPrimitive.Content.displayName 54 | 55 | export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } 56 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Docker build & Push 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | release: 7 | types: [published] 8 | workflow_dispatch: 9 | 10 | jobs: 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Get version 17 | id: get_version 18 | run: echo ::set-output name=VERSION::${GITHUB_REF/refs\/tags\//} 19 | 20 | - uses: mr-smithers-excellent/docker-build-push@v5 21 | name: Publish latest tag to Github Repo (only on master branch push) 22 | if: github.event_name == 'push' && github.ref == 'refs/heads/master' 23 | with: 24 | image: gowitness 25 | addLatest: true 26 | tags: latest 27 | registry: ghcr.io 28 | username: ${{ github.actor }} 29 | password: ${{ secrets.GITHUB_TOKEN }} 30 | 31 | - uses: mr-smithers-excellent/docker-build-push@v5 32 | name: Publish latest and version tag to Github Repo (only on tag event) 33 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') 34 | with: 35 | image: gowitness 36 | addLatest: true 37 | tags: ${{ steps.get_version.outputs.VERSION }}, latest 38 | registry: ghcr.io 39 | username: ${{ github.actor }} 40 | password: ${{ secrets.GITHUB_TOKEN }} 41 | 42 | - uses: mr-smithers-excellent/docker-build-push@v5 43 | name: Publish latest tag to Docker Repo (only on master branch push) 44 | if: github.event_name == 'push' && github.ref == 'refs/heads/master' 45 | with: 46 | image: leonjza/gowitness 47 | addLatest: true 48 | tags: latest 49 | registry: docker.io 50 | username: ${{ secrets.DOCKER_USERNAME }} 51 | password: ${{ secrets.DOCKER_PASSWORD }} 52 | 53 | - uses: mr-smithers-excellent/docker-build-push@v5 54 | name: Publish latest and version tag to Docker Repo (only on tag event) 55 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') 56 | with: 57 | image: leonjza/gowitness 58 | addLatest: true 59 | tags: ${{ steps.get_version.outputs.VERSION }}, latest 60 | registry: docker.io 61 | username: ${{ secrets.DOCKER_USERNAME }} 62 | password: ${{ secrets.DOCKER_PASSWORD }} 63 | -------------------------------------------------------------------------------- /web/ui/src/main.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import { createBrowserRouter, RouterProvider } from "react-router-dom"; 4 | import '@/index.css'; 5 | 6 | import App from '@/pages/App'; 7 | import ErrorPage from '@/pages/Error'; 8 | 9 | import DashboardPage from '@/pages/dashboard/Dashboard'; 10 | import GalleryPage from '@/pages/gallery/Gallery'; 11 | import TablePage from '@/pages/table/Table'; 12 | import ScreenshotDetailPage from '@/pages/detail/Detail'; 13 | import SearchResultsPage from '@/pages/search/Search'; 14 | import JobSubmissionPage from '@/pages/submit/Submit'; 15 | 16 | import { searchAction } from '@/pages/search/action'; 17 | import { searchLoader } from '@/pages/search/loader'; 18 | import { deleteAction } from '@/pages/detail/actions'; 19 | import { submitImmediateAction, submitJobAction } from '@/pages/submit/action'; 20 | 21 | const router = createBrowserRouter([ 22 | { 23 | path: '/', 24 | element: , 25 | errorElement: , 26 | children: [ 27 | { 28 | path: '/', 29 | element: 30 | }, 31 | { 32 | path: 'gallery', 33 | element: 34 | }, 35 | { 36 | path: 'overview', 37 | element: 38 | }, 39 | { 40 | path: 'screenshot/:id', 41 | element: , 42 | action: deleteAction 43 | }, 44 | { 45 | path: 'search', 46 | element: , 47 | action: searchAction, 48 | loader: searchLoader, 49 | }, 50 | { 51 | path: 'submit', 52 | element: , 53 | action: async ({ request }) => { 54 | const formData = await request.formData(); 55 | const action = formData.get('action'); 56 | 57 | switch (action) { 58 | case 'job': 59 | return submitJobAction({ formData }); 60 | case 'immediate': 61 | return submitImmediateAction({ formData }); 62 | 63 | default: 64 | throw new Error('unknown action for job submit route'); 65 | } 66 | }, 67 | }, 68 | ] 69 | } 70 | ]); 71 | 72 | createRoot(document.getElementById('root')!).render( 73 | 74 | 75 | , 76 | ); 77 | -------------------------------------------------------------------------------- /web/ui/src/pages/Error.tsx: -------------------------------------------------------------------------------- 1 | import { Button } from "@/components/ui/button"; 2 | import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; 3 | import { CircleXIcon, RefreshCcwIcon } from "lucide-react"; 4 | import { isRouteErrorResponse, useNavigate, useRouteError } from "react-router-dom"; 5 | 6 | const ErrorPage = () => { 7 | const error = useRouteError(); 8 | const navigate = useNavigate(); 9 | console.error(error); 10 | 11 | let errorMessage = "An unknown error occurred"; 12 | let errorDetails = null; 13 | 14 | if (isRouteErrorResponse(error)) { 15 | errorMessage = error.statusText || error.data; 16 | errorDetails = error.data; 17 | } else if (error instanceof Error) { 18 | errorMessage = error.message; 19 | errorDetails = error.stack; 20 | } 21 | 22 | return ( 23 |
24 | 25 | 26 | 27 | 28 | Oops! Something went wrong 29 | 30 | 31 | We encountered an error while processing your request 32 | 33 | 34 | 35 |
36 | Error: {errorMessage} 37 |
38 | {errorDetails && ( 39 |
40 | Details: 41 |
42 |                 {errorDetails}
43 |               
44 |
45 | )} 46 |
47 | 50 | 54 |
55 |
56 |
57 |
58 | ); 59 | }; 60 | 61 | export default ErrorPage; -------------------------------------------------------------------------------- /web/ui/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 240 10% 3.9%; 9 | --card: 0 0% 100%; 10 | --card-foreground: 240 10% 3.9%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 240 10% 3.9%; 13 | --primary: 240 5.9% 10%; 14 | --primary-foreground: 0 0% 98%; 15 | --secondary: 240 4.8% 95.9%; 16 | --secondary-foreground: 240 5.9% 10%; 17 | --muted: 240 4.8% 95.9%; 18 | --muted-foreground: 240 3.8% 46.1%; 19 | --accent: 240 4.8% 95.9%; 20 | --accent-foreground: 240 5.9% 10%; 21 | --destructive: 0 84.2% 60.2%; 22 | --destructive-foreground: 0 0% 98%; 23 | --border: 240 5.9% 90%; 24 | --input: 240 5.9% 90%; 25 | --ring: 240 5.9% 10%; 26 | --radius: 0.5rem; 27 | --chart-1: 12 76% 61%; 28 | --chart-2: 173 58% 39%; 29 | --chart-3: 197 37% 24%; 30 | --chart-4: 43 74% 66%; 31 | --chart-5: 27 87% 67%; 32 | } 33 | 34 | .dark { 35 | --background: 240 10% 3.9%; 36 | --foreground: 0 0% 98%; 37 | --card: 240 10% 3.9%; 38 | --card-foreground: 0 0% 98%; 39 | --popover: 240 10% 3.9%; 40 | --popover-foreground: 0 0% 98%; 41 | --primary: 0 0% 98%; 42 | --primary-foreground: 240 5.9% 10%; 43 | --secondary: 240 3.7% 15.9%; 44 | --secondary-foreground: 0 0% 98%; 45 | --muted: 240 3.7% 15.9%; 46 | --muted-foreground: 240 5% 64.9%; 47 | --accent: 240 3.7% 15.9%; 48 | --accent-foreground: 0 0% 98%; 49 | --destructive: 0 62.8% 30.6%; 50 | --destructive-foreground: 0 0% 98%; 51 | --border: 240 3.7% 15.9%; 52 | --input: 240 3.7% 15.9%; 53 | --ring: 240 4.9% 83.9%; 54 | --chart-1: 220 70% 50%; 55 | --chart-2: 160 60% 45%; 56 | --chart-3: 30 80% 55%; 57 | --chart-4: 280 65% 60%; 58 | --chart-5: 340 75% 55%; 59 | } 60 | } 61 | 62 | @layer base { 63 | :root { 64 | --chart-1: 12 76% 61%; 65 | --chart-2: 173 58% 39%; 66 | --chart-3: 197 37% 24%; 67 | --chart-4: 43 74% 66%; 68 | --chart-5: 27 87% 67%; 69 | } 70 | 71 | .dark { 72 | --chart-1: 220 70% 50%; 73 | --chart-2: 160 60% 45%; 74 | --chart-3: 30 80% 55%; 75 | --chart-4: 280 65% 60%; 76 | --chart-5: 340 75% 55%; 77 | } 78 | } 79 | 80 | @layer base { 81 | * { 82 | @apply border-border; 83 | } 84 | 85 | body { 86 | @apply bg-background text-foreground; 87 | } 88 | } -------------------------------------------------------------------------------- /pkg/imagehash/perception.go: -------------------------------------------------------------------------------- 1 | package imagehash 2 | 3 | import ( 4 | "errors" 5 | "image" 6 | "math" 7 | "sync" 8 | 9 | "github.com/corona10/goimagehash" 10 | "github.com/corona10/goimagehash/etcs" 11 | "github.com/corona10/goimagehash/transforms" 12 | ) 13 | 14 | const ( 15 | sampleSize = 64 16 | hashWidth = 8 17 | hashHeight = 8 18 | ) 19 | 20 | var pixelPool = sync.Pool{ 21 | New: func() interface{} { 22 | p := make([]float64, sampleSize*sampleSize) 23 | return &p 24 | }, 25 | } 26 | 27 | // PerceptionHash calculates a perceptual hash for the provided image while avoiding 28 | // the heavy intermediate allocations required by github.com/nfnt/resize as implemented 29 | // in goimagehash. Essentially, this is just a small reimplemenation. 30 | func PerceptionHash(img image.Image) (string, error) { 31 | if img == nil { 32 | return "", errors.New("image is nil") 33 | } 34 | 35 | bounds := img.Bounds() 36 | if bounds.Dx() == 0 || bounds.Dy() == 0 { 37 | return "", errors.New("image has invalid bounds") 38 | } 39 | 40 | pixelsPtr := pixelPool.Get().(*[]float64) 41 | defer pixelPool.Put(pixelsPtr) 42 | 43 | fillDownsampledGrayscale(img, *pixelsPtr) 44 | 45 | transforms.DCT2DFast64(pixelsPtr) 46 | flattens := transforms.FlattenPixelsFast64(*pixelsPtr, hashWidth, hashHeight) 47 | median := etcs.MedianOfPixelsFast64(flattens) 48 | 49 | var hash uint64 50 | for idx, p := range flattens { 51 | if p > median { 52 | shift := uint(len(flattens) - idx - 1) 53 | hash |= 1 << shift 54 | } 55 | } 56 | 57 | return goimagehash.NewImageHash(hash, goimagehash.PHash).ToString(), nil 58 | } 59 | 60 | func fillDownsampledGrayscale(src image.Image, dst []float64) { 61 | bounds := src.Bounds() 62 | srcWidth := bounds.Dx() 63 | srcHeight := bounds.Dy() 64 | 65 | scaleX := float64(srcWidth) / float64(sampleSize) 66 | scaleY := float64(srcHeight) / float64(sampleSize) 67 | 68 | for y := range sampleSize { 69 | sy := bounds.Min.Y + sampleCoordinate(scaleY, srcHeight, y) 70 | for x := range sampleSize { 71 | sx := bounds.Min.X + sampleCoordinate(scaleX, srcWidth, x) 72 | r, g, b, _ := src.At(sx, sy).RGBA() 73 | dst[(y*sampleSize)+x] = toGray(r, g, b) 74 | } 75 | } 76 | } 77 | 78 | func sampleCoordinate(scale float64, max int, pos int) int { 79 | if max <= 1 { 80 | return 0 81 | } 82 | 83 | value := int(math.Floor((float64(pos) + 0.5) * scale)) 84 | if value < 0 { 85 | return 0 86 | } 87 | if value >= max { 88 | return max - 1 89 | } 90 | 91 | return value 92 | } 93 | 94 | func toGray(r, g, b uint32) float64 { 95 | return 0.299*float64(r/257) + 0.587*float64(g/257) + 0.114*float64(b/256) 96 | } 97 | -------------------------------------------------------------------------------- /pkg/writers/db.go: -------------------------------------------------------------------------------- 1 | package writers 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/sensepost/gowitness/internal/islazy" 7 | "github.com/sensepost/gowitness/pkg/database" 8 | "github.com/sensepost/gowitness/pkg/log" 9 | "github.com/sensepost/gowitness/pkg/models" 10 | "gorm.io/gorm" 11 | ) 12 | 13 | var hammingThreshold = 10 14 | 15 | // DbWriter is a Database writer 16 | type DbWriter struct { 17 | URI string 18 | conn *gorm.DB 19 | mutex sync.Mutex 20 | hammingGroups []islazy.HammingGroup 21 | } 22 | 23 | // NewDbWriter initialises a database writer 24 | func NewDbWriter(uri string, debug bool) (*DbWriter, error) { 25 | c, err := database.Connection(uri, false, debug) 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | return &DbWriter{ 31 | URI: uri, 32 | conn: c, 33 | mutex: sync.Mutex{}, 34 | hammingGroups: []islazy.HammingGroup{}, 35 | }, nil 36 | } 37 | 38 | // Write results to the database 39 | func (dw *DbWriter) Write(result *models.Result) error { 40 | dw.mutex.Lock() 41 | defer dw.mutex.Unlock() 42 | 43 | // Assign Group ID based on PerceptionHash 44 | groupID, err := dw.AssignGroupID(result.PerceptionHash) 45 | if err == nil { 46 | result.PerceptionHashGroupId = groupID 47 | } else { 48 | // if we couldn't get a perception hash, thats okay. maybe the 49 | // screenshot failed. 50 | log.Debug("could not get group id for perception hash", "hash", result.PerceptionHash) 51 | } 52 | 53 | return dw.conn.Create(result).Error 54 | } 55 | 56 | // AssignGroupID assigns a PerceptionHashGroupId based on Hamming distance 57 | func (dw *DbWriter) AssignGroupID(perceptionHashStr string) (uint, error) { 58 | // Parse the incoming perception hash 59 | parsedHash, err := islazy.ParsePerceptionHash(perceptionHashStr) 60 | if err != nil { 61 | return 0, err 62 | } 63 | 64 | // Iterate through existing groups to find a match 65 | for _, group := range dw.hammingGroups { 66 | dist, err := islazy.HammingDistance(parsedHash, group.Hash) 67 | if err != nil { 68 | return 0, err 69 | } 70 | 71 | if dist <= hammingThreshold { 72 | return group.GroupID, nil 73 | } 74 | } 75 | 76 | // No matching group found; create a new group 77 | var maxGroupID uint 78 | err = dw.conn.Model(&models.Result{}). 79 | Select("COALESCE(MAX(perception_hash_group_id), 0)"). 80 | Scan(&maxGroupID).Error 81 | if err != nil { 82 | return 0, err 83 | } 84 | nextGroupID := maxGroupID + 1 85 | 86 | // Add the new group to in-memory cache 87 | newGroup := islazy.HammingGroup{ 88 | GroupID: nextGroupID, 89 | Hash: parsedHash, 90 | } 91 | dw.hammingGroups = append(dw.hammingGroups, newGroup) 92 | 93 | return nextGroupID, nil 94 | } 95 | -------------------------------------------------------------------------------- /pkg/writers/csv.go: -------------------------------------------------------------------------------- 1 | package writers 2 | 3 | import ( 4 | "encoding/csv" 5 | "fmt" 6 | "os" 7 | "reflect" 8 | 9 | "github.com/sensepost/gowitness/internal/islazy" 10 | "github.com/sensepost/gowitness/pkg/models" 11 | ) 12 | 13 | // fields in the main model to ignore 14 | var csvExludedFields = []string{"HTML"} 15 | 16 | // CsvWriter writes CSV files 17 | type CsvWriter struct { 18 | FilePath string 19 | finalPath string 20 | } 21 | 22 | // NewCsvWriter gets a new CsvWriter 23 | func NewCsvWriter(destination string) (*CsvWriter, error) { 24 | p, err := islazy.CreateFileWithDir(destination) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | // open the file and write the CSV headers to it 30 | file, err := os.OpenFile(p, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | writer := csv.NewWriter(file) 36 | defer writer.Flush() 37 | 38 | if err := writer.Write(csvHeaders()); err != nil { 39 | return nil, err 40 | } 41 | 42 | return &CsvWriter{ 43 | FilePath: destination, 44 | finalPath: p, 45 | }, nil 46 | } 47 | 48 | // Write a CSV line 49 | func (cw *CsvWriter) Write(result *models.Result) error { 50 | file, err := os.OpenFile(cw.finalPath, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644) 51 | if err != nil { 52 | return err 53 | } 54 | defer file.Close() 55 | 56 | writer := csv.NewWriter(file) 57 | defer writer.Flush() 58 | 59 | // get values from the result 60 | val := reflect.ValueOf(*result) 61 | numField := val.NumField() 62 | 63 | var values []string 64 | for i := 0; i < numField; i++ { 65 | // skip excluded fields 66 | if islazy.SliceHasStr(csvExludedFields, val.Type().Field(i).Name) { 67 | continue 68 | } 69 | 70 | // skip slices 71 | if val.Field(i).Kind() == reflect.Slice { 72 | continue // Optionally skip slice fields, or handle them differently 73 | } 74 | 75 | values = append(values, fmt.Sprintf("%v", val.Field(i).Interface())) 76 | } 77 | 78 | return writer.Write(values) 79 | } 80 | 81 | // headers returns the headers a CSV file should have. 82 | func csvHeaders() []string { 83 | val := reflect.ValueOf(models.Result{}) 84 | numField := val.NumField() 85 | 86 | var fieldNames []string 87 | for i := 0; i < numField; i++ { 88 | // skip excluded fields 89 | if islazy.SliceHasStr(csvExludedFields, val.Type().Field(i).Name) { 90 | continue 91 | } 92 | 93 | // skip slices 94 | if val.Field(i).Kind() == reflect.Slice { 95 | continue // Optionally skip slice fields, or handle them differently 96 | } 97 | 98 | fieldNames = append(fieldNames, val.Type().Field(i).Name) 99 | } 100 | 101 | return fieldNames 102 | } 103 | -------------------------------------------------------------------------------- /internal/islazy/fs.go: -------------------------------------------------------------------------------- 1 | package islazy 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | "unicode" 10 | ) 11 | 12 | // CreateDir creates a directory if it does not exist, returning the final 13 | // normalized directory as a result. 14 | func CreateDir(dir string) (string, error) { 15 | var err error 16 | 17 | if strings.HasPrefix(dir, "~") { 18 | homeDir, err := os.UserHomeDir() 19 | if err != nil { 20 | return "", err 21 | } 22 | dir = filepath.Join(homeDir, dir[1:]) 23 | } 24 | 25 | dir, err = filepath.Abs(dir) 26 | if err != nil { 27 | return "", err 28 | } 29 | 30 | if err := os.MkdirAll(dir, 0755); err != nil { 31 | return "", err 32 | } 33 | 34 | return dir, nil 35 | } 36 | 37 | // CreateFileWithDir creates a file, relative to a directory, returning the 38 | // final normalized path as a result. 39 | func CreateFileWithDir(destination string) (string, error) { 40 | dir := filepath.Dir(destination) 41 | file := filepath.Base(destination) 42 | 43 | if file == "." || file == "/" { 44 | return "", fmt.Errorf("destination does not appear to be a valid file path: %s", destination) 45 | } 46 | 47 | absDir, err := CreateDir(dir) 48 | if err != nil { 49 | return "", err 50 | } 51 | 52 | absPath := filepath.Join(absDir, file) 53 | fileHandle, err := os.Create(absPath) 54 | if err != nil { 55 | return "", err 56 | } 57 | defer fileHandle.Close() 58 | 59 | return absPath, nil 60 | } 61 | 62 | // SafeFileName takes a string and returns a string safe to use as 63 | // a file name. 64 | func SafeFileName(s string) string { 65 | var builder strings.Builder 66 | 67 | for _, r := range s { 68 | if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '.' { 69 | builder.WriteRune(r) 70 | } else { 71 | builder.WriteRune('-') 72 | } 73 | } 74 | 75 | return builder.String() 76 | } 77 | 78 | // FileExists returns true if a path exists 79 | func FileExists(path string) bool { 80 | _, err := os.Stat(path) 81 | 82 | return !os.IsNotExist(err) 83 | } 84 | 85 | // MoveFile moves a file from a to b 86 | func MoveFile(sourcePath, destPath string) error { 87 | if err := os.Rename(sourcePath, destPath); err == nil { 88 | return nil 89 | } 90 | 91 | sourceFile, err := os.Open(sourcePath) 92 | if err != nil { 93 | return err 94 | } 95 | defer sourceFile.Close() 96 | 97 | destFile, err := os.Create(destPath) 98 | if err != nil { 99 | return err 100 | } 101 | defer destFile.Close() 102 | 103 | _, err = io.Copy(destFile, sourceFile) 104 | if err != nil { 105 | return err 106 | } 107 | 108 | err = os.Remove(sourcePath) 109 | if err != nil { 110 | return err 111 | } 112 | 113 | return nil 114 | } 115 | -------------------------------------------------------------------------------- /web/ui/src/pages/search/Search.tsx: -------------------------------------------------------------------------------- 1 | import { Link, useLoaderData, useNavigation } from "react-router-dom"; 2 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 3 | import { Badge } from "@/components/ui/badge"; 4 | import * as api from "@/lib/api/api"; 5 | import * as apitypes from "@/lib/api/types"; 6 | import { WideSkeleton } from "@/components/loading"; 7 | import { getStatusColor } from "@/lib/common"; 8 | 9 | 10 | export default function SearchResultsPage() { 11 | const data = useLoaderData() as apitypes.searchresult[] | undefined; 12 | const navigation = useNavigation(); 13 | 14 | if (!data || data.length === 0) { 15 | return
No results found.
; 16 | } 17 | 18 | if (navigation.state === 'loading') return ; 19 | 20 | return ( 21 |
22 |

23 | Search Results ({data.length}) 24 |

25 |
26 | {data.map((result) => ( 27 | 28 | 29 | 30 | {result.url} 39 | 42 | {result.response_code} 43 | 44 | 45 | 46 | {result.title} 47 |

{result.final_url}

48 |
49 |
50 |

Matched Fields:

51 | {result.matched_fields.map((field) => ( 52 | 53 | {field} 54 | 55 | ))} 56 |
57 |
58 |
59 |
60 | 61 | ))} 62 |
63 |
64 | ); 65 | } -------------------------------------------------------------------------------- /pkg/readers/file_test.go: -------------------------------------------------------------------------------- 1 | package readers 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | ) 7 | 8 | func TestUrlsFor(t *testing.T) { 9 | fr := FileReader{ 10 | Options: &FileReaderOptions{}, 11 | } 12 | 13 | tests := []struct { 14 | name string 15 | candidate string 16 | ports []int 17 | want []string 18 | }{ 19 | { 20 | name: "Test with IP", 21 | candidate: "192.168.1.1", 22 | ports: []int{80, 443, 8443}, 23 | want: []string{ 24 | "http://192.168.1.1:80", 25 | "http://192.168.1.1:443", 26 | "http://192.168.1.1:8443", 27 | "https://192.168.1.1:80", 28 | "https://192.168.1.1:443", 29 | "https://192.168.1.1:8443", 30 | }, 31 | }, 32 | { 33 | name: "Test with IP and port", 34 | candidate: "192.168.1.1:8080", 35 | ports: []int{80, 443, 8443}, 36 | want: []string{ 37 | "http://192.168.1.1:8080", 38 | "https://192.168.1.1:8080", 39 | }, 40 | }, 41 | { 42 | name: "Test with IP and port with spaces", 43 | candidate: " 192.168.1.1:8080 ", 44 | ports: []int{80, 443, 8443}, 45 | want: []string{ 46 | "http://192.168.1.1:8080", 47 | "https://192.168.1.1:8080", 48 | }, 49 | }, 50 | { 51 | name: "Test with scheme, IP and port", 52 | candidate: "http://192.168.1.1:8080", 53 | ports: []int{80, 443, 8443}, 54 | want: []string{ 55 | "http://192.168.1.1:8080", 56 | }, 57 | }, 58 | { 59 | name: "Test with scheme and IP", 60 | candidate: "https://192.168.1.1", 61 | ports: []int{80, 443, 8443}, 62 | want: []string{ 63 | "https://192.168.1.1:80", 64 | "https://192.168.1.1:443", 65 | "https://192.168.1.1:8443", 66 | }, 67 | }, 68 | { 69 | name: "Test with IP and path", 70 | candidate: "192.168.1.1/path", 71 | ports: []int{80, 443, 8443}, 72 | want: []string{ 73 | "http://192.168.1.1:80/path", 74 | "http://192.168.1.1:443/path", 75 | "http://192.168.1.1:8443/path", 76 | "https://192.168.1.1:80/path", 77 | "https://192.168.1.1:443/path", 78 | "https://192.168.1.1:8443/path", 79 | }, 80 | }, 81 | { 82 | name: "Test with scheme, IP, port and path", 83 | candidate: "http://192.168.1.1:8080/path", 84 | ports: []int{80, 443, 8443}, 85 | want: []string{ 86 | "http://192.168.1.1:8080/path", 87 | }, 88 | }, 89 | { 90 | name: "Test with IP, port and path", 91 | candidate: "192.168.1.1:8080/path", 92 | ports: []int{80, 443, 8443}, 93 | want: []string{ 94 | "http://192.168.1.1:8080/path", 95 | "https://192.168.1.1:8080/path", 96 | }, 97 | }, 98 | } 99 | 100 | for _, tt := range tests { 101 | t.Run(tt.name, func(t *testing.T) { 102 | got := fr.urlsFor(tt.candidate, tt.ports) 103 | if !reflect.DeepEqual(got, tt.want) { 104 | t.Errorf("urlsFor() =>\n\nhave: %v\nwant %v", got, tt.want) 105 | } 106 | }) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /web/server.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "net/http" 5 | "strconv" 6 | 7 | "github.com/sensepost/gowitness/web/docs" 8 | httpSwagger "github.com/swaggo/http-swagger" 9 | 10 | "github.com/go-chi/chi/v5" 11 | "github.com/go-chi/chi/v5/middleware" 12 | "github.com/go-chi/cors" 13 | "github.com/sensepost/gowitness/pkg/log" 14 | "github.com/sensepost/gowitness/web/api" 15 | ) 16 | 17 | // Server is a web server 18 | type Server struct { 19 | Host string 20 | Port int 21 | DbUri string 22 | ScreenshotPath string 23 | } 24 | 25 | // NewServer returns a new server intance 26 | func NewServer(host string, port int, dburi string, screenshotpath string) *Server { 27 | return &Server{ 28 | Host: host, 29 | Port: port, 30 | DbUri: dburi, 31 | ScreenshotPath: screenshotpath, 32 | } 33 | } 34 | 35 | // isJSON sets the Content-Type header to application/json 36 | func isJSON(next http.Handler) http.Handler { 37 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 38 | w.Header().Set("Content-Type", "application/json") 39 | next.ServeHTTP(w, r) 40 | }) 41 | } 42 | 43 | // Run a server 44 | func (s *Server) Run() { 45 | 46 | // configure our swagger docs 47 | docs.SwaggerInfo.Title = "gowitness v3 api" 48 | docs.SwaggerInfo.Description = "API documentation for gowitness v3" 49 | docs.SwaggerInfo.Version = "1.0" 50 | docs.SwaggerInfo.BasePath = "/api" 51 | 52 | // get the router ready 53 | r := chi.NewRouter() 54 | 55 | r.Use(middleware.Logger) 56 | r.Use(middleware.CleanPath) 57 | r.Use(middleware.RealIP) 58 | r.Use(middleware.Recoverer) 59 | 60 | apih, err := api.NewApiHandler(s.DbUri, s.ScreenshotPath) 61 | if err != nil { 62 | log.Error("could not get api handler up", "err", err) 63 | return 64 | } 65 | 66 | r.Route("/api", func(r chi.Router) { 67 | r.Use(isJSON) 68 | r.Use(cors.Handler(cors.Options{ 69 | AllowedOrigins: []string{"*"}, // TODO: flag this 70 | })) 71 | 72 | r.Get("/ping", apih.PingHandler) 73 | r.Get("/statistics", apih.StatisticsHandler) 74 | r.Get("/wappalyzer", apih.WappalyzerHandler) 75 | r.Post("/search", apih.SearchHandler) 76 | r.Post("/submit", apih.SubmitHandler) 77 | r.Post("/submit/single", apih.SubmitSingleHandler) 78 | 79 | r.Get("/results/gallery", apih.GalleryHandler) 80 | r.Get("/results/list", apih.ListHandler) 81 | r.Get("/results/detail/{id}", apih.DetailHandler) 82 | r.Post("/results/delete", apih.DeleteResultHandler) 83 | r.Get("/results/technology", apih.TechnologyListHandler) 84 | }) 85 | 86 | // screenshot files 87 | r.Mount("/screenshots", http.StripPrefix("/screenshots/", http.FileServer(http.Dir(s.ScreenshotPath)))) 88 | 89 | // swagger documentation 90 | r.Get("/swagger/*", httpSwagger.Handler(httpSwagger.URL("/swagger/doc.json"))) 91 | 92 | // the spa 93 | r.Handle("/*", SpaHandler()) 94 | 95 | log.Info("starting web server", "host", s.Host, "port", s.Port) 96 | if err := http.ListenAndServe(s.Host+":"+strconv.Itoa(s.Port), r); err != nil { 97 | log.Error("server listen error", "err", err) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /pkg/readers/cidr.go: -------------------------------------------------------------------------------- 1 | package readers 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os" 7 | "strings" 8 | 9 | "github.com/sensepost/gowitness/internal/islazy" 10 | "github.com/sensepost/gowitness/pkg/log" 11 | ) 12 | 13 | type CidrReader struct { 14 | Options *CidrReaderOptions 15 | } 16 | 17 | type CidrReaderOptions struct { 18 | NoHTTP bool 19 | NoHTTPS bool 20 | Cidrs []string 21 | Source string 22 | Ports []int 23 | PortsSmall bool 24 | PortsMedium bool 25 | PortsLarge bool 26 | Random bool 27 | } 28 | 29 | func NewCidrReader(opts *CidrReaderOptions) *CidrReader { 30 | return &CidrReader{ 31 | Options: opts, 32 | } 33 | } 34 | 35 | func (cr *CidrReader) Read(ch chan<- string) error { 36 | defer close(ch) 37 | 38 | candidates, err := cr.candidates() 39 | if err != nil { 40 | return err 41 | } 42 | 43 | log.Debug("total candidates to scan", "total", len(candidates)) 44 | 45 | for _, target := range candidates { 46 | ch <- target 47 | } 48 | 49 | return nil 50 | } 51 | 52 | // candidates creates url candidates from ports and ips 53 | func (cr *CidrReader) candidates() ([]string, error) { 54 | var candidates []string 55 | 56 | ports := cr.ports() 57 | ips, err := cr.ips() 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | for _, ip := range ips { 63 | for _, port := range ports { 64 | partial := fmt.Sprintf("%s:%d", ip, port) 65 | 66 | if !cr.Options.NoHTTP { 67 | candidates = append(candidates, fmt.Sprintf("http://%s", partial)) 68 | } 69 | 70 | if !cr.Options.NoHTTPS { 71 | candidates = append(candidates, fmt.Sprintf("https://%s", partial)) 72 | } 73 | } 74 | } 75 | 76 | if cr.Options.Random { 77 | islazy.ShuffleStr(candidates) 78 | } 79 | 80 | return candidates, nil 81 | } 82 | 83 | // ports returns all of the ports to scan 84 | func (cr *CidrReader) ports() []int { 85 | var ports = cr.Options.Ports 86 | 87 | if cr.Options.PortsSmall { 88 | ports = append(ports, small...) 89 | } 90 | 91 | if cr.Options.PortsMedium { 92 | ports = append(ports, medium...) 93 | } 94 | 95 | if cr.Options.PortsLarge { 96 | ports = append(ports, large...) 97 | } 98 | 99 | return islazy.UniqueIntSlice(ports) 100 | } 101 | 102 | // ips gets ips from a file and cidr agruments 103 | func (cr *CidrReader) ips() ([]string, error) { 104 | var cidrs = cr.Options.Cidrs 105 | var ips []string 106 | 107 | // Slurp a file if we have one 108 | if cr.Options.Source != "" { 109 | var file *os.File 110 | var err error 111 | if cr.Options.Source == "-" { 112 | file = os.Stdin 113 | } else { 114 | file, err = os.Open(cr.Options.Source) 115 | if err != nil { 116 | return nil, err 117 | } 118 | defer file.Close() 119 | } 120 | 121 | scanner := bufio.NewScanner(file) 122 | for scanner.Scan() { 123 | cidrs = append(cidrs, strings.TrimSpace(scanner.Text())) 124 | } 125 | } 126 | 127 | // populate ips from the collected cidrs to return 128 | for _, cidr := range cidrs { 129 | if !strings.Contains(cidr, "/") { 130 | cidr += "/32" 131 | } 132 | 133 | ip, err := islazy.IpsInCIDR(cidr) 134 | if err != nil { 135 | return nil, err 136 | } 137 | 138 | ips = append(ips, ip...) 139 | } 140 | 141 | return ips, nil 142 | } 143 | -------------------------------------------------------------------------------- /web/api/submit_single.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "log/slog" 6 | "net/http" 7 | 8 | "github.com/sensepost/gowitness/pkg/log" 9 | "github.com/sensepost/gowitness/pkg/runner" 10 | driver "github.com/sensepost/gowitness/pkg/runner/drivers" 11 | "github.com/sensepost/gowitness/pkg/writers" 12 | ) 13 | 14 | type submitSingleRequest struct { 15 | URL string `json:"url"` 16 | Options *submitRequestOptions `json:"options"` 17 | } 18 | 19 | // SubmitSingleHandler submits a URL to scan, returning the result. 20 | // 21 | // @Summary Submit a single URL for probing 22 | // @Description Starts a new probing routine for a URL and options, returning the results when done. 23 | // @Tags Results 24 | // @Accept json 25 | // @Produce json 26 | // @Param query body submitSingleRequest true "The URL scanning request object" 27 | // @Success 200 {object} models.Result "The URL Result object" 28 | // @Router /submit/single [post] 29 | func (h *ApiHandler) SubmitSingleHandler(w http.ResponseWriter, r *http.Request) { 30 | var request submitSingleRequest 31 | if err := json.NewDecoder(r.Body).Decode(&request); err != nil { 32 | log.Error("failed to read json request", "err", err) 33 | http.Error(w, "Error reading JSON request", http.StatusInternalServerError) 34 | return 35 | } 36 | 37 | if request.URL == "" { 38 | http.Error(w, "No URL provided", http.StatusBadRequest) 39 | return 40 | } 41 | 42 | options := runner.NewDefaultOptions() 43 | options.Scan.ScreenshotToWriter = true 44 | options.Scan.ScreenshotSkipSave = true 45 | 46 | // Override default values with request options 47 | if request.Options != nil { 48 | if request.Options.X != 0 { 49 | options.Chrome.WindowX = request.Options.X 50 | } 51 | if request.Options.Y != 0 { 52 | options.Chrome.WindowY = request.Options.Y 53 | } 54 | if request.Options.UserAgent != "" { 55 | options.Chrome.UserAgent = request.Options.UserAgent 56 | } 57 | if request.Options.Timeout != 0 { 58 | options.Scan.Timeout = request.Options.Timeout 59 | } 60 | if request.Options.Delay != 0 { 61 | options.Scan.Delay = request.Options.Delay 62 | } 63 | if request.Options.Format != "" { 64 | options.Scan.ScreenshotFormat = request.Options.Format 65 | } 66 | } 67 | 68 | writer, err := writers.NewMemoryWriter(1) 69 | if err != nil { 70 | http.Error(w, "Error getting a memory writer", http.StatusInternalServerError) 71 | return 72 | } 73 | 74 | logger := slog.New(log.Logger) 75 | 76 | driver, err := driver.NewChromedp(logger, *options) 77 | if err != nil { 78 | http.Error(w, "Error sarting driver", http.StatusInternalServerError) 79 | return 80 | } 81 | 82 | runner, err := runner.NewRunner(logger, driver, *options, []writers.Writer{writer}) 83 | if err != nil { 84 | log.Error("error starting runner", "err", err) 85 | http.Error(w, "Error starting runner", http.StatusInternalServerError) 86 | return 87 | } 88 | 89 | go func() { 90 | runner.Targets <- request.URL 91 | close(runner.Targets) 92 | }() 93 | 94 | runner.Run() 95 | runner.Close() 96 | 97 | jsonData, err := json.Marshal(writer.GetLatest()) 98 | if err != nil { 99 | http.Error(w, "Error creating JSON response", http.StatusInternalServerError) 100 | return 101 | } 102 | 103 | w.Write(jsonData) 104 | } 105 | -------------------------------------------------------------------------------- /cmd/scan_file.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/sensepost/gowitness/internal/ascii" 7 | "github.com/sensepost/gowitness/internal/islazy" 8 | "github.com/sensepost/gowitness/pkg/log" 9 | "github.com/sensepost/gowitness/pkg/readers" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | var fileCmdOptions = &readers.FileReaderOptions{} 14 | var fileCmd = &cobra.Command{ 15 | Use: "file", 16 | Short: "Scan targets sourced from a file or stdin", 17 | Long: ascii.LogoHelp(ascii.Markdown(` 18 | # scan file 19 | 20 | Scan targets sourced from a file or stdin. 21 | 22 | ## description 23 | 24 | This command will check the structure of a target URL to ensure that a protocol 25 | is defined. If it is not set, it will prepend 'http://' and 'https://'. You can 26 | disable either using the --no-http / --no-https flags. 27 | 28 | URLs in the source file should be newline-separated. Invalid URLs are simply 29 | ignored. 30 | 31 | If any ports are added (via --port or one of the ports collections), then URL 32 | candidates will also be generated with the port section specified. 33 | 34 | **Note**: By default, no metadata is saved except for screenshots that are 35 | stored in the configured --screenshot-path. For later parsing (i.e., using the 36 | gowitness reporting feature), you need to specify where to write results (db, 37 | csv, jsonl) using the _--write-*_ set of flags. See _--help_ for available 38 | flags.`)), 39 | Example: ascii.Markdown(` 40 | - gowitness scan file -f ~/Desktop/targets.txt --write-jsonl 41 | - gowitness scan file -f targets.txt --threads 50 --write-db 42 | - cat urls.txt | gowitness scan file -f - --write-csv 43 | - gowitness scan file -f <( shuf domains.txt ) --no-http 44 | `), 45 | PreRunE: func(cmd *cobra.Command, args []string) error { 46 | if fileCmdOptions.Source == "" { 47 | return errors.New("a source must be specified") 48 | } 49 | 50 | if fileCmdOptions.Source != "-" && !islazy.FileExists(fileCmdOptions.Source) { 51 | return errors.New("source is not readable") 52 | } 53 | 54 | return nil 55 | }, 56 | Run: func(cmd *cobra.Command, args []string) { 57 | log.Debug("starting file scanning", "file", fileCmdOptions.Source) 58 | 59 | reader := readers.NewFileReader(fileCmdOptions) 60 | go func() { 61 | if err := reader.Read(scanRunner.Targets); err != nil { 62 | log.Error("error in reader.Read", "err", err) 63 | return 64 | } 65 | }() 66 | 67 | scanRunner.Run() 68 | scanRunner.Close() 69 | }, 70 | } 71 | 72 | func init() { 73 | scanCmd.AddCommand(fileCmd) 74 | 75 | fileCmd.Flags().StringVarP(&fileCmdOptions.Source, "file", "f", "", "A file with targets to scan. Use - for stdin") 76 | fileCmd.Flags().BoolVar(&fileCmdOptions.NoHTTP, "no-http", false, "Do not add 'http://' to targets where missing") 77 | fileCmd.Flags().BoolVar(&fileCmdOptions.NoHTTPS, "no-https", false, "Do not add 'https://' to targets where missing") 78 | fileCmd.Flags().IntSliceVarP(&fileCmdOptions.Ports, "port", "p", []int{80, 443}, "Ports on targets to scan. Supports multiple --port flags") 79 | fileCmd.Flags().BoolVar(&fileCmdOptions.PortsSmall, "ports-small", false, "Include a small ports list when scanning targets") 80 | fileCmd.Flags().BoolVar(&fileCmdOptions.PortsMedium, "ports-medium", false, "Include a medium ports list when scanning targets") 81 | fileCmd.Flags().BoolVar(&fileCmdOptions.PortsLarge, "ports-large", false, "Include a large ports list when scanning targets") 82 | } 83 | -------------------------------------------------------------------------------- /web/api/statistics.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/sensepost/gowitness/pkg/log" 8 | "github.com/sensepost/gowitness/pkg/models" 9 | ) 10 | 11 | type statisticsResponse struct { 12 | DbSize int64 `json:"dbsize"` 13 | Results int64 `json:"results"` 14 | Headers int64 `json:"headers"` 15 | NetworkLogs int64 `json:"networklogs"` 16 | ConsoleLogs int64 `json:"consolelogs"` 17 | ResponseCodes []*statisticsResponseCode `json:"response_code_stats"` 18 | } 19 | 20 | type statisticsResponseCode struct { 21 | Code int `json:"code"` 22 | Count int64 `json:"count"` 23 | } 24 | 25 | // StatisticsHandler returns database statistics 26 | // 27 | // @Summary Database statistics 28 | // @Description Get database statistics. 29 | // @Tags Results 30 | // @Accept json 31 | // @Produce json 32 | // @Success 200 {object} statisticsResponse 33 | // @Router /statistics [get] 34 | func (h *ApiHandler) StatisticsHandler(w http.ResponseWriter, r *http.Request) { 35 | response := &statisticsResponse{} 36 | 37 | var dbSizeQuery string 38 | switch { 39 | case len(h.DbURI) >= 9 && h.DbURI[:9] == "sqlite://": 40 | dbSizeQuery = "SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size()" 41 | case len(h.DbURI) >= 8 && h.DbURI[:8] == "mysql://": 42 | dbSizeQuery = "SELECT SUM(data_length + index_length) AS size FROM information_schema.tables WHERE table_schema = DATABASE()" 43 | case len(h.DbURI) >= 11 && h.DbURI[:11] == "postgres://": 44 | dbSizeQuery = "SELECT SUM(pg_total_relation_size(quote_ident(schemaname) || '.' || quote_ident(tablename))) AS size FROM pg_tables WHERE schemaname = 'public'" 45 | default: 46 | dbSizeQuery = "" 47 | } 48 | 49 | if dbSizeQuery != "" { 50 | if err := h.DB.Raw(dbSizeQuery).Take(&response.DbSize).Error; err != nil { 51 | log.Error("an error occured getting database size", "err", err) 52 | response.DbSize = -1 53 | } 54 | } else { 55 | log.Error("unsupported database type for statistics", "dburi", h.DbURI) 56 | response.DbSize = -1 57 | } 58 | 59 | if err := h.DB.Model(&models.Result{}).Count(&response.Results).Error; err != nil { 60 | log.Error("an error occured counting results", "err", err) 61 | return 62 | } 63 | 64 | if err := h.DB.Model(&models.Header{}).Count(&response.Headers).Error; err != nil { 65 | log.Error("an error occured counting headers", "err", err) 66 | return 67 | } 68 | 69 | if err := h.DB.Model(&models.NetworkLog{}).Count(&response.NetworkLogs).Error; err != nil { 70 | log.Error("an error occured counting network logs", "err", err) 71 | return 72 | } 73 | 74 | if err := h.DB.Model(&models.ConsoleLog{}).Count(&response.ConsoleLogs).Error; err != nil { 75 | log.Error("an error occured counting console logs", "err", err) 76 | return 77 | } 78 | 79 | var counts []*statisticsResponseCode 80 | if err := h.DB.Model(&models.Result{}). 81 | Select("response_code as code, count(*) as count"). 82 | Group("response_code").Scan(&counts).Error; err != nil { 83 | log.Error("failed counting response codes", "err", err) 84 | return 85 | } 86 | 87 | response.ResponseCodes = counts 88 | 89 | jsonData, err := json.Marshal(response) 90 | if err != nil { 91 | http.Error(w, err.Error(), http.StatusInternalServerError) 92 | return 93 | } 94 | 95 | w.Write(jsonData) 96 | } 97 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |
3 | gowitness 4 |
5 |
6 |

7 | 8 |

A golang, web screenshot utility using Chrome Headless.

9 |

10 | @leonjza 11 | Go Report Card 12 | Docker build & Push 13 |

14 |
15 | 16 | ## introduction 17 | 18 | `gowitness` is a website screenshot utility written in Golang, that uses Chrome Headless to generate screenshots of web interfaces using the command line, with a handy report viewer to process results. Both Linux and macOS is supported, with Windows support mostly working. 19 | 20 | ## features 21 | 22 | The main goal of `gowitness` is to take website screenshots (**and do that well!**), while optionally saving any information it gathered along the way. That said, a short list of features include: 23 | 24 | - Take website screenshots, obviously..., but fast and accurate! 25 | - Scan a list of URLs, CIDRs, Nmap Results, Nessus Results and more. 26 | - Ability to grab and save data (i.e., a request log, console logs, headers, cookies, etc.) 27 | - Write data to many formats (sqlite database, jsonlines, csv, etc.) 28 | - An epic web-based results viewer (if you saved data to SQLite) including a fully featured API! 29 | - And many, many more! 30 | 31 | ## quick start 32 | 33 | There are a few ways to get gowitness, the simplest (assuming your `$GOBIN` path is in your shell `$PATH`) will be to use `go install`: 34 | 35 | ```text 36 | go install github.com/sensepost/gowitness@latest 37 | ``` 38 | 39 | Otherwise, grab a platform specific release binary or compile from source. Then, scan your first target writing the results to a SQLite database and the screenshot to `./screenshots` with: 40 | 41 | ```text 42 | gowitness scan single --url "https://sensepost.com" --write-db 43 | ``` 44 | 45 | There are many, *many* flags and scan types in `gowitness`. Just add `-h` anywhere and read all about it! 46 | 47 | ## documentation 48 | 49 | For advanced installation information and other documentation, please refer to the wiki [here](https://github.com/sensepost/gowitness/wiki). 50 | 51 | ## screenshots 52 | 53 | ![gallery](images/gowitness-gallery.png) 54 | 55 | ![detail](images/gowitness-detail.png) 56 | 57 | ![terminal](images/gowitness-terminal.png) 58 | 59 | ## credits 60 | 61 | `gowitness` would not have been possible without some of [these amazing projects](./go.mod): [chi](https://github.com/go-chi/chi), [chromedp](https://github.com/chromedp/chromedp), [go-rod](https://github.com/go-rod/rod), [cobra](https://github.com/spf13/cobra), [gorm](https://github.com/go-gorm/gorm), [glamour](https://github.com/charmbracelet/glamour), [go-nmap](https://github.com/lair-framework/go-nmap), [wappalyzergo](https://github.com/projectdiscovery/wappalyzergo), [goimagehash](https://github.com/corona10/goimagehash). 62 | 63 | ## license 64 | 65 | `gowitness` is licensed under a [GNU General Public v3 License](https://www.gnu.org/licenses/gpl-3.0.en.html). Permissions beyond the scope of this license may be available at . 66 | -------------------------------------------------------------------------------- /web/ui/src/components/ui/table.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Table = React.forwardRef< 6 | HTMLTableElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
10 | 15 | 16 | )) 17 | Table.displayName = "Table" 18 | 19 | const TableHeader = React.forwardRef< 20 | HTMLTableSectionElement, 21 | React.HTMLAttributes 22 | >(({ className, ...props }, ref) => ( 23 | 24 | )) 25 | TableHeader.displayName = "TableHeader" 26 | 27 | const TableBody = React.forwardRef< 28 | HTMLTableSectionElement, 29 | React.HTMLAttributes 30 | >(({ className, ...props }, ref) => ( 31 | 36 | )) 37 | TableBody.displayName = "TableBody" 38 | 39 | const TableFooter = React.forwardRef< 40 | HTMLTableSectionElement, 41 | React.HTMLAttributes 42 | >(({ className, ...props }, ref) => ( 43 | tr]:last:border-b-0", 47 | className 48 | )} 49 | {...props} 50 | /> 51 | )) 52 | TableFooter.displayName = "TableFooter" 53 | 54 | const TableRow = React.forwardRef< 55 | HTMLTableRowElement, 56 | React.HTMLAttributes 57 | >(({ className, ...props }, ref) => ( 58 | 66 | )) 67 | TableRow.displayName = "TableRow" 68 | 69 | const TableHead = React.forwardRef< 70 | HTMLTableCellElement, 71 | React.ThHTMLAttributes 72 | >(({ className, ...props }, ref) => ( 73 |
[role=checkbox]]:translate-y-[2px]", 77 | className 78 | )} 79 | {...props} 80 | /> 81 | )) 82 | TableHead.displayName = "TableHead" 83 | 84 | const TableCell = React.forwardRef< 85 | HTMLTableCellElement, 86 | React.TdHTMLAttributes 87 | >(({ className, ...props }, ref) => ( 88 | [role=checkbox]]:translate-y-[2px]", 92 | className 93 | )} 94 | {...props} 95 | /> 96 | )) 97 | TableCell.displayName = "TableCell" 98 | 99 | const TableCaption = React.forwardRef< 100 | HTMLTableCaptionElement, 101 | React.HTMLAttributes 102 | >(({ className, ...props }, ref) => ( 103 |
108 | )) 109 | TableCaption.displayName = "TableCaption" 110 | 111 | export { 112 | Table, 113 | TableHeader, 114 | TableBody, 115 | TableFooter, 116 | TableHead, 117 | TableRow, 118 | TableCell, 119 | TableCaption, 120 | } 121 | -------------------------------------------------------------------------------- /cmd/scan_cidr.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/sensepost/gowitness/internal/ascii" 7 | "github.com/sensepost/gowitness/internal/islazy" 8 | "github.com/sensepost/gowitness/pkg/log" 9 | "github.com/sensepost/gowitness/pkg/readers" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | var cidrCmdOptions = &readers.CidrReaderOptions{} 14 | var cidrCmd = &cobra.Command{ 15 | Use: "cidr", 16 | Short: "Scan CIDR targets on a network", 17 | Long: ascii.LogoHelp(ascii.Markdown(` 18 | # scan cidr 19 | 20 | Scan CIDR targets on a network. 21 | 22 | This command takes input CIDR ranges, optional extra ports, and other 23 | configuration options to generate permutations for scanning web services to screenshot. 24 | URL schemes are automatically added as 'http://' and 'https://' unless either 25 | the --no-http or --no-https flags are present. 26 | 27 | By default, this command will scan targets sequentially. If the --random flag is 28 | set, targets will go through a shuffling phase before scanning starts. This is 29 | useful in cases where scanning too many ports in sequence may trigger port 30 | scanning-related alerts. 31 | 32 | **Note**: By default, no metadata is saved except for screenshots that are 33 | stored in the configured --screenshot-path. For later parsing (i.e., using the 34 | gowitness reporting feature), you need to specify where to write results (db, 35 | csv, jsonl) using the _--write-*_ set of flags. See _--help_ for available 36 | flags.`)), 37 | Example: ascii.Markdown(` 38 | - gowitness scan cidr --cidr 192.168.0.0/24 --cidr 10.0.50.0/24 39 | - gowitness scan cidr -c 10.0.50.0/24 --port 8888 --port 8443 40 | - gowitness scan cidr -c 172.16.1.0/24 -c 10.10.10.0/24 --no-http --ports-medium 41 | - gowitness scan cidr -t 20 --log-scan-errors -c 10.20.20.0/28`), 42 | PreRunE: func(cmd *cobra.Command, args []string) error { 43 | if cidrCmdOptions.Source == "" && len(cidrCmdOptions.Cidrs) == 0 { 44 | return errors.New("need targets to scan via either a --cidr-file or --cidr") 45 | } 46 | 47 | if cidrCmdOptions.Source != "" && !islazy.FileExists(cidrCmdOptions.Source) { 48 | return errors.New("source is not readable") 49 | } 50 | 51 | return nil 52 | }, 53 | Run: func(cmd *cobra.Command, args []string) { 54 | log.Debug("starting CIDR scanning", "file", cidrCmdOptions.Source, "cidrs", cidrCmdOptions.Cidrs) 55 | 56 | reader := readers.NewCidrReader(cidrCmdOptions) 57 | go func() { 58 | if err := reader.Read(scanRunner.Targets); err != nil { 59 | log.Error("error in reader.Read", "err", err) 60 | return 61 | } 62 | }() 63 | 64 | scanRunner.Run() 65 | scanRunner.Close() 66 | }, 67 | } 68 | 69 | func init() { 70 | scanCmd.AddCommand(cidrCmd) 71 | 72 | cidrCmd.Flags().StringVarP(&cidrCmdOptions.Source, "cidr-file", "f", "", "A file with target CIDRs to scan. Use - for stdin") 73 | cidrCmd.Flags().BoolVar(&cidrCmdOptions.NoHTTP, "no-http", false, "Do not add 'http://' to targets where missing") 74 | cidrCmd.Flags().BoolVar(&cidrCmdOptions.NoHTTPS, "no-https", false, "Do not add 'https://' to targets where missing") 75 | cidrCmd.Flags().StringSliceVarP(&cidrCmdOptions.Cidrs, "cidr", "c", []string{}, "A network CIDR to scan. Supports multiple --cidr flags") 76 | cidrCmd.Flags().IntSliceVarP(&cidrCmdOptions.Ports, "port", "p", []int{80, 443}, "Ports on targets to scan. Supports multiple --port flags") 77 | cidrCmd.Flags().BoolVar(&cidrCmdOptions.PortsSmall, "ports-small", false, "Include a small ports list when scanning targets") 78 | cidrCmd.Flags().BoolVar(&cidrCmdOptions.PortsMedium, "ports-medium", false, "Include a medium ports list when scanning targets") 79 | cidrCmd.Flags().BoolVar(&cidrCmdOptions.PortsLarge, "ports-large", false, "Include a large ports list when scanning targets") 80 | cidrCmd.Flags().BoolVar(&cidrCmdOptions.Random, "random", false, "Randomize scan targets") 81 | } 82 | -------------------------------------------------------------------------------- /cmd/scan_nessus.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/sensepost/gowitness/internal/ascii" 7 | "github.com/sensepost/gowitness/internal/islazy" 8 | "github.com/sensepost/gowitness/pkg/log" 9 | "github.com/sensepost/gowitness/pkg/readers" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | var nessusCmdOptions = &readers.NessusReaderOptions{} 14 | var nessusCmd = &cobra.Command{ 15 | Use: "nessus", 16 | Short: "Scan targets from a Nessus XML file", 17 | Long: ascii.LogoHelp(ascii.Markdown(` 18 | # scan nessus 19 | 20 | Scan targets from a Nessus XML file. 21 | 22 | Targets are parsed out of an exported Nessus scan result in XML format. This 23 | format is typically called "Nessus" format in the export menu. 24 | 25 | By default, the parser will search for web services using the following rules: 26 | - Plugin Name Contains: "Service Detection" 27 | - Plugin Service Name Contains: "www" or "http" 28 | - Plugin Output Value Contains: "web server" 29 | 30 | With these defaults, the parser should detect most web services from a Nessus 31 | scan export. You can adjust the filters to include more Plugin Names, Service 32 | Names, or Plugin Output filters using the --service-name, --plugin-output, and 33 | --plugin-name flags. 34 | 35 | Including the --hostnames flag will have the parser add a scan target based on 36 | any hostname information found in a matched result. 37 | 38 | **Note**: By default, no metadata is saved except for screenshots that are 39 | stored in the configured --screenshot-path. For later parsing (i.e., using the 40 | gowitness reporting feature), you need to specify where to write results (db, 41 | csv, jsonl) using the _--write-*_ set of flags. See _--help_ for available 42 | flags.`)), 43 | Example: ascii.Markdown(` 44 | - gowitness scan nessus -f ~/Desktop/scan-results.nessus 45 | - gowitness scan nessus -f results.nessus --threads 50 --plugin-output server 46 | - gowitness scan nessus -f ./scan-results.nessus --port 80`), 47 | PreRunE: func(cmd *cobra.Command, args []string) error { 48 | if nessusCmdOptions.Source == "" { 49 | return errors.New("a source must be specified") 50 | } 51 | 52 | if !islazy.FileExists(nessusCmdOptions.Source) { 53 | return errors.New("source is not readable") 54 | } 55 | 56 | return nil 57 | }, 58 | Run: func(cmd *cobra.Command, args []string) { 59 | log.Debug("starting Nessus file scanning", "file", nessusCmdOptions.Source) 60 | 61 | reader := readers.NewNessusReader(nessusCmdOptions) 62 | go func() { 63 | if err := reader.Read(scanRunner.Targets); err != nil { 64 | log.Error("error in reader.Read", "err", err) 65 | return 66 | } 67 | }() 68 | 69 | scanRunner.Run() 70 | scanRunner.Close() 71 | }, 72 | } 73 | 74 | func init() { 75 | scanCmd.AddCommand(nessusCmd) 76 | 77 | nessusCmd.Flags().StringVarP(&nessusCmdOptions.Source, "file", "f", "", "A file with targets to scan. Use - for stdin") 78 | nessusCmd.Flags().BoolVar(&nessusCmdOptions.NoHTTP, "no-http", false, "Do not add 'http://' to targets where missing") 79 | nessusCmd.Flags().BoolVar(&nessusCmdOptions.NoHTTPS, "no-https", false, "Do not add 'https://' to targets where missing") 80 | nessusCmd.Flags().BoolVar(&nessusCmdOptions.Hostnames, "hostnames", false, "Enable hostname scanning") 81 | nessusCmd.Flags().StringSliceVar(&nessusCmdOptions.Services, "service-name", []string{"www", "http"}, "Service name filter. Supports multiple --service-name flags") 82 | nessusCmd.Flags().StringSliceVar(&nessusCmdOptions.PluginOutputs, "plugin-output", []string{"web server"}, "Plugin output contains filter. Supports multiple --plugin-output flags") 83 | nessusCmd.Flags().StringSliceVar(&nessusCmdOptions.PluginNames, "plugin-name", []string{"Service Detection"}, "Plugin name filter. Supports multiple --plugin-name flags") 84 | nessusCmd.Flags().IntSliceVar(&nessusCmdOptions.Ports, "port", []int{}, "Port filter. Supports multiple --port flags") 85 | } 86 | -------------------------------------------------------------------------------- /web/api/submit.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "log/slog" 6 | "net/http" 7 | 8 | "github.com/sensepost/gowitness/pkg/log" 9 | "github.com/sensepost/gowitness/pkg/runner" 10 | driver "github.com/sensepost/gowitness/pkg/runner/drivers" 11 | "github.com/sensepost/gowitness/pkg/writers" 12 | ) 13 | 14 | type submitRequest struct { 15 | URLs []string `json:"urls"` 16 | Options *submitRequestOptions `json:"options"` 17 | } 18 | 19 | type submitRequestOptions struct { 20 | X int `json:"window_x"` 21 | Y int `json:"window_y"` 22 | UserAgent string `json:"user_agent"` 23 | Timeout int `json:"timeout"` 24 | Delay int `json:"delay"` 25 | Format string `json:"format"` 26 | } 27 | 28 | // SubmitHandler submits URL's for scans, writing them to the database. 29 | // 30 | // @Summary Submit URL's for scanning 31 | // @Description Starts a new scanning routine for a list of URL's and options, writing results to the database. 32 | // @Tags Results 33 | // @Accept json 34 | // @Produce json 35 | // @Param query body submitRequest true "The URL scanning request object" 36 | // @Success 200 {string} string "Probing started" 37 | // @Router /submit [post] 38 | func (h *ApiHandler) SubmitHandler(w http.ResponseWriter, r *http.Request) { 39 | var request submitRequest 40 | if err := json.NewDecoder(r.Body).Decode(&request); err != nil { 41 | log.Error("failed to read json request", "err", err) 42 | http.Error(w, "Error reading JSON request", http.StatusInternalServerError) 43 | return 44 | } 45 | 46 | if len(request.URLs) == 0 { 47 | http.Error(w, "No URLs provided", http.StatusBadRequest) 48 | return 49 | } 50 | 51 | options := runner.NewDefaultOptions() 52 | options.Scan.ScreenshotPath = h.ScreenshotPath 53 | 54 | // Override default values with request options 55 | if request.Options != nil { 56 | if request.Options.X != 0 { 57 | options.Chrome.WindowX = request.Options.X 58 | } 59 | if request.Options.Y != 0 { 60 | options.Chrome.WindowY = request.Options.Y 61 | } 62 | if request.Options.UserAgent != "" { 63 | options.Chrome.UserAgent = request.Options.UserAgent 64 | } 65 | if request.Options.Timeout != 0 { 66 | options.Scan.Timeout = request.Options.Timeout 67 | } 68 | if request.Options.Delay != 0 { 69 | options.Scan.Delay = request.Options.Delay 70 | } 71 | if request.Options.Format != "" { 72 | options.Scan.ScreenshotFormat = request.Options.Format 73 | } 74 | } 75 | 76 | writer, err := writers.NewDbWriter(h.DbURI, false) 77 | if err != nil { 78 | http.Error(w, "Error connecting to DB for writer", http.StatusInternalServerError) 79 | return 80 | } 81 | 82 | logger := slog.New(log.Logger) 83 | 84 | driver, err := driver.NewChromedp(logger, *options) 85 | if err != nil { 86 | http.Error(w, "Error sarting driver", http.StatusInternalServerError) 87 | return 88 | } 89 | 90 | runner, err := runner.NewRunner(logger, driver, *options, []writers.Writer{writer}) 91 | if err != nil { 92 | log.Error("error starting runner", "err", err) 93 | http.Error(w, "Error starting runner", http.StatusInternalServerError) 94 | return 95 | } 96 | 97 | // have everything we need! start ther runner goroutine 98 | go dispatchRunner(runner, request.URLs) 99 | 100 | response := `Probing started` 101 | jsonData, err := json.Marshal(response) 102 | if err != nil { 103 | http.Error(w, "Error creating JSON response", http.StatusInternalServerError) 104 | return 105 | } 106 | 107 | w.Write(jsonData) 108 | } 109 | 110 | // dispatchRunner run's a runner in a separate goroutine 111 | func dispatchRunner(runner *runner.Runner, targets []string) { 112 | // feed in targets 113 | go func() { 114 | for _, url := range targets { 115 | runner.Targets <- url 116 | } 117 | close(runner.Targets) 118 | }() 119 | 120 | runner.Run() 121 | runner.Close() 122 | } 123 | -------------------------------------------------------------------------------- /web/ui/src/lib/api/types.ts: -------------------------------------------------------------------------------- 1 | // stats 2 | type statistics = { 3 | dbsize: number; 4 | results: number; 5 | headers: number; 6 | consolelogs: number; 7 | networklogs: number; 8 | response_code_stats: response_code_stats[]; 9 | }; 10 | 11 | interface response_code_stats { 12 | code: number; 13 | count: number; 14 | } 15 | 16 | // wappalyzer 17 | type wappalyzer = { 18 | [name: string]: string; 19 | }; 20 | 21 | // gallery 22 | type gallery = { 23 | results: galleryResult[]; 24 | page: number; 25 | limit: number; 26 | total_count: number; 27 | }; 28 | 29 | type galleryResult = { 30 | id: number; 31 | url: string; 32 | probed_at: string; 33 | title: string; 34 | response_code: number; 35 | file_name: string; 36 | screenshot: string; 37 | failed: boolean; 38 | technologies: string[]; 39 | }; 40 | 41 | // list 42 | type list = { 43 | id: number; 44 | url: string; 45 | final_url: string; 46 | response_code: number; 47 | response_reason: string; 48 | protocol: string; 49 | content_length: number; 50 | title: string; 51 | failed: boolean; 52 | failed_reason: string; 53 | }; 54 | 55 | // details 56 | interface tls { 57 | id: number; 58 | result_id: number; 59 | protocol: string; 60 | key_exchange: string; 61 | cipher: string; 62 | subject_name: string; 63 | san_list: sanlist[]; 64 | issuer: string; 65 | valid_from: string; 66 | valid_to: string; 67 | server_signature_algorithm: number; 68 | encrypted_client_hello: boolean; 69 | } 70 | 71 | interface sanlist { 72 | id: number; 73 | tls_id: number; 74 | value: string; 75 | } 76 | 77 | interface technology { 78 | id: number; 79 | result_id: number; 80 | value: string; 81 | } 82 | 83 | interface header { 84 | id: number; 85 | result_id: number; 86 | key: string; 87 | value: string | null; 88 | } 89 | 90 | interface networklog { 91 | id: number; 92 | result_id: number; 93 | request_type: number; 94 | status_code: number; 95 | url: string; 96 | remote_ip: string; 97 | mime_type: string; 98 | time: string; 99 | error: string; 100 | content: string; 101 | } 102 | 103 | interface consolelog { 104 | id: number; 105 | resultid: number; 106 | type: string; 107 | value: string; 108 | } 109 | 110 | interface cookie { 111 | id: number; 112 | result_id: number; 113 | name: string; 114 | value: string; 115 | domain: string; 116 | path: string; 117 | expires: string; // actually a timestamp 118 | size: number; 119 | http_only: boolean; 120 | secure: boolean; 121 | session: boolean; 122 | priority: string; 123 | source_scheme: string; 124 | source_port: number; 125 | } 126 | 127 | interface detail { 128 | id: number; 129 | url: string; 130 | probed_at: string; 131 | final_url: string; 132 | response_code: number; 133 | response_reason: string; 134 | protocol: string; 135 | content_length: number; 136 | html: string; 137 | title: string; 138 | perception_hash: string; 139 | file_name: string; 140 | is_pdf: boolean; 141 | failed: boolean; 142 | failed_reason: string; 143 | screenshot: string; 144 | tls: tls; 145 | technologies: technology[]; 146 | headers: header[]; 147 | network: networklog[]; 148 | console: consolelog[]; 149 | cookies: cookie[]; 150 | } 151 | 152 | interface searchresult { 153 | id: number; 154 | url: string; 155 | final_url: string; 156 | response_code: number; 157 | content_length: number; 158 | title: string; 159 | matched_fields: string[]; 160 | file_name: string; 161 | screenshot: string; 162 | } 163 | 164 | interface technologylist { 165 | technologies: string[]; 166 | } 167 | 168 | export type { 169 | statistics, 170 | wappalyzer, 171 | gallery, 172 | list, 173 | galleryResult, 174 | tls, 175 | sanlist, 176 | technology, 177 | header, 178 | networklog, 179 | consolelog, 180 | cookie, 181 | detail, 182 | searchresult, 183 | technologylist, 184 | }; -------------------------------------------------------------------------------- /pkg/database/db.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/url" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | 11 | "github.com/glebarez/sqlite" 12 | "github.com/sensepost/gowitness/pkg/models" 13 | "gorm.io/driver/mysql" 14 | "gorm.io/driver/postgres" 15 | "gorm.io/gorm" 16 | "gorm.io/gorm/logger" 17 | ) 18 | 19 | // Connection returns a Database connection based on a URI 20 | func Connection(uri string, shouldExist, debug bool) (*gorm.DB, error) { 21 | var err error 22 | var c *gorm.DB 23 | 24 | db, err := url.Parse(uri) 25 | if err != nil { 26 | return nil, err 27 | } 28 | 29 | var config = &gorm.Config{} 30 | if debug { 31 | config.Logger = logger.Default.LogMode(logger.Info) 32 | } else { 33 | config.Logger = logger.Default.LogMode(logger.Error) 34 | } 35 | 36 | switch db.Scheme { 37 | case "sqlite": 38 | if shouldExist { 39 | dbpath := filepath.Join(db.Host, db.Path) 40 | dbpath = filepath.Clean(dbpath) 41 | 42 | if _, err := os.Stat(dbpath); os.IsNotExist(err) { 43 | return nil, fmt.Errorf("sqlite database file does not exist: %s", dbpath) 44 | } else if err != nil { 45 | return nil, fmt.Errorf("error checking sqlite database file: %w", err) 46 | } 47 | } 48 | 49 | c, err = gorm.Open(sqlite.Open(db.Host+db.Path+"?cache=shared"), config) 50 | if err != nil { 51 | return nil, err 52 | } 53 | c.Exec("PRAGMA foreign_keys = ON") 54 | case "postgres": 55 | dsn, err := convertPostgresURItoDSN(uri) 56 | if err != nil { 57 | return nil, err 58 | } 59 | c, err = gorm.Open(postgres.Open(dsn), config) 60 | if err != nil { 61 | return nil, err 62 | } 63 | case "mysql": 64 | dsn, err := convertMySQLURItoDSN(uri) 65 | if err != nil { 66 | return nil, err 67 | } 68 | c, err = gorm.Open(mysql.Open(dsn), config) 69 | if err != nil { 70 | return nil, err 71 | } 72 | default: 73 | return nil, errors.New("invalid db uri scheme") 74 | } 75 | 76 | // run database migrations on the connection 77 | if err := c.AutoMigrate( 78 | &models.Result{}, 79 | &models.TLS{}, 80 | &models.TLSSanList{}, 81 | &models.Technology{}, 82 | &models.Header{}, 83 | &models.NetworkLog{}, 84 | &models.ConsoleLog{}, 85 | &models.Cookie{}, 86 | ); err != nil { 87 | return nil, err 88 | } 89 | 90 | return c, nil 91 | } 92 | 93 | func convertMySQLURItoDSN(uri string) (string, error) { 94 | parsed, err := url.Parse(uri) 95 | if err != nil { 96 | return "", err 97 | } 98 | 99 | user := parsed.User.Username() 100 | pass, _ := parsed.User.Password() 101 | host := parsed.Host 102 | dbname := strings.TrimPrefix(parsed.Path, "/") 103 | 104 | // Handle "tcp(...)" 105 | if strings.HasPrefix(host, "tcp(") && strings.HasSuffix(host, ")") { 106 | host = strings.TrimPrefix(host, "tcp(") 107 | host = strings.TrimSuffix(host, ")") 108 | } 109 | 110 | // Default port 111 | if !strings.Contains(host, ":") { 112 | host = host + ":3306" 113 | } 114 | 115 | dsn := fmt.Sprintf( 116 | "%s:%s@tcp(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", 117 | user, pass, host, dbname, 118 | ) 119 | 120 | return dsn, nil 121 | } 122 | 123 | func convertPostgresURItoDSN(uri string) (string, error) { 124 | parsed, err := url.Parse(uri) 125 | if err != nil { 126 | return "", err 127 | } 128 | 129 | user := parsed.User.Username() 130 | pass, _ := parsed.User.Password() 131 | host := parsed.Hostname() 132 | port := parsed.Port() 133 | if port == "" { 134 | port = "5432" 135 | } 136 | 137 | dbname := strings.TrimPrefix(parsed.Path, "/") 138 | 139 | // Start building the DSN 140 | dsn := fmt.Sprintf( 141 | "host=%s user=%s password=%s dbname=%s port=%s", 142 | host, user, pass, dbname, port, 143 | ) 144 | 145 | // Add query params from URI 146 | query := parsed.Query() 147 | for key, values := range query { 148 | // Only take the first value per key 149 | dsn += fmt.Sprintf(" %s=%s", key, values[0]) 150 | } 151 | 152 | return dsn, nil 153 | } 154 | -------------------------------------------------------------------------------- /pkg/readers/nmap.go: -------------------------------------------------------------------------------- 1 | package readers 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "strings" 7 | 8 | "github.com/lair-framework/go-nmap" 9 | "github.com/sensepost/gowitness/internal/islazy" 10 | ) 11 | 12 | // NmapReader is an Nmap results reader 13 | type NmapReader struct { 14 | Options *NmapReaderOptions 15 | } 16 | 17 | // NmapReaderOptions are options for the nmap reader 18 | type NmapReaderOptions struct { 19 | // Path to an Nmap XML file 20 | Source string 21 | NoHTTP bool 22 | NoHTTPS bool 23 | // OpenOnly will only scan ports marked as open 24 | OpenOnly bool 25 | // Ports to limit scans to 26 | Ports []int 27 | // Ports to exclude, no matter what 28 | ExcludePorts []int 29 | // SkipPorts are ports to not scan 30 | SkipPorts []int 31 | // ServiceContains is a partial service filter 32 | ServiceContains string 33 | // Services is a service limit 34 | Services []string 35 | // Hostname is a hostname to use for url targets 36 | Hostnames bool 37 | } 38 | 39 | // NewNmapReader prepares a new Nmap reader 40 | func NewNmapReader(opts *NmapReaderOptions) *NmapReader { 41 | return &NmapReader{ 42 | Options: opts, 43 | } 44 | } 45 | 46 | // Read an nmap file 47 | func (nr *NmapReader) Read(ch chan<- string) error { 48 | defer close(ch) 49 | 50 | xml, err := os.ReadFile(nr.Options.Source) 51 | if err != nil { 52 | return err 53 | } 54 | 55 | nmapXML, err := nmap.Parse(xml) 56 | if err != nil { 57 | return err 58 | } 59 | 60 | for _, host := range nmapXML.Hosts { 61 | for _, address := range host.Addresses { 62 | if !islazy.SliceHasStr([]string{"ipv4", "ipv6"}, address.AddrType) { 63 | continue 64 | } 65 | 66 | for _, port := range host.Ports { 67 | // filter only open ports 68 | if nr.Options.OpenOnly && port.State.State != "open" { 69 | continue 70 | } 71 | 72 | // if this port should always be excluded 73 | if len(nr.Options.ExcludePorts) > 0 && !islazy.SliceHasInt(nr.Options.ExcludePorts, port.PortId) { 74 | continue 75 | } 76 | 77 | // apply the port filter if it exists 78 | if len(nr.Options.Ports) > 0 && !islazy.SliceHasInt(nr.Options.Ports, port.PortId) { 79 | continue 80 | } 81 | 82 | // apply port skips 83 | if len(nr.Options.SkipPorts) > 0 && islazy.SliceHasInt(nr.Options.SkipPorts, port.PortId) { 84 | continue 85 | } 86 | 87 | // apply service filter 88 | if len(nr.Options.Services) > 0 && !islazy.SliceHasStr(nr.Options.Services, port.Service.Name) { 89 | continue 90 | } 91 | 92 | // apply partial service filter 93 | if len(nr.Options.ServiceContains) > 0 && !strings.Contains(nr.Options.ServiceContains, port.Service.Name) { 94 | continue 95 | } 96 | 97 | // filters are complete, generate urls to push into the channel 98 | 99 | // add hostname candidates 100 | if nr.Options.Hostnames { 101 | for _, hostaName := range host.Hostnames { 102 | for _, target := range nr.urlsFor(hostaName.Name, port.PortId) { 103 | ch <- target 104 | } 105 | } 106 | } 107 | 108 | // ip:port candidates 109 | if address.AddrType == "ipv4" { 110 | for _, target := range nr.urlsFor(address.Addr, port.PortId) { 111 | ch <- target 112 | } 113 | } else { 114 | addr := fmt.Sprintf("[%s]", address.Addr) 115 | for _, target := range nr.urlsFor(addr, port.PortId) { 116 | ch <- target 117 | } 118 | } 119 | } 120 | } 121 | } 122 | 123 | return nil 124 | } 125 | 126 | // urlsFor returns URLs for a scanning candidate. 127 | // For candidates with no protocol, (and none of http/https is ignored), the 128 | // method will return two urls 129 | func (nr *NmapReader) urlsFor(target string, port int) []string { 130 | var urls []string 131 | 132 | if !nr.Options.NoHTTP { 133 | urls = append(urls, fmt.Sprintf("http://%s:%d", target, port)) 134 | } 135 | 136 | if !nr.Options.NoHTTPS { 137 | urls = append(urls, fmt.Sprintf("https://%s:%d", target, port)) 138 | } 139 | 140 | return urls 141 | } 142 | -------------------------------------------------------------------------------- /web/ui/src/pages/dashboard/Dashboard.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { WideSkeleton } from "@/components/loading"; 3 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; 4 | import { DatabaseIcon, FileTextIcon, HardDriveIcon, NetworkIcon, TerminalIcon } from "lucide-react"; 5 | import { Bar, BarChart, CartesianGrid, XAxis, YAxis, ResponsiveContainer } from "recharts"; 6 | import { ChartContainer, ChartLegend, ChartLegendContent, ChartTooltip, ChartTooltipContent, type ChartConfig } from "@/components/ui/chart"; 7 | import * as apitypes from "@/lib/api/types"; 8 | import { getData } from "./data"; 9 | 10 | const chartConfig = { 11 | count: { 12 | label: "Total", 13 | color: "hsl(var(--chart-5))", 14 | }, 15 | code: { 16 | label: "HTTP Status Code", 17 | color: "hsl(var(--chart-1))", 18 | }, 19 | } satisfies ChartConfig; 20 | 21 | const StatCard = ({ title, value, icon: Icon }: { title: string; value: number | string; icon: React.ElementType; }) => ( 22 | 23 | 24 | {title} 25 | 26 | 27 | 28 |
{value}
29 |
30 |
31 | ); 32 | 33 | export default function DashboardPage() { 34 | const [stats, setStats] = useState(); 35 | const [loading, setLoading] = useState(true); 36 | 37 | useEffect(() => { 38 | getData(setLoading, setStats); 39 | }, []); 40 | 41 | if (loading) return ; 42 | 43 | return ( 44 |
45 |

Dashboard

46 |
47 | 52 | 57 | 62 | 67 | 72 |
73 | 74 | 75 | HTTP Status Code Distribution 76 | 77 | 78 | 79 | 80 | 81 | 82 | 87 | `${value}%`} 91 | /> 92 | } /> 93 | } /> 94 | 95 | 96 | 97 | 98 | 99 | 100 |
101 | ); 102 | } -------------------------------------------------------------------------------- /pkg/runner/options.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | // Options are global gowitness options 4 | type Options struct { 5 | // Logging is logging options 6 | Logging Logging 7 | // Chrome is Chrome related options 8 | Chrome Chrome 9 | // Writer is output options 10 | Writer Writer 11 | // Scan is typically Scan options 12 | Scan Scan 13 | } 14 | 15 | // Logging is log related options 16 | type Logging struct { 17 | // Debug display debug level logging 18 | Debug bool 19 | // LogScanErrors log errors related to scanning 20 | LogScanErrors bool 21 | // Silence all logging 22 | Silence bool 23 | } 24 | 25 | // Chrome is Google Chrome related options 26 | type Chrome struct { 27 | // Path to the Chrome binary. An empty value implies that 28 | // go-rod will auto download a platform appropriate binary 29 | // to use. 30 | Path string 31 | // WSS is a websocket URL. Setting this will prevent gowitness 32 | // form launching Chrome, but rather use the remote instance. 33 | WSS string 34 | // Proxy server to use 35 | Proxy string 36 | // UserAgent is the user-agent string to set for Chrome 37 | UserAgent string 38 | // Headers to add to every request 39 | Headers []string 40 | // WindowSize, in pixels. Eg; X=1920,Y=1080 41 | WindowX int 42 | WindowY int 43 | } 44 | 45 | // Writer options 46 | type Writer struct { 47 | Db bool 48 | DbURI string 49 | DbDebug bool // enables verbose database logs 50 | Csv bool 51 | CsvFile string 52 | Jsonl bool 53 | JsonlFile string 54 | Stdout bool 55 | None bool 56 | } 57 | 58 | // Scan is scanning related options 59 | type Scan struct { 60 | // The scan driver to use. Can be one of [gorod, chromedp] 61 | Driver string 62 | // Threads (not really) are the number of goroutines to use. 63 | // More soecifically, its the go-rod page pool well use. 64 | Threads int 65 | // Timeout is the maximum time to wait for a page load before timing out. 66 | Timeout int 67 | // Number of seconds of delay between navigation and screenshotting 68 | Delay int 69 | // UriFilter are URI's that are okay to process. This should normally 70 | // be http and https 71 | UriFilter []string 72 | // Don't write HTML response content 73 | SkipHTML bool 74 | // SkipNetworkLogs stops recording individual request/response entries 75 | SkipNetworkLogs bool 76 | // ScreenshotPath is the path where screenshot images will be stored. 77 | // An empty value means drivers will not write screenshots to disk. In 78 | // that case, you'd need to specify writer saves. 79 | ScreenshotPath string 80 | // ScreenshotFormat to save as 81 | ScreenshotFormat string 82 | // ScreenshotJpegQuality is the quality of the JPEG screenshot (1-100) 83 | ScreenshotJpegQuality int 84 | // ScreenshotFullPage saves full, scrolled web pages 85 | ScreenshotFullPage bool 86 | // ScreenshotToWriter passes screenshots as a model property to writers 87 | ScreenshotToWriter bool 88 | // ScreenshotSkipSave skips saving screenshots to disk 89 | ScreenshotSkipSave bool 90 | // JavaScript to evaluate on every page 91 | JavaScript string 92 | JavaScriptFile string 93 | // Save content stores content from network requests (warning) this 94 | // could make written artefacts huge 95 | SaveContent bool 96 | // HttpCodeFilter are http response codes to screenshot. this is a filter. 97 | // by default all codes are screenshotted 98 | HttpCodeFilter []int 99 | } 100 | 101 | // NewDefaultOptions returns Options with some default values 102 | func NewDefaultOptions() *Options { 103 | return &Options{ 104 | Chrome: Chrome{ 105 | UserAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", 106 | WindowX: 1920, 107 | WindowY: 1080, 108 | }, 109 | Scan: Scan{ 110 | Driver: "chromedp", 111 | Threads: 6, 112 | Timeout: 60, 113 | UriFilter: []string{"http", "https"}, 114 | ScreenshotFormat: "jpeg", 115 | HttpCodeFilter: []int{}, 116 | }, 117 | Logging: Logging{ 118 | Debug: true, 119 | LogScanErrors: true, 120 | }, 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /pkg/models/oldv2/models.go: -------------------------------------------------------------------------------- 1 | package oldv2 2 | 3 | import ( 4 | "encoding/json" 5 | "strconv" 6 | "time" 7 | 8 | "gorm.io/gorm" 9 | ) 10 | 11 | // URL contains information about a URL 12 | type URL struct { 13 | gorm.Model 14 | 15 | URL string 16 | FinalURL string 17 | ResponseCode int 18 | ResponseReason string 19 | Proto string 20 | ContentLength int64 21 | Title string 22 | Filename string 23 | IsPDF bool 24 | PerceptionHash string 25 | DOM string 26 | Screenshot string 27 | 28 | TLS TLS 29 | 30 | Headers []Header 31 | Technologies []Technologie 32 | Console []ConsoleLog 33 | Network []NetworkLog 34 | } 35 | 36 | // AddHeader adds a new header to a URL 37 | func (url *URL) AddHeader(key string, value string) { 38 | url.Headers = append(url.Headers, Header{ 39 | Key: key, 40 | Value: value, 41 | }) 42 | } 43 | 44 | // AddTechnlogies adds a new technologies to a URL 45 | func (url *URL) AddTechnologie(value string) { 46 | url.Technologies = append(url.Technologies, Technologie{ 47 | Value: value, 48 | }) 49 | } 50 | 51 | // MarshallCSV returns values as a slice 52 | func (url *URL) MarshallCSV() (res []string) { 53 | return []string{url.URL, 54 | url.FinalURL, 55 | strconv.Itoa(url.ResponseCode), 56 | url.ResponseReason, 57 | url.Proto, 58 | strconv.Itoa(int(url.ContentLength)), 59 | url.Title, 60 | url.Filename} 61 | } 62 | 63 | // MarshalJSON returns JSON encoding of url. Implements json.Marshaler. 64 | func (url *URL) MarshalJSON() ([]byte, error) { 65 | var tmp struct { 66 | URL string 67 | FinalURL string 68 | ResponseCode int 69 | ResponseReason string 70 | Proto string 71 | ContentLength int64 72 | Title string 73 | Filename string 74 | } 75 | 76 | tmp.URL = url.URL 77 | tmp.FinalURL = url.FinalURL 78 | tmp.ResponseCode = url.ResponseCode 79 | tmp.ResponseReason = url.ResponseReason 80 | tmp.Proto = url.Proto 81 | tmp.ContentLength = url.ContentLength 82 | tmp.Title = url.Title 83 | tmp.Filename = url.Filename 84 | 85 | return json.Marshal(&tmp) 86 | } 87 | 88 | // Header contains an HTTP header 89 | type Header struct { 90 | gorm.Model 91 | 92 | URLID uint 93 | 94 | Key string 95 | Value string 96 | } 97 | 98 | // Technologie contains a technologie 99 | type Technologie struct { 100 | gorm.Model 101 | 102 | URLID uint 103 | 104 | Value string 105 | } 106 | 107 | // TLS contains TLS information for a URL 108 | type TLS struct { 109 | gorm.Model 110 | 111 | URLID uint 112 | 113 | Version uint16 114 | ServerName string 115 | TLSCertificates []TLSCertificate 116 | } 117 | 118 | // TLSCertificate contain TLS Certificate information 119 | type TLSCertificate struct { 120 | gorm.Model 121 | 122 | TLSID uint 123 | 124 | Raw []byte 125 | DNSNames []TLSCertificateDNSName 126 | SubjectCommonName string 127 | IssuerCommonName string 128 | SignatureAlgorithm string 129 | PubkeyAlgorithm string 130 | } 131 | 132 | // AddDNSName adds a new DNS Name to a Certificate 133 | func (tlsCert *TLSCertificate) AddDNSName(name string) { 134 | tlsCert.DNSNames = append(tlsCert.DNSNames, TLSCertificateDNSName{Name: name}) 135 | } 136 | 137 | // TLSCertificateDNSName has DNS names for a TLS certificate 138 | type TLSCertificateDNSName struct { 139 | gorm.Model 140 | 141 | TLSCertificateID uint 142 | Name string 143 | } 144 | 145 | // ConsoleLog contains the console log, and exceptions emitted 146 | type ConsoleLog struct { 147 | gorm.Model 148 | 149 | URLID uint 150 | 151 | Time time.Time 152 | Type string 153 | Value string 154 | } 155 | 156 | // RequestType are network log types 157 | type RequestType int 158 | 159 | const ( 160 | HTTP RequestType = 0 161 | WS 162 | ) 163 | 164 | // NetworkLog contains Chrome networks events that were emitted 165 | type NetworkLog struct { 166 | gorm.Model 167 | 168 | URLID uint 169 | 170 | RequestID string 171 | RequestType RequestType 172 | StatusCode int64 173 | URL string 174 | FinalURL string 175 | IP string 176 | Time time.Time 177 | Error string 178 | } 179 | -------------------------------------------------------------------------------- /cmd/scan_nmap.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/sensepost/gowitness/internal/ascii" 7 | "github.com/sensepost/gowitness/internal/islazy" 8 | "github.com/sensepost/gowitness/pkg/log" 9 | "github.com/sensepost/gowitness/pkg/readers" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | var nmapCmdOptions = &readers.NmapReaderOptions{} 14 | var nmapCmd = &cobra.Command{ 15 | Use: "nmap", 16 | Short: "Scan targets from an Nmap XML file", 17 | Long: ascii.LogoHelp(ascii.Markdown(` 18 | # scan nmap 19 | 20 | Scan targets from an Nmap XML file. 21 | 22 | When performing Nmap scans, specify the -oX nmap.xml flag to store data in an 23 | XML-formatted file that gowitness can parse. 24 | 25 | By default, this command will try and screenshot all ports specified in an 26 | nmap.xml results file. That means it will try and do silly things like 27 | screenshot SSH services, which obviously won't work. It's for this reason that 28 | you'd want to specify the ports or services to parse using the --port and 29 | --service / --service-contains flags. For most HTTP-based services, try: 30 | - --service http 31 | - --service http-alt 32 | - --service http-mgmt 33 | - --service http-proxy 34 | - --service https 35 | - --service https-alt 36 | 37 | On ports, when specifying --port (can be multiple), target candidates will only 38 | be generated for results that match one of the specified ports. In contrast, 39 | when --exclude-port (can also be multiple) is set, no candidates for that port 40 | will be generated. 41 | 42 | **Note**: By default, no metadata is saved except for screenshots that are 43 | stored in the configured --screenshot-path. For later parsing (i.e., using the 44 | gowitness reporting feature), you need to specify where to write results (db, 45 | csv, jsonl) using the _--write-*_ set of flags. See _--help_ for available 46 | flags.`)), 47 | Example: ascii.Markdown(` 48 | - gowitness scan nmap -f ~/Desktop/targets.xml --write-json --write-db 49 | - gowitness scan nmap -f targets.xml --threads 50 --no-http --service https 50 | - gowitness scan nmap -f /tmp/n.xml --open-only --port 80 --port 443 --port 8080 51 | - gowitness scan nmap -f ~/nmap.xml --open-only --service-contains http`), 52 | PreRunE: func(cmd *cobra.Command, args []string) error { 53 | if nmapCmdOptions.Source == "" { 54 | return errors.New("a source must be specified") 55 | } 56 | 57 | if !islazy.FileExists(nmapCmdOptions.Source) { 58 | return errors.New("source is not readable") 59 | } 60 | 61 | return nil 62 | }, 63 | Run: func(cmd *cobra.Command, args []string) { 64 | log.Debug("starting Nmap file scanning", "file", nmapCmdOptions.Source) 65 | 66 | reader := readers.NewNmapReader(nmapCmdOptions) 67 | go func() { 68 | if err := reader.Read(scanRunner.Targets); err != nil { 69 | log.Error("error in reader.Read", "err", err) 70 | return 71 | } 72 | }() 73 | 74 | scanRunner.Run() 75 | scanRunner.Close() 76 | }, 77 | } 78 | 79 | func init() { 80 | scanCmd.AddCommand(nmapCmd) 81 | 82 | nmapCmd.Flags().StringVarP(&nmapCmdOptions.Source, "file", "f", "", "A file with targets to scan. Use - for stdin") 83 | nmapCmd.Flags().BoolVar(&nmapCmdOptions.NoHTTP, "no-http", false, "Do not add 'http://' to targets where missing") 84 | nmapCmd.Flags().BoolVar(&nmapCmdOptions.NoHTTPS, "no-https", false, "Do not add 'https://' to targets where missing") 85 | nmapCmd.Flags().BoolVarP(&nmapCmdOptions.OpenOnly, "open-only", "o", false, "Only scan ports marked as open") 86 | nmapCmd.Flags().IntSliceVar(&nmapCmdOptions.Ports, "port", []int{}, "A port filter to apply. Supports multiple --port flags") 87 | nmapCmd.Flags().IntSliceVar(&nmapCmdOptions.ExcludePorts, "exclude-port", []int{}, "A port exclusion filter to apply. Supports multiple --exclude-port flags") 88 | nmapCmd.Flags().IntSliceVar(&nmapCmdOptions.SkipPorts, "skip-port", []int{}, "Do not scan these ports. Supports multiple --skip-port flags") 89 | nmapCmd.Flags().StringVar(&nmapCmdOptions.ServiceContains, "service-contains", "", "A service name filter. Will check if service 'contains' this value first") 90 | nmapCmd.Flags().StringSliceVar(&nmapCmdOptions.Services, "service", []string{}, "A service filter to apply. Supports multiple --service flags") 91 | nmapCmd.Flags().BoolVar(&nmapCmdOptions.Hostnames, "hostnames", false, "Add hostnames in URL candidates (useful for virtual hosting)") 92 | } 93 | -------------------------------------------------------------------------------- /web/ui/src/components/ui/dialog.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import * as DialogPrimitive from "@radix-ui/react-dialog" 3 | import { Cross2Icon } from "@radix-ui/react-icons" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const Dialog = DialogPrimitive.Root 8 | 9 | const DialogTrigger = DialogPrimitive.Trigger 10 | 11 | const DialogPortal = DialogPrimitive.Portal 12 | 13 | const DialogClose = DialogPrimitive.Close 14 | 15 | const DialogOverlay = React.forwardRef< 16 | React.ElementRef, 17 | React.ComponentPropsWithoutRef 18 | >(({ className, ...props }, ref) => ( 19 | 27 | )) 28 | DialogOverlay.displayName = DialogPrimitive.Overlay.displayName 29 | 30 | const DialogContent = React.forwardRef< 31 | React.ElementRef, 32 | React.ComponentPropsWithoutRef 33 | >(({ className, children, ...props }, ref) => ( 34 | 35 | 36 | 44 | {children} 45 | 46 | 47 | Close 48 | 49 | 50 | 51 | )) 52 | DialogContent.displayName = DialogPrimitive.Content.displayName 53 | 54 | const DialogHeader = ({ 55 | className, 56 | ...props 57 | }: React.HTMLAttributes) => ( 58 |
65 | ) 66 | DialogHeader.displayName = "DialogHeader" 67 | 68 | const DialogFooter = ({ 69 | className, 70 | ...props 71 | }: React.HTMLAttributes) => ( 72 |
79 | ) 80 | DialogFooter.displayName = "DialogFooter" 81 | 82 | const DialogTitle = React.forwardRef< 83 | React.ElementRef, 84 | React.ComponentPropsWithoutRef 85 | >(({ className, ...props }, ref) => ( 86 | 94 | )) 95 | DialogTitle.displayName = DialogPrimitive.Title.displayName 96 | 97 | const DialogDescription = React.forwardRef< 98 | React.ElementRef, 99 | React.ComponentPropsWithoutRef 100 | >(({ className, ...props }, ref) => ( 101 | 106 | )) 107 | DialogDescription.displayName = DialogPrimitive.Description.displayName 108 | 109 | export { 110 | Dialog, 111 | DialogPortal, 112 | DialogOverlay, 113 | DialogTrigger, 114 | DialogClose, 115 | DialogContent, 116 | DialogHeader, 117 | DialogFooter, 118 | DialogTitle, 119 | DialogDescription, 120 | } 121 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "runtime" 8 | "runtime/pprof" 9 | "runtime/trace" 10 | "time" 11 | 12 | "github.com/sensepost/gowitness/internal/ascii" 13 | "github.com/sensepost/gowitness/pkg/log" 14 | "github.com/sensepost/gowitness/pkg/runner" 15 | "github.com/spf13/cobra" 16 | ) 17 | 18 | var ( 19 | opts = &runner.Options{} 20 | 21 | // perf profiling 22 | enableProfiling bool 23 | profileDir string 24 | 25 | // hooks to run after command execution 26 | postRunHooks []func() 27 | ) 28 | 29 | var rootCmd = &cobra.Command{ 30 | Use: "gowitness", 31 | Short: "A web screenshot and information gathering tool", 32 | Long: ascii.Logo(), 33 | PersistentPreRunE: func(cmd *cobra.Command, args []string) error { 34 | if opts.Logging.Silence { 35 | log.EnableSilence() 36 | } 37 | 38 | if opts.Logging.Debug && !opts.Logging.Silence { 39 | // also enable logging of scan errors. often this is _actually_ what 40 | // people want when they enable debug logging. 41 | opts.Logging.LogScanErrors = true 42 | log.EnableDebug() 43 | log.Debug("debug logging enabled. this also enabled --log-scan-errors") 44 | } 45 | 46 | if enableProfiling { 47 | ts := time.Now().Format("20060102-150405") 48 | profileDir = filepath.Join("profiles", ts) 49 | 50 | if err := os.MkdirAll(profileDir, 0o755); err != nil { 51 | return fmt.Errorf("could not create profile directory %q: %w", profileDir, err) 52 | } 53 | 54 | cpuPath := filepath.Join(profileDir, "cpu.pprof") 55 | memPath := filepath.Join(profileDir, "mem.pprof") 56 | tracePath := filepath.Join(profileDir, "trace.out") 57 | 58 | // cpu 59 | cpuFile, err := os.Create(cpuPath) 60 | if err != nil { 61 | return fmt.Errorf("could not create CPU profile file: %w", err) 62 | } 63 | if err := pprof.StartCPUProfile(cpuFile); err != nil { 64 | _ = cpuFile.Close() 65 | return fmt.Errorf("could not start CPU profile: %w", err) 66 | } 67 | postRunHooks = append(postRunHooks, func() { 68 | pprof.StopCPUProfile() 69 | _ = cpuFile.Close() 70 | }) 71 | 72 | // memory 73 | postRunHooks = append(postRunHooks, func() { 74 | memFile, err := os.Create(memPath) 75 | if err != nil { 76 | fmt.Fprintf(os.Stderr, "could not create memory profile file: %v\n", err) 77 | return 78 | } 79 | defer memFile.Close() 80 | 81 | runtime.GC() // refresh heap statistics 82 | 83 | if err := pprof.WriteHeapProfile(memFile); err != nil { 84 | fmt.Fprintf(os.Stderr, "could not write memory profile: %v\n", err) 85 | } 86 | }) 87 | 88 | // trace 89 | traceFile, err := os.Create(tracePath) 90 | if err != nil { 91 | return fmt.Errorf("could not create trace file: %w", err) 92 | } 93 | if err := trace.Start(traceFile); err != nil { 94 | _ = traceFile.Close() 95 | return fmt.Errorf("could not start trace: %w", err) 96 | } 97 | postRunHooks = append(postRunHooks, func() { 98 | trace.Stop() 99 | _ = traceFile.Close() 100 | }) 101 | 102 | // Log where results will be written 103 | log.Info(fmt.Sprintf("profiling enabled: writing profiles to %s", profileDir)) 104 | } 105 | 106 | return nil 107 | }, 108 | 109 | PersistentPostRun: func(cmd *cobra.Command, args []string) { 110 | for _, hook := range postRunHooks { 111 | hook() 112 | } 113 | }, 114 | } 115 | 116 | func Execute() { 117 | rootCmd.CompletionOptions.DisableDefaultCmd = true 118 | rootCmd.SilenceErrors = true 119 | err := rootCmd.Execute() 120 | if err != nil { 121 | var cmd string 122 | c, _, cerr := rootCmd.Find(os.Args[1:]) 123 | if cerr == nil { 124 | cmd = c.Name() 125 | } 126 | 127 | v := "\n" 128 | 129 | if cmd != "" { 130 | v += fmt.Sprintf("An error occured running the `%s` command\n", cmd) 131 | } else { 132 | v += "An error has occured. " 133 | } 134 | 135 | v += "The error was:\n\n" + fmt.Sprintf("```%s```", err) 136 | fmt.Println(ascii.Markdown(v)) 137 | 138 | os.Exit(1) 139 | } 140 | } 141 | 142 | func init() { 143 | rootCmd.PersistentFlags().BoolVarP(&opts.Logging.Debug, "debug-log", "D", false, "Enable debug logging") 144 | rootCmd.PersistentFlags().BoolVarP(&opts.Logging.Silence, "quiet", "q", false, "Silence (almost all) logging") 145 | rootCmd.PersistentFlags().BoolVar(&enableProfiling, "profile", false, "Enable CPU, memory, and trace profiling (writes to profiles//)") 146 | } 147 | -------------------------------------------------------------------------------- /pkg/readers/nessus.go: -------------------------------------------------------------------------------- 1 | package readers 2 | 3 | import ( 4 | "encoding/xml" 5 | "fmt" 6 | "net" 7 | "os" 8 | 9 | "github.com/sensepost/gowitness/internal/islazy" 10 | ) 11 | 12 | // NessusReader is a Nessus file reader 13 | type NessusReader struct { 14 | Options *NessusReaderOptions 15 | } 16 | 17 | // NessusReaderOptions are options for a nessus file reader 18 | type NessusReaderOptions struct { 19 | Source string 20 | NoHTTP bool 21 | NoHTTPS bool 22 | Hostnames bool 23 | // filters 24 | Services []string 25 | PluginOutputs []string 26 | PluginNames []string 27 | Ports []int 28 | } 29 | 30 | // structures for XML parsing 31 | type reportHost struct { 32 | HostName string `xml:"name,attr"` 33 | ReportItems []reportItem `xml:"ReportItem"` 34 | Tags []tag `xml:"HostProperties>tag"` 35 | } 36 | 37 | type tag struct { 38 | Key string `xml:"name,attr"` 39 | Value string `xml:",chardata"` 40 | } 41 | 42 | type reportItem struct { 43 | PluginName string `xml:"pluginName,attr"` 44 | ServiceName string `xml:"svc_name,attr"` 45 | Port int `xml:"port,attr"` 46 | PluginOutput string `xml:"plugin_output"` 47 | } 48 | 49 | // NewNessusReader returns a new Nessus file reader 50 | func NewNessusReader(opts *NessusReaderOptions) *NessusReader { 51 | return &NessusReader{ 52 | Options: opts, 53 | } 54 | } 55 | 56 | func (nr *NessusReader) Read(ch chan<- string) error { 57 | defer close(ch) 58 | 59 | nessus, err := os.Open(nr.Options.Source) 60 | if err != nil { 61 | return err 62 | } 63 | defer nessus.Close() 64 | 65 | decoder := xml.NewDecoder(nessus) 66 | var targets = make(map[string][]int) 67 | 68 | for { 69 | token, err := decoder.Token() 70 | if err != nil || token == nil { 71 | break // EOF or error 72 | } 73 | 74 | switch element := token.(type) { 75 | case xml.StartElement: 76 | if element.Name.Local != "ReportHost" { 77 | break 78 | } 79 | 80 | var host reportHost 81 | decoder.DecodeElement(&host, &element) 82 | 83 | var fqdn, ip string 84 | for _, v := range host.Tags { 85 | if v.Key == "host-fqdn" { 86 | fqdn = v.Value 87 | } 88 | if v.Key == "host-ip" { 89 | ip = v.Value 90 | } 91 | } 92 | 93 | for _, item := range host.ReportItems { 94 | // for future parsing debugging <3 95 | // log.Debug("report item", "ip", ip, "fqdn", fqdn) 96 | // log.Debug("detail", "plugin", item.PluginName, "service", item.ServiceName) 97 | 98 | // skip port if the port does not match the provided ports to filter 99 | if len(nr.Options.Ports) > 0 && !islazy.SliceHasInt(nr.Options.Ports, item.Port) { 100 | continue 101 | } 102 | 103 | // check the plugin name contains a given string. Contains should work, though startsWith may be useful. 104 | // A valid plugin name must be given here, otherwise we'll be iterating across too many pointless plugins. 105 | if !islazy.SliceHasStr(nr.Options.PluginNames, item.PluginName) { 106 | continue 107 | } 108 | 109 | // check the service name. typically this will at least be "web server" and or whatever plugin output 110 | if islazy.SliceHasStr(nr.Options.Services, item.ServiceName) || 111 | islazy.SliceHasStr(nr.Options.PluginOutputs, item.PluginOutput) { 112 | 113 | // Add the hostnames or IP to the merged targetsMap 114 | if nr.Options.Hostnames && fqdn != "" { 115 | targets[fqdn] = islazy.UniqueIntSlice(append(targets[fqdn], item.Port)) 116 | } 117 | if ip != "" { 118 | targets[ip] = islazy.UniqueIntSlice(append(targets[ip], item.Port)) 119 | } 120 | } 121 | } 122 | } 123 | } 124 | 125 | for host, ports := range targets { 126 | for _, target := range nr.urlsFor(host, ports) { 127 | ch <- target 128 | } 129 | } 130 | 131 | return nil 132 | } 133 | 134 | // urlsFor generates urls for a target and its port ranges 135 | func (nr *NessusReader) urlsFor(target string, ports []int) []string { 136 | var urls []string 137 | 138 | ip := net.ParseIP(target) 139 | 140 | host := target 141 | if ip != nil && ip.To4() == nil { 142 | host = fmt.Sprintf("[%s]", target) 143 | } 144 | 145 | for _, port := range ports { 146 | if !nr.Options.NoHTTP { 147 | urls = append(urls, fmt.Sprintf("http://%s:%d", host, port)) 148 | } 149 | if !nr.Options.NoHTTPS { 150 | urls = append(urls, fmt.Sprintf("https://%s:%d", host, port)) 151 | } 152 | } 153 | 154 | return urls 155 | } 156 | -------------------------------------------------------------------------------- /web/ui/src/lib/api/api.ts: -------------------------------------------------------------------------------- 1 | import { gallery, list, statistics, wappalyzer, detail, searchresult, technologylist } from "@/lib/api/types"; 2 | 3 | const endpoints = { 4 | // api base path 5 | base: { 6 | path: import.meta.env.VITE_GOWITNESS_API_BASE_URL 7 | ? import.meta.env.VITE_GOWITNESS_API_BASE_URL + `/api` 8 | : `/api`, 9 | returnas: [] // n/a 10 | }, 11 | // screenshot path 12 | screenshot: { 13 | path: import.meta.env.VITE_GOWITNESS_API_BASE_URL 14 | ? import.meta.env.VITE_GOWITNESS_API_BASE_URL + `/screenshots` 15 | : `/screenshots`, 16 | returnas: [] // n/a 17 | }, 18 | 19 | // get endpoints 20 | statistics: { 21 | path: `/statistics`, 22 | returnas: {} as statistics 23 | }, 24 | wappalyzer: { 25 | path: `/wappalyzer`, 26 | returnas: {} as wappalyzer 27 | }, 28 | gallery: { 29 | path: `/results/gallery`, 30 | returnas: {} as gallery 31 | }, 32 | list: { 33 | path: `/results/list`, 34 | returnas: [] as list[] 35 | }, 36 | detail: { 37 | path: `/results/detail/:id`, 38 | returnas: {} as detail 39 | }, 40 | technology: { 41 | path: `/results/technology`, 42 | returnas: {} as technologylist 43 | }, 44 | 45 | // post endpoints 46 | search: { 47 | path: `/search`, 48 | returnas: {} as searchresult 49 | }, 50 | delete: { 51 | path: `/results/delete`, 52 | returnas: "" as string 53 | }, 54 | submit: { 55 | path: `/submit`, 56 | returnas: "" as string 57 | }, 58 | submitsingle: { 59 | path: `/submit/single`, 60 | returnas: {} as detail 61 | } 62 | }; 63 | 64 | type Endpoints = typeof endpoints; 65 | type EndpointReturnType = Endpoints[K]['returnas']; 66 | 67 | const replacePathParams = (path: string, params?: Record): [string, Record] => { 68 | if (!params) return [path, {}]; 69 | 70 | const paramRegex = /:([a-zA-Z0-9_]+)/g; 71 | const missingParams: string[] = []; 72 | const remainingParams = { ...params }; // Create a copy of the params object to modify 73 | 74 | // Replace all `:param` placeholders with the corresponding values from params 75 | const newPath = path.replace(paramRegex, (match, paramName) => { 76 | if (paramName in remainingParams) { 77 | const value = remainingParams[paramName]; 78 | delete remainingParams[paramName]; 79 | return encodeURIComponent(value.toString()); 80 | } else { 81 | missingParams.push(paramName); 82 | return match; 83 | } 84 | }); 85 | 86 | // If any required params were missing, throw an error 87 | if (missingParams.length > 0) { 88 | throw new Error(`Missing required parameters: ${missingParams.join(', ')}`); 89 | } 90 | 91 | return [newPath, remainingParams]; 92 | }; 93 | 94 | const serializeParams = (params: Record) => { 95 | const query = new URLSearchParams(); 96 | Object.entries(params).forEach(([key, value]) => { 97 | query.append(key, value.toString()); 98 | }); 99 | return query.toString() ? `?${query.toString()}` : ''; 100 | }; 101 | 102 | const get = async ( 103 | endpointKey: K, 104 | params?: Record, 105 | raw: boolean = false 106 | ): Promise> => { 107 | 108 | const endpoint = endpoints[endpointKey]; 109 | const [pathWithParams, remainingParams] = replacePathParams(endpoint.path, params); 110 | const queryString = remainingParams ? serializeParams(remainingParams) : ''; 111 | 112 | const res = await fetch(`${endpoints.base.path}${pathWithParams}${queryString}`); 113 | 114 | if (!res.ok) throw new Error(`HTTP Error: ${res.status}`); 115 | 116 | if (raw) return await res.text() as unknown as EndpointReturnType; 117 | return await res.json() as EndpointReturnType; 118 | }; 119 | 120 | const post = async ( 121 | endpointKey: K, 122 | data?: unknown 123 | ): Promise> => { 124 | 125 | const endpoint = endpoints[endpointKey]; 126 | const res = await fetch(`${endpoints.base.path}${endpoint.path}`, { 127 | method: 'POST', 128 | headers: { 'Content-Type': 'application/json' }, 129 | body: JSON.stringify(data) 130 | }); 131 | 132 | if (!res.ok) throw new Error(`HTTP Error: ${res.status}`); 133 | 134 | return await res.json() as EndpointReturnType; 135 | }; 136 | 137 | export { endpoints, get, post }; -------------------------------------------------------------------------------- /web/ui/src/hooks/use-toast.ts: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import type { 4 | ToastActionElement, 5 | ToastProps, 6 | } from "@/components/ui/toast" 7 | 8 | const TOAST_LIMIT = 1 9 | const TOAST_REMOVE_DELAY = 1000000 10 | 11 | type ToasterToast = ToastProps & { 12 | id: string 13 | title?: React.ReactNode 14 | description?: React.ReactNode 15 | action?: ToastActionElement 16 | } 17 | 18 | const actionTypes = { 19 | ADD_TOAST: "ADD_TOAST", 20 | UPDATE_TOAST: "UPDATE_TOAST", 21 | DISMISS_TOAST: "DISMISS_TOAST", 22 | REMOVE_TOAST: "REMOVE_TOAST", 23 | } as const 24 | 25 | let count = 0 26 | 27 | function genId() { 28 | count = (count + 1) % Number.MAX_SAFE_INTEGER 29 | return count.toString() 30 | } 31 | 32 | type ActionType = typeof actionTypes 33 | 34 | type Action = 35 | | { 36 | type: ActionType["ADD_TOAST"] 37 | toast: ToasterToast 38 | } 39 | | { 40 | type: ActionType["UPDATE_TOAST"] 41 | toast: Partial 42 | } 43 | | { 44 | type: ActionType["DISMISS_TOAST"] 45 | toastId?: ToasterToast["id"] 46 | } 47 | | { 48 | type: ActionType["REMOVE_TOAST"] 49 | toastId?: ToasterToast["id"] 50 | } 51 | 52 | interface State { 53 | toasts: ToasterToast[] 54 | } 55 | 56 | const toastTimeouts = new Map>() 57 | 58 | const addToRemoveQueue = (toastId: string) => { 59 | if (toastTimeouts.has(toastId)) { 60 | return 61 | } 62 | 63 | const timeout = setTimeout(() => { 64 | toastTimeouts.delete(toastId) 65 | dispatch({ 66 | type: "REMOVE_TOAST", 67 | toastId: toastId, 68 | }) 69 | }, TOAST_REMOVE_DELAY) 70 | 71 | toastTimeouts.set(toastId, timeout) 72 | } 73 | 74 | export const reducer = (state: State, action: Action): State => { 75 | switch (action.type) { 76 | case "ADD_TOAST": 77 | return { 78 | ...state, 79 | toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), 80 | } 81 | 82 | case "UPDATE_TOAST": 83 | return { 84 | ...state, 85 | toasts: state.toasts.map((t) => 86 | t.id === action.toast.id ? { ...t, ...action.toast } : t 87 | ), 88 | } 89 | 90 | case "DISMISS_TOAST": { 91 | const { toastId } = action 92 | 93 | // ! Side effects ! - This could be extracted into a dismissToast() action, 94 | // but I'll keep it here for simplicity 95 | if (toastId) { 96 | addToRemoveQueue(toastId) 97 | } else { 98 | state.toasts.forEach((toast) => { 99 | addToRemoveQueue(toast.id) 100 | }) 101 | } 102 | 103 | return { 104 | ...state, 105 | toasts: state.toasts.map((t) => 106 | t.id === toastId || toastId === undefined 107 | ? { 108 | ...t, 109 | open: false, 110 | } 111 | : t 112 | ), 113 | } 114 | } 115 | case "REMOVE_TOAST": 116 | if (action.toastId === undefined) { 117 | return { 118 | ...state, 119 | toasts: [], 120 | } 121 | } 122 | return { 123 | ...state, 124 | toasts: state.toasts.filter((t) => t.id !== action.toastId), 125 | } 126 | } 127 | } 128 | 129 | const listeners: Array<(state: State) => void> = [] 130 | 131 | let memoryState: State = { toasts: [] } 132 | 133 | function dispatch(action: Action) { 134 | memoryState = reducer(memoryState, action) 135 | listeners.forEach((listener) => { 136 | listener(memoryState) 137 | }) 138 | } 139 | 140 | type Toast = Omit 141 | 142 | function toast({ ...props }: Toast) { 143 | const id = genId() 144 | 145 | const update = (props: ToasterToast) => 146 | dispatch({ 147 | type: "UPDATE_TOAST", 148 | toast: { ...props, id }, 149 | }) 150 | const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) 151 | 152 | dispatch({ 153 | type: "ADD_TOAST", 154 | toast: { 155 | ...props, 156 | id, 157 | open: true, 158 | onOpenChange: (open) => { 159 | if (!open) dismiss() 160 | }, 161 | }, 162 | }) 163 | 164 | return { 165 | id: id, 166 | dismiss, 167 | update, 168 | } 169 | } 170 | 171 | function useToast() { 172 | const [state, setState] = React.useState(memoryState) 173 | 174 | React.useEffect(() => { 175 | listeners.push(setState) 176 | return () => { 177 | const index = listeners.indexOf(setState) 178 | if (index > -1) { 179 | listeners.splice(index, 1) 180 | } 181 | } 182 | }, [state]) 183 | 184 | return { 185 | ...state, 186 | toast, 187 | dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), 188 | } 189 | } 190 | 191 | export { useToast, toast } 192 | -------------------------------------------------------------------------------- /pkg/models/models.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // RequestType are network log types 8 | type RequestType int 9 | 10 | const ( 11 | HTTP RequestType = 0 12 | WS 13 | ) 14 | 15 | // Result is a Gowitness result 16 | type Result struct { 17 | ID uint `json:"id" gorm:"primarykey"` 18 | 19 | URL string `json:"url"` 20 | ProbedAt time.Time `json:"probed_at"` 21 | FinalURL string `json:"final_url"` 22 | ResponseCode int `json:"response_code"` 23 | ResponseReason string `json:"response_reason"` 24 | Protocol string `json:"protocol"` 25 | ContentLength int64 `json:"content_length"` 26 | HTML string `json:"html" gorm:"type:longtext;index:,length:191"` 27 | Title string `json:"title" gorm:"index"` 28 | PerceptionHash string `json:"perception_hash" gorm:"index"` 29 | PerceptionHashGroupId uint `json:"perception_hash_group_id" gorm:"index"` 30 | Screenshot string `json:"screenshot"` 31 | 32 | // Name of the screenshot file 33 | Filename string `json:"file_name"` 34 | IsPDF bool `json:"is_pdf"` 35 | 36 | // Failed flag set if the result should be considered failed 37 | Failed bool `json:"failed"` 38 | FailedReason string `json:"failed_reason"` 39 | 40 | TLS TLS `json:"tls" gorm:"constraint:OnDelete:CASCADE"` 41 | Technologies []Technology `json:"technologies" gorm:"constraint:OnDelete:CASCADE"` 42 | 43 | Headers []Header `json:"headers" gorm:"constraint:OnDelete:CASCADE"` 44 | Network []NetworkLog `json:"network" gorm:"constraint:OnDelete:CASCADE"` 45 | Console []ConsoleLog `json:"console" gorm:"constraint:OnDelete:CASCADE"` 46 | Cookies []Cookie `json:"cookies" gorm:"constraint:OnDelete:CASCADE"` 47 | } 48 | 49 | func (r *Result) HeaderMap() map[string][]string { 50 | headersMap := make(map[string][]string) 51 | 52 | for _, header := range r.Headers { 53 | headersMap[header.Key] = []string{header.Value} 54 | } 55 | 56 | return headersMap 57 | } 58 | 59 | type TLS struct { 60 | ID uint `json:"id" gorm:"primarykey"` 61 | ResultID uint `json:"resultid"` 62 | 63 | Protocol string `json:"protocol"` 64 | KeyExchange string `json:"key_exchange"` 65 | Cipher string `json:"cipher"` 66 | SubjectName string `json:"subject_name"` 67 | SanList []TLSSanList `json:"san_list" gorm:"constraint:OnDelete:CASCADE"` 68 | Issuer string `json:"issuer"` 69 | ValidFrom time.Time `json:"valid_from"` 70 | ValidTo time.Time `json:"valid_to"` 71 | ServerSignatureAlgorithm int64 `json:"server_signature_algorithm"` 72 | EncryptedClientHello bool `json:"encrypted_client_hello"` 73 | } 74 | 75 | type TLSSanList struct { 76 | ID uint `json:"id" gorm:"primarykey"` 77 | TLSID uint `json:"tls_id"` 78 | 79 | Value string `json:"value"` 80 | } 81 | 82 | type Technology struct { 83 | ID uint `json:"id" gorm:"primarykey"` 84 | ResultID uint `json:"result_id"` 85 | 86 | Value string `json:"value" gorm:"index"` 87 | } 88 | 89 | type Header struct { 90 | ID uint `json:"id" gorm:"primarykey"` 91 | ResultID uint `json:"result_id"` 92 | 93 | Key string `json:"key"` 94 | Value string `json:"value" gorm:"type:longtext;index:,length:191"` 95 | } 96 | 97 | type NetworkLog struct { 98 | ID uint `json:"id" gorm:"primarykey"` 99 | ResultID uint `json:"result_id"` 100 | 101 | RequestType RequestType `json:"request_type"` 102 | StatusCode int64 `json:"status_code"` 103 | URL string `json:"url"` 104 | RemoteIP string `json:"remote_ip"` 105 | MIMEType string `json:"mime_type"` 106 | Time time.Time `json:"time"` 107 | Content []byte `json:"content"` 108 | Error string `json:"error"` 109 | } 110 | 111 | type ConsoleLog struct { 112 | ID uint `json:"id" gorm:"primarykey"` 113 | ResultID uint `json:"result_id"` 114 | 115 | Type string `json:"type"` 116 | Value string `json:"value" gorm:"type:longtext;index:,length:191"` 117 | } 118 | 119 | type Cookie struct { 120 | ID uint `json:"id" gorm:"primarykey"` 121 | ResultID uint `json:"result_id"` 122 | 123 | Name string `json:"name"` 124 | Value string `json:"value"` 125 | Domain string `json:"domain"` 126 | Path string `json:"path"` 127 | Expires time.Time `json:"expires"` 128 | Size int64 `json:"size"` 129 | HTTPOnly bool `json:"http_only"` 130 | Secure bool `json:"secure"` 131 | Session bool `json:"session"` 132 | Priority string `json:"priority"` 133 | SourceScheme string `json:"source_scheme"` 134 | SourcePort int64 `json:"source_port"` 135 | } 136 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/sensepost/gowitness 2 | 3 | go 1.25 4 | 5 | require ( 6 | github.com/charmbracelet/glamour v0.10.0 7 | github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 8 | github.com/charmbracelet/log v0.4.2 9 | github.com/charmbracelet/x/term v0.2.2 10 | github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d 11 | github.com/chromedp/chromedp v0.14.2 12 | github.com/corona10/goimagehash v1.1.0 13 | github.com/glebarez/sqlite v1.11.0 14 | github.com/go-chi/chi/v5 v5.2.3 15 | github.com/go-chi/cors v1.2.2 16 | github.com/go-rod/rod v0.116.2 17 | github.com/lair-framework/go-nmap v0.0.0-20191202052157-3507e0b03523 18 | github.com/projectdiscovery/wappalyzergo v0.2.56 19 | github.com/spf13/cobra v1.10.1 20 | github.com/swaggo/http-swagger v1.3.4 21 | github.com/swaggo/swag v1.16.6 22 | github.com/ysmood/gson v0.7.3 23 | gorm.io/driver/mysql v1.6.0 24 | gorm.io/driver/postgres v1.6.0 25 | gorm.io/gorm v1.31.1 26 | ) 27 | 28 | require ( 29 | filippo.io/edwards25519 v1.1.0 // indirect 30 | github.com/KyleBanks/depth v1.2.1 // indirect 31 | github.com/alecthomas/chroma/v2 v2.20.0 // indirect 32 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 33 | github.com/aymerick/douceur v0.2.0 // indirect 34 | github.com/charmbracelet/colorprofile v0.3.3 // indirect 35 | github.com/charmbracelet/x/ansi v0.11.1 // indirect 36 | github.com/charmbracelet/x/cellbuf v0.0.14 // indirect 37 | github.com/charmbracelet/x/exp/slice v0.0.0-20251118172736-77d017256798 // indirect 38 | github.com/chromedp/sysutil v1.1.0 // indirect 39 | github.com/clipperhouse/displaywidth v0.6.0 // indirect 40 | github.com/clipperhouse/stringish v0.1.1 // indirect 41 | github.com/clipperhouse/uax29/v2 v2.3.0 // indirect 42 | github.com/dlclark/regexp2 v1.11.5 // indirect 43 | github.com/dustin/go-humanize v1.0.1 // indirect 44 | github.com/glebarez/go-sqlite v1.22.0 // indirect 45 | github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect 46 | github.com/go-logfmt/logfmt v0.6.1 // indirect 47 | github.com/go-openapi/jsonpointer v0.22.3 // indirect 48 | github.com/go-openapi/jsonreference v0.21.3 // indirect 49 | github.com/go-openapi/spec v0.22.1 // indirect 50 | github.com/go-openapi/swag/conv v0.25.3 // indirect 51 | github.com/go-openapi/swag/jsonname v0.25.3 // indirect 52 | github.com/go-openapi/swag/jsonutils v0.25.3 // indirect 53 | github.com/go-openapi/swag/loading v0.25.3 // indirect 54 | github.com/go-openapi/swag/stringutils v0.25.3 // indirect 55 | github.com/go-openapi/swag/typeutils v0.25.3 // indirect 56 | github.com/go-openapi/swag/yamlutils v0.25.3 // indirect 57 | github.com/go-sql-driver/mysql v1.9.3 // indirect 58 | github.com/gobwas/httphead v0.1.0 // indirect 59 | github.com/gobwas/pool v0.2.1 // indirect 60 | github.com/gobwas/ws v1.4.0 // indirect 61 | github.com/google/uuid v1.6.0 // indirect 62 | github.com/gorilla/css v1.0.1 // indirect 63 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 64 | github.com/jackc/pgpassfile v1.0.0 // indirect 65 | github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect 66 | github.com/jackc/pgx/v5 v5.7.6 // indirect 67 | github.com/jackc/puddle/v2 v2.2.2 // indirect 68 | github.com/jinzhu/inflection v1.0.0 // indirect 69 | github.com/jinzhu/now v1.1.5 // indirect 70 | github.com/lucasb-eyer/go-colorful v1.3.0 // indirect 71 | github.com/mattn/go-isatty v0.0.20 // indirect 72 | github.com/mattn/go-runewidth v0.0.19 // indirect 73 | github.com/microcosm-cc/bluemonday v1.0.27 // indirect 74 | github.com/muesli/reflow v0.3.0 // indirect 75 | github.com/muesli/termenv v0.16.0 // indirect 76 | github.com/ncruces/go-strftime v1.0.0 // indirect 77 | github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect 78 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 79 | github.com/rivo/uniseg v0.4.7 // indirect 80 | github.com/spf13/pflag v1.0.10 // indirect 81 | github.com/swaggo/files v1.0.1 // indirect 82 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect 83 | github.com/ysmood/fetchup v0.2.3 // indirect 84 | github.com/ysmood/goob v0.4.0 // indirect 85 | github.com/ysmood/got v0.42.0 // indirect 86 | github.com/ysmood/leakless v0.9.0 // indirect 87 | github.com/yuin/goldmark v1.7.13 // indirect 88 | github.com/yuin/goldmark-emoji v1.0.6 // indirect 89 | go.yaml.in/yaml/v3 v3.0.4 // indirect 90 | golang.org/x/crypto v0.45.0 // indirect 91 | golang.org/x/exp v0.0.0-20251113190631-e25ba8c21ef6 // indirect 92 | golang.org/x/mod v0.30.0 // indirect 93 | golang.org/x/net v0.47.0 // indirect 94 | golang.org/x/sync v0.18.0 // indirect 95 | golang.org/x/sys v0.38.0 // indirect 96 | golang.org/x/term v0.37.0 // indirect 97 | golang.org/x/text v0.31.0 // indirect 98 | golang.org/x/tools v0.39.0 // indirect 99 | modernc.org/libc v1.67.1 // indirect 100 | modernc.org/mathutil v1.7.1 // indirect 101 | modernc.org/memory v1.11.0 // indirect 102 | modernc.org/sqlite v1.40.1 // indirect 103 | ) 104 | -------------------------------------------------------------------------------- /pkg/readers/file.go: -------------------------------------------------------------------------------- 1 | package readers 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "net/url" 7 | "os" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/sensepost/gowitness/internal/islazy" 12 | ) 13 | 14 | // FileReader is a reader that expects a file with targets that 15 | // is newline delimited. 16 | type FileReader struct { 17 | Options *FileReaderOptions 18 | } 19 | 20 | // FileReaderOptions are options for the file reader 21 | type FileReaderOptions struct { 22 | Source string 23 | NoHTTP bool 24 | NoHTTPS bool 25 | Ports []int 26 | PortsSmall bool 27 | PortsMedium bool 28 | PortsLarge bool 29 | Random bool 30 | } 31 | 32 | // NewFileReader prepares a new file reader 33 | func NewFileReader(opts *FileReaderOptions) *FileReader { 34 | return &FileReader{ 35 | Options: opts, 36 | } 37 | } 38 | 39 | // Read from a file that contains targets. 40 | // FilePath can be "-" indicating that we should read from stdin. 41 | func (fr *FileReader) Read(ch chan<- string) error { 42 | defer close(ch) 43 | 44 | var file *os.File 45 | var err error 46 | 47 | if fr.Options.Source == "-" { 48 | file = os.Stdin 49 | } else { 50 | file, err = os.Open(fr.Options.Source) 51 | if err != nil { 52 | return err 53 | } 54 | defer file.Close() 55 | } 56 | 57 | // determine any ports 58 | ports := fr.ports() 59 | 60 | scanner := bufio.NewScanner(file) 61 | for scanner.Scan() { 62 | candidate := scanner.Text() 63 | if candidate == "" { 64 | continue 65 | } 66 | 67 | for _, url := range fr.urlsFor(candidate, ports) { 68 | ch <- url 69 | } 70 | } 71 | 72 | return scanner.Err() 73 | } 74 | 75 | // urlsFor returns URLs for a scanning candidate. 76 | // 77 | // For candidates with no protocol, (and none of http/https is ignored), the 78 | // method will return two urls. 79 | // If any ports configuration exists, those will also be added as candidates. 80 | func (fr *FileReader) urlsFor(candidate string, ports []int) []string { 81 | var urls []string 82 | 83 | // trim any spaces 84 | candidate = strings.TrimSpace(candidate) 85 | 86 | // check if we got a scheme, add 87 | hasScheme := strings.Contains(candidate, "://") 88 | if !hasScheme { 89 | candidate = "http://" + candidate 90 | } 91 | 92 | parsedURL, err := url.Parse(candidate) 93 | if err != nil { 94 | // invalid url, return empty slice 95 | return urls 96 | } 97 | 98 | hasPort := parsedURL.Port() != "" 99 | hostname := parsedURL.Hostname() 100 | 101 | // if hostname is not set we may have rubbish input. try and "fix" it 102 | if hostname == "" { 103 | // is it hostname/path? 104 | if idx := strings.Index(candidate, "/"); idx != -1 { 105 | parsedURL.Host = candidate[:idx] 106 | parsedURL.Path = candidate[idx:] 107 | hostname = parsedURL.Hostname() 108 | } else { 109 | // its just a hostname then? 110 | parsedURL.Host = candidate 111 | parsedURL.Path = "" 112 | hostname = candidate 113 | } 114 | 115 | // at this point if hostname is still "", then just skip it entirely 116 | if hostname == "" { 117 | return urls 118 | } 119 | } 120 | 121 | if hasScheme && hasPort { 122 | // return the candidate as is 123 | urls = append(urls, parsedURL.String()) 124 | return urls 125 | } 126 | 127 | // determine schemes to apply 128 | var schemes []string 129 | if hasScheme { 130 | schemes = append(schemes, parsedURL.Scheme) 131 | } else { 132 | if !fr.Options.NoHTTP { 133 | schemes = append(schemes, "http") 134 | } 135 | if !fr.Options.NoHTTPS { 136 | schemes = append(schemes, "https") 137 | } 138 | } 139 | 140 | // determine ports to use 141 | var targetPorts []int 142 | if hasPort { 143 | port, err := strconv.Atoi(parsedURL.Port()) 144 | if err == nil { // just ignore it 145 | targetPorts = append(targetPorts, port) 146 | } 147 | } else { 148 | // If no port is specified, use the provided ports 149 | targetPorts = ports 150 | } 151 | 152 | // generate the urls 153 | for _, scheme := range schemes { 154 | for _, port := range targetPorts { 155 | host := hostname 156 | 157 | if port != 0 { 158 | if isIPv6(hostname) { 159 | host = fmt.Sprintf("[%s]:%d", hostname, port) 160 | } else { 161 | host = fmt.Sprintf("%s:%d", hostname, port) 162 | } 163 | } 164 | 165 | fullURL := url.URL{ 166 | Scheme: scheme, 167 | Host: host, 168 | Path: parsedURL.Path, 169 | RawQuery: parsedURL.RawQuery, 170 | } 171 | 172 | urls = append(urls, fullURL.String()) 173 | } 174 | } 175 | 176 | return urls 177 | } 178 | 179 | // ports returns all of the ports to scan 180 | func (fr *FileReader) ports() []int { 181 | var ports = fr.Options.Ports 182 | 183 | if fr.Options.PortsSmall { 184 | ports = append(ports, small...) 185 | } 186 | 187 | if fr.Options.PortsMedium { 188 | ports = append(ports, medium...) 189 | } 190 | 191 | if fr.Options.PortsLarge { 192 | ports = append(ports, large...) 193 | } 194 | 195 | return islazy.UniqueIntSlice(ports) 196 | } 197 | 198 | func isIPv6(hostname string) bool { 199 | return len(hostname) > 0 && hostname[0] == '[' && hostname[len(hostname)-1] == ']' 200 | } 201 | -------------------------------------------------------------------------------- /web/api/gallery.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "strconv" 7 | "strings" 8 | "time" 9 | 10 | "github.com/sensepost/gowitness/pkg/log" 11 | "github.com/sensepost/gowitness/pkg/models" 12 | ) 13 | 14 | type galleryResponse struct { 15 | Results []*galleryContent `json:"results"` 16 | Page int `json:"page"` 17 | Limit int `json:"limit"` 18 | TotalCount int64 `json:"total_count"` 19 | } 20 | 21 | type galleryContent struct { 22 | ID uint `json:"id"` 23 | ProbedAt time.Time `json:"probed_at"` 24 | URL string `json:"url"` 25 | ResponseCode int `json:"response_code"` 26 | Title string `json:"title"` 27 | Filename string `json:"file_name"` 28 | Screenshot string `json:"screenshot"` 29 | Failed bool `json:"failed"` 30 | Technologies []string `json:"technologies"` 31 | } 32 | 33 | // GalleryHandler gets a paginated gallery 34 | // 35 | // @Summary Gallery 36 | // @Description Get a paginated list of results. 37 | // @Tags Results 38 | // @Accept json 39 | // @Produce json 40 | // @Param page query int false "The page to load." 41 | // @Param limit query int false "Number of results per page." 42 | // @Param technologies query string false "A comma seperated list of technologies to filter by." 43 | // @Param status query string false "A comma seperated list of HTTP status codes to filter by." 44 | // @Param perception query boolean false "Order the results by perception hash." 45 | // @Param failed query boolean false "Include failed screenshots in the results." 46 | // @Success 200 {object} galleryResponse 47 | // @Router /results/gallery [get] 48 | func (h *ApiHandler) GalleryHandler(w http.ResponseWriter, r *http.Request) { 49 | var results = &galleryResponse{ 50 | Page: 1, 51 | Limit: 24, 52 | } 53 | 54 | // pagination 55 | urlPage := r.URL.Query().Get("page") 56 | urlLimit := r.URL.Query().Get("limit") 57 | if p, err := strconv.Atoi(urlPage); err == nil && p > 0 { 58 | results.Page = p 59 | } 60 | if l, err := strconv.Atoi(urlLimit); err == nil && l > 0 { 61 | results.Limit = l 62 | } 63 | offset := (results.Page - 1) * results.Limit 64 | 65 | // perception sorting 66 | var perceptionSort bool 67 | perceptionSortValue := r.URL.Query().Get("perception") 68 | perceptionSort, err := strconv.ParseBool(perceptionSortValue) 69 | if err != nil { 70 | perceptionSort = false 71 | } 72 | 73 | // status code filtering 74 | var statusCodes []int 75 | statusFilterValue := r.URL.Query().Get("status") 76 | if statusFilterValue != "" { 77 | for _, statusCodeString := range strings.Split(statusFilterValue, ",") { 78 | statusCode, err := strconv.Atoi(statusCodeString) 79 | if err != nil { 80 | continue 81 | } 82 | 83 | statusCodes = append(statusCodes, statusCode) 84 | } 85 | } 86 | 87 | // technology filtering 88 | var technologies []string 89 | technologyFilterValue := r.URL.Query().Get("technologies") 90 | if technologyFilterValue != "" { 91 | technologies = append(technologies, strings.Split(technologyFilterValue, ",")...) 92 | } 93 | 94 | // failed result filtering 95 | var showFailed bool 96 | showFailed, err = strconv.ParseBool(r.URL.Query().Get("failed")) 97 | if err != nil { 98 | showFailed = true 99 | } 100 | 101 | // query the db 102 | var queryResults []*models.Result 103 | query := h.DB.Model(&models.Result{}).Limit(results.Limit). 104 | Offset(offset).Preload("Technologies") 105 | 106 | if perceptionSort { 107 | query.Order("perception_hash_group_id DESC") 108 | } 109 | 110 | if len(statusCodes) > 0 { 111 | query.Where("response_code IN ?", statusCodes) 112 | } 113 | 114 | if len(technologies) > 0 { 115 | query.Where("id in (?)", h.DB.Model(&models.Technology{}). 116 | Select("result_id").Distinct("result_id"). 117 | Where("value IN (?)", technologies)) 118 | } 119 | 120 | if !showFailed { 121 | query.Where("failed = ?", showFailed) 122 | } 123 | 124 | // run the query 125 | if err := query.Find(&queryResults).Error; err != nil { 126 | log.Error("could not get gallery", "err", err) 127 | return 128 | } 129 | 130 | // extract Technologies for each result 131 | for _, result := range queryResults { 132 | var technologies []string 133 | for _, tech := range result.Technologies { 134 | technologies = append(technologies, tech.Value) 135 | } 136 | 137 | // Append the processed data to the response 138 | results.Results = append(results.Results, &galleryContent{ 139 | ID: result.ID, 140 | ProbedAt: result.ProbedAt, 141 | URL: result.URL, 142 | ResponseCode: result.ResponseCode, 143 | Title: result.Title, 144 | Filename: result.Filename, 145 | Screenshot: result.Screenshot, 146 | Failed: result.Failed, 147 | Technologies: technologies, 148 | }) 149 | } 150 | 151 | if err := h.DB.Model(&models.Result{}).Count(&results.TotalCount).Error; err != nil { 152 | log.Error("could not count total results", "err", err) 153 | return 154 | } 155 | 156 | jsonData, err := json.Marshal(results) 157 | if err != nil { 158 | http.Error(w, err.Error(), http.StatusInternalServerError) 159 | return 160 | } 161 | 162 | w.Write(jsonData) 163 | } 164 | --------------------------------------------------------------------------------