Skip to content

Commit 49b7e8e

Browse files
h0tak88rclaude
andcommitted
feat(dashboard): subdomains/scans overhaul, JWT brute-force, scanner cleanup
Subdomains page: - Wire status/tech/cname filter listeners (filters were dead) + debounce - Add "Live only" default toggle backed by a new `live` query param/SQL filter - Status-class buckets (2xx-5xx) alongside exact codes in one SQL expression - Define window.changeSubdomainsPage (pagination was a ReferenceError) - Make Copy All respect active filters; fix onboarding empty-state + nav badge to use the unfiltered stats total Scans page + launcher: - Remove the request preview; redesign launcher (labeled grid, flag accordions) and the filter bar into a toolbar - Fix type filter for suffixed types (nuclei-*, dns-*), mcp-discovery rescan dropping its scan id, and subdomain-mode 400s for js/nuclei Security Lab: - Add JWT HMAC secret brute-force: POST /api/jwt/brute (parallel HS256/384/512, embedded common-secrets wordlist, 16MiB body cap) + Security Lab UI + tests Cleanup: - Remove dead /api/upload handler, validateFilePath, ScanRequest legacy fields, and the launcher upload mode - Remove dead apkx residue (cache-clear button/handler refs, finding-type classifier entry, stale README docs); keep client-side APK Auditor viewing Recon/scanner: - Fold URL collection into recon.RunFullRecon (new phase 5) and remove the fastlook package; repoint gf to recon so gf/sqlmap still get their URL corpus Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 4a84301 commit 49b7e8e

28 files changed

Lines changed: 1092 additions & 583 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ keyhack_templates/ # Cloned in Docker from KeysKit repo
9090

9191
*.txt
9292
!internal/api/ui/adbauditor/robots.txt
93+
!internal/api/jwt_secrets.txt
9394

9495
# Exclude all markdown files except README.md
9596
*.md

README.md

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ autoar lite run -d <domain> Lighter workflow: livehosts → ref
6464
[--phase-timeout] Set default phase timeout in seconds
6565
[--timeout-<phase>] Specific overrides (e.g. --timeout-livehosts)
6666
67-
autoar fastlook run -d <domain> Quick recon: subdomains → live hosts → URLs/JS collection
67+
autoar recon run -d <domain> Unified recon: subdomains → live hosts → tech → CNAMEs → URLs/JS
6868
6969
autoar asr -d <domain> High-depth reconnaissance (ASR Modes)
7070
[-mode 1-5] Recon mode (default: 5)
@@ -222,14 +222,6 @@ The **APK Auditor** is a fully browser-based static analysis tool available at `
222222
# Enter the package ID, optionally enable MITM patch, click Start
223223
```
224224

225-
Or via API:
226-
```bash
227-
curl -X POST https://your-server/scan/apkx \
228-
-H "Authorization: Bearer <token>" \
229-
-H "Content-Type: application/json" \
230-
-d '{"package_id": "com.example.app", "mitm": true}'
231-
```
232-
233225
What happens:
234226
1. Downloads the APK from APKPure (supports `.xapk` / split APKs automatically)
235227
2. *(Optional)* Patches `network_security_config.xml` to trust user-installed CAs + disables certificate pinning
@@ -241,13 +233,6 @@ What happens:
241233

242234
> **Scan records from APK Auditor are hidden from the main Scans dashboard** — they exist only within the Auditor context.
243235
244-
```bash
245-
autoar apkx scan -i <apk_or_ipa_path> Analyze a local APK or IPA file
246-
-p <package_id> Download and scan by package ID
247-
[--mitm] Patch APK for MITM traffic analysis
248-
autoar apkx mitm -i <apk_path> Patch APK for MITM traffic analysis
249-
```
250-
251236
### Mobile Application Analysis (IPA Auditor)
252237

253238
The **IPA Auditor** is a browser-based iOS static analysis tool available at `/ui/ipaauditor/`.

internal/api/api.go

Lines changed: 2 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -363,7 +363,6 @@ type ScanRequest struct {
363363
Bucket *string `json:"bucket"`
364364
Region *string `json:"region"`
365365
Repo *string `json:"repo"`
366-
FilePath *string `json:"file_path"`
367366
Strategy *string `json:"strategy"`
368367
Pattern *string `json:"pattern"`
369368
Interval *int `json:"interval"`
@@ -398,15 +397,6 @@ type ScanRequest struct {
398397
CVEs *[]string `json:"cves"` // CVEs to check (CVE-2025-55182, CVE-2025-14847)
399398
MongoDBHost *string `json:"mongodb_host"` // MongoDB host for CVE-2025-14847
400399
MongoDBPort *int `json:"mongodb_port"` // MongoDB port for CVE-2025-14847
401-
// ApkX options
402-
PackageID *string `json:"package_id"`
403-
MITM *bool `json:"mitm"`
404-
// JWT options
405-
Token *string `json:"token"` // JWT token
406-
SkipCrack *bool `json:"skip_crack"` // JWT skip crack
407-
SkipPayloads *bool `json:"skip_payloads"` // JWT skip payloads
408-
WordlistPath *string `json:"wordlist_path"` // JWT wordlist
409-
MaxCrackAttempts *int `json:"max_crack_attempts"` // JWT max crack attempts
410400
// Misconfig options
411401
ServiceID *string `json:"service_id"` // Misconfig service ID
412402
Delay *int `json:"delay"` // Misconfig delay (ms)
@@ -602,8 +592,8 @@ func SetupAPI() *gin.Engine {
602592
apiGroup.GET("/system/metrics", apiGetSystemMetrics)
603593
apiGroup.GET("/system/limits", apiGetRuntimeLimits)
604594
apiGroup.GET("/nuclei/templates", apiListNucleiTemplates)
605-
// Upload handler
606-
apiGroup.POST("/upload", apiUploadHandler)
595+
// Security Lab — JWT HMAC secret brute-force (client-side analyzer calls this)
596+
apiGroup.POST("/jwt/brute", apiJWTBrute)
607597
// Report Templates
608598
apiGroup.GET("/report-templates", apiListReportTemplates)
609599
apiGroup.GET("/report-templates/export", apiExportReportTemplates)
@@ -714,81 +704,6 @@ func availableMemoryBytes() int64 {
714704
return -1
715705
}
716706

717-
// apiUploadHandler handles file uploads for analysis
718-
func apiUploadHandler(c *gin.Context) {
719-
file, err := c.FormFile("file")
720-
if err != nil {
721-
c.JSON(http.StatusBadRequest, gin.H{"error": "No file uploaded"})
722-
return
723-
}
724-
725-
// Create temp directory for uploads
726-
uploadDir := filepath.Join(utils.GetResultsDir(), "uploads")
727-
if err := os.MkdirAll(uploadDir, 0755); err != nil {
728-
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create upload directory"})
729-
return
730-
}
731-
732-
// Clean filename to prevent traversal
733-
filename := filepath.Base(file.Filename)
734-
destPath := filepath.Join(uploadDir, fmt.Sprintf("%d-%s", time.Now().Unix(), filename))
735-
736-
if err := c.SaveUploadedFile(file, destPath); err != nil {
737-
c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("Failed to save file: %v", err)})
738-
return
739-
}
740-
741-
c.JSON(http.StatusOK, gin.H{
742-
"message": "File uploaded successfully",
743-
"file_path": destPath,
744-
"filename": filename,
745-
})
746-
}
747-
748-
// validateFilePath ensures the given path is inside an allowed directory (#5 path traversal protection).
749-
func validateFilePath(filePath string) error {
750-
if strings.TrimSpace(filePath) == "" {
751-
return fmt.Errorf("file_path is required")
752-
}
753-
allowedRoots := []string{
754-
utils.GetResultsDir(),
755-
os.TempDir(),
756-
"/app",
757-
"/tmp",
758-
}
759-
if extra := os.Getenv("AUTOAR_ALLOWED_FILE_ROOT"); extra != "" {
760-
allowedRoots = append(allowedRoots, extra)
761-
}
762-
763-
resolvedPath, err := filepath.EvalSymlinks(filePath)
764-
if err != nil {
765-
return fmt.Errorf("invalid file_path %q: %w", filePath, err)
766-
}
767-
resolvedPath, err = filepath.Abs(resolvedPath)
768-
if err != nil {
769-
return fmt.Errorf("invalid file_path %q: %w", filePath, err)
770-
}
771-
772-
for _, root := range allowedRoots {
773-
if root == "" {
774-
continue
775-
}
776-
resolvedRoot, rErr := filepath.EvalSymlinks(root)
777-
if rErr != nil {
778-
resolvedRoot = root // root may not exist in some deployments; keep conservative fallback
779-
}
780-
resolvedRoot, rErr = filepath.Abs(filepath.Clean(resolvedRoot))
781-
if rErr != nil {
782-
continue
783-
}
784-
rel, relErr := filepath.Rel(resolvedRoot, resolvedPath)
785-
if relErr == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
786-
return nil
787-
}
788-
}
789-
return fmt.Errorf("file_path %q is outside the allowed directories — only paths under AUTOAR_RESULTS_DIR or /tmp are permitted", filePath)
790-
}
791-
792707
func corsMiddleware() gin.HandlerFunc {
793708
allowedOrigins := strings.TrimSpace(os.Getenv("CORS_ALLOWED_ORIGINS"))
794709
devMode := strings.EqualFold(strings.TrimSpace(os.Getenv("AUTOAR_ENV")), "development")

internal/api/jwt_brute.go

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
package api
2+
3+
import (
4+
"crypto/hmac"
5+
"crypto/sha256"
6+
"crypto/sha512"
7+
_ "embed"
8+
"encoding/base64"
9+
"encoding/json"
10+
"hash"
11+
"net/http"
12+
"runtime"
13+
"strings"
14+
"sync"
15+
"sync/atomic"
16+
17+
"github.com/gin-gonic/gin"
18+
)
19+
20+
// defaultJWTSecrets is the bundled common-secret wordlist used by the JWT
21+
// brute-force endpoint when the caller does not opt out. It is embedded so the
22+
// feature works without depending on the external Wordlists submodule.
23+
//
24+
//go:embed jwt_secrets.txt
25+
var defaultJWTSecrets string
26+
27+
// maxJWTCandidates caps how many secrets a single brute request will try, so a
28+
// huge pasted wordlist can't pin the CPU indefinitely.
29+
const maxJWTCandidates = 2_000_000
30+
31+
type jwtBruteRequest struct {
32+
Token string `json:"token"`
33+
// Secrets is an optional caller-supplied list (newline- or comma-separated)
34+
// tried in addition to (or instead of) the bundled default list.
35+
Secrets string `json:"secrets"`
36+
// UseDefault toggles the bundled wordlist. Defaults to true when omitted.
37+
UseDefault *bool `json:"use_default"`
38+
}
39+
40+
// apiJWTBrute attempts to recover the HMAC secret of a pasted JWT by trying a
41+
// wordlist of candidate secrets. Only HS256/HS384/HS512 (symmetric HMAC) tokens
42+
// can be cracked this way — asymmetric algorithms (RS*/ES*/PS*/EdDSA) are
43+
// rejected with a clear message. Cracking happens in parallel across CPU cores.
44+
func apiJWTBrute(c *gin.Context) {
45+
// Cap the request body so a giant pasted wordlist can't exhaust memory.
46+
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16<<20) // 16 MiB
47+
var req jwtBruteRequest
48+
if err := c.ShouldBindJSON(&req); err != nil {
49+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
50+
return
51+
}
52+
53+
token := strings.TrimSpace(req.Token)
54+
parts := strings.Split(token, ".")
55+
if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" {
56+
c.JSON(http.StatusBadRequest, gin.H{"error": "not a valid JWT (expected header.payload.signature)"})
57+
return
58+
}
59+
60+
headerJSON, err := base64.RawURLEncoding.DecodeString(parts[0])
61+
if err != nil {
62+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JWT header encoding"})
63+
return
64+
}
65+
var header struct {
66+
Alg string `json:"alg"`
67+
}
68+
if err := json.Unmarshal(headerJSON, &header); err != nil {
69+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JWT header JSON"})
70+
return
71+
}
72+
73+
alg := strings.ToUpper(strings.TrimSpace(header.Alg))
74+
var newHash func() hash.Hash
75+
switch alg {
76+
case "HS256":
77+
newHash = sha256.New
78+
case "HS384":
79+
newHash = sha512.New384
80+
case "HS512":
81+
newHash = sha512.New
82+
default:
83+
c.JSON(http.StatusOK, gin.H{
84+
"found": false,
85+
"alg": header.Alg,
86+
"error": "only HMAC algorithms (HS256/HS384/HS512) can be brute-forced; this token uses \"" + header.Alg + "\"",
87+
})
88+
return
89+
}
90+
91+
wantSig, err := base64.RawURLEncoding.DecodeString(parts[2])
92+
if err != nil {
93+
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JWT signature encoding"})
94+
return
95+
}
96+
signingInput := []byte(parts[0] + "." + parts[1])
97+
98+
candidates := buildJWTSecretCandidates(req)
99+
if len(candidates) > maxJWTCandidates {
100+
candidates = candidates[:maxJWTCandidates]
101+
}
102+
103+
found, secret, tried := bruteForceJWTSecret(signingInput, wantSig, newHash, candidates)
104+
105+
resp := gin.H{"alg": header.Alg, "tried": tried, "found": found}
106+
if found {
107+
resp["secret"] = secret
108+
}
109+
c.JSON(http.StatusOK, resp)
110+
}
111+
112+
// buildJWTSecretCandidates merges the bundled wordlist (unless disabled) with any
113+
// caller-supplied secrets, de-duplicating and always including the empty secret.
114+
func buildJWTSecretCandidates(req jwtBruteRequest) []string {
115+
seen := make(map[string]struct{})
116+
out := make([]string, 0, 512)
117+
add := func(s string) {
118+
if _, ok := seen[s]; ok {
119+
return
120+
}
121+
seen[s] = struct{}{}
122+
out = append(out, s)
123+
}
124+
125+
useDefault := req.UseDefault == nil || *req.UseDefault
126+
if useDefault {
127+
for _, line := range strings.Split(defaultJWTSecrets, "\n") {
128+
t := strings.TrimSpace(strings.TrimRight(line, "\r"))
129+
if t == "" || strings.HasPrefix(t, "#") {
130+
continue
131+
}
132+
add(t)
133+
}
134+
}
135+
136+
if strings.TrimSpace(req.Secrets) != "" {
137+
normalized := strings.NewReplacer(",", "\n").Replace(req.Secrets)
138+
for _, line := range strings.Split(normalized, "\n") {
139+
if t := strings.TrimSpace(line); t != "" {
140+
add(t)
141+
}
142+
}
143+
}
144+
145+
add("") // empty-key check (CVE-2018-1000531 class)
146+
return out
147+
}
148+
149+
// bruteForceJWTSecret recomputes the HMAC signature for each candidate secret in
150+
// parallel and returns the first match. Workers always drain the job channel
151+
// (skipping work once a match is found) so the producer can never deadlock.
152+
func bruteForceJWTSecret(signingInput, wantSig []byte, newHash func() hash.Hash, candidates []string) (bool, string, int) {
153+
workers := runtime.NumCPU()
154+
if workers < 2 {
155+
workers = 2
156+
}
157+
158+
jobs := make(chan string, 2048)
159+
var found atomic.Bool
160+
var tried atomic.Int64
161+
var secret atomic.Value
162+
var wg sync.WaitGroup
163+
164+
for i := 0; i < workers; i++ {
165+
wg.Add(1)
166+
go func() {
167+
defer wg.Done()
168+
for cand := range jobs {
169+
if found.Load() {
170+
continue // drain remaining jobs without hashing
171+
}
172+
tried.Add(1)
173+
mac := hmac.New(newHash, []byte(cand))
174+
mac.Write(signingInput)
175+
if hmac.Equal(mac.Sum(nil), wantSig) {
176+
if !found.Swap(true) {
177+
secret.Store(cand)
178+
}
179+
}
180+
}
181+
}()
182+
}
183+
184+
for _, cand := range candidates {
185+
if found.Load() {
186+
break
187+
}
188+
jobs <- cand
189+
}
190+
close(jobs)
191+
wg.Wait()
192+
193+
s, _ := secret.Load().(string)
194+
return found.Load(), s, int(tried.Load())
195+
}

0 commit comments

Comments
 (0)