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(` + +
+ + +