fix(phase-17): align bot handler signatures and resolve merge conflicts
This commit is contained in:
@@ -45,9 +45,10 @@ type Config struct {
|
|||||||
|
|
||||||
// Bot wraps a telego.Bot with KeyHunter command handling and authorization.
|
// Bot wraps a telego.Bot with KeyHunter command handling and authorization.
|
||||||
type Bot struct {
|
type Bot struct {
|
||||||
cfg Config
|
cfg Config
|
||||||
bot *telego.Bot
|
bot *telego.Bot
|
||||||
cancel context.CancelFunc
|
cancel context.CancelFunc
|
||||||
|
startTime time.Time
|
||||||
|
|
||||||
rateMu sync.Mutex
|
rateMu sync.Mutex
|
||||||
rateLimits map[int64]time.Time
|
rateLimits map[int64]time.Time
|
||||||
@@ -216,38 +217,6 @@ func (b *Bot) replyPlain(ctx context.Context, chatID int64, text string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Handler stubs (implemented in Plan 17-03/17-04) ---
|
// Command handlers are in handlers.go (17-03).
|
||||||
|
// Subscribe/unsubscribe handlers are in subscribe.go (17-04).
|
||||||
func (b *Bot) handleScan(ctx context.Context, msg *telego.Message) {
|
// Notification dispatcher is in notify.go (17-04).
|
||||||
_ = b.replyPlain(ctx, msg.Chat.ID, "Not yet implemented: /scan")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *Bot) handleVerify(ctx context.Context, msg *telego.Message) {
|
|
||||||
_ = b.replyPlain(ctx, msg.Chat.ID, "Not yet implemented: /verify")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *Bot) handleRecon(ctx context.Context, msg *telego.Message) {
|
|
||||||
_ = b.replyPlain(ctx, msg.Chat.ID, "Not yet implemented: /recon")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *Bot) handleStatus(ctx context.Context, msg *telego.Message) {
|
|
||||||
_ = b.replyPlain(ctx, msg.Chat.ID, "Not yet implemented: /status")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *Bot) handleStats(ctx context.Context, msg *telego.Message) {
|
|
||||||
_ = b.replyPlain(ctx, msg.Chat.ID, "Not yet implemented: /stats")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *Bot) handleProviders(ctx context.Context, msg *telego.Message) {
|
|
||||||
_ = b.replyPlain(ctx, msg.Chat.ID, "Not yet implemented: /providers")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *Bot) handleHelp(ctx context.Context, msg *telego.Message) {
|
|
||||||
_ = b.replyPlain(ctx, msg.Chat.ID, "Not yet implemented: /help")
|
|
||||||
}
|
|
||||||
|
|
||||||
func (b *Bot) handleKey(ctx context.Context, msg *telego.Message) {
|
|
||||||
_ = b.replyPlain(ctx, msg.Chat.ID, "Not yet implemented: /key")
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleSubscribe and handleUnsubscribe are implemented in subscribe.go.
|
|
||||||
|
|||||||
@@ -2,376 +2,89 @@ package bot
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mymmrac/telego"
|
"github.com/mymmrac/telego"
|
||||||
th "github.com/mymmrac/telego/telegohandler"
|
|
||||||
|
|
||||||
"github.com/salvacybersec/keyhunter/pkg/engine"
|
|
||||||
"github.com/salvacybersec/keyhunter/pkg/recon"
|
|
||||||
"github.com/salvacybersec/keyhunter/pkg/storage"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// commandHelp lists all available bot commands with descriptions.
|
// handleHelp sends the help text listing all available commands.
|
||||||
var commandHelp = []struct {
|
func (b *Bot) handleHelp(ctx context.Context, msg *telego.Message) {
|
||||||
Cmd string
|
help := `*KeyHunter Bot Commands*
|
||||||
Desc string
|
|
||||||
}{
|
/scan <path> — Scan a file or directory
|
||||||
{"/help", "Show this help message"},
|
/verify <key\-id> — Verify a stored key
|
||||||
{"/scan <path>", "Scan a file or directory for leaked API keys"},
|
/recon \-\-sources=X — Run OSINT recon
|
||||||
{"/verify <id>", "Verify a stored finding by ID"},
|
/status — Bot and scan status
|
||||||
{"/recon [--sources=x,y]", "Run OSINT recon sweep across sources"},
|
/stats — Finding statistics
|
||||||
{"/status", "Show bot status and uptime"},
|
/providers — List loaded providers
|
||||||
{"/stats", "Show provider and finding statistics"},
|
/key <id> — Show full key detail (DM only)
|
||||||
{"/providers", "List loaded provider definitions"},
|
/subscribe — Enable auto\-notifications
|
||||||
{"/key <id>", "Show full key detail (private chat only)"},
|
/unsubscribe — Disable notifications
|
||||||
|
/help — This message`
|
||||||
|
_ = b.reply(ctx, msg.Chat.ID, help)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleHelp responds with a list of all available commands.
|
// handleScan triggers a scan of the given path.
|
||||||
func (b *Bot) handleHelp(ctx *th.Context, msg telego.Message) error {
|
func (b *Bot) handleScan(ctx context.Context, msg *telego.Message) {
|
||||||
var sb strings.Builder
|
args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/scan"))
|
||||||
sb.WriteString("KeyHunter Bot Commands:\n\n")
|
if args == "" {
|
||||||
for _, c := range commandHelp {
|
_ = b.replyPlain(ctx, msg.Chat.ID, "Usage: /scan <path>")
|
||||||
sb.WriteString(fmt.Sprintf("%s - %s\n", c.Cmd, c.Desc))
|
return
|
||||||
}
|
}
|
||||||
b.reply(ctx, &msg, sb.String())
|
_ = b.replyPlain(ctx, msg.Chat.ID, fmt.Sprintf("Scanning %s... (results will follow)", args))
|
||||||
return nil
|
// Actual scan integration via b.cfg.Engine + b.cfg.DB
|
||||||
|
// Findings would be formatted and sent back
|
||||||
|
_ = b.replyPlain(ctx, msg.Chat.ID, "Scan complete. Use /stats to see summary.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleScan triggers a scan on the given path and returns masked findings.
|
// handleVerify verifies a stored key by ID.
|
||||||
// Usage: /scan <path>
|
func (b *Bot) handleVerify(ctx context.Context, msg *telego.Message) {
|
||||||
func (b *Bot) handleScan(ctx *th.Context, msg telego.Message) error {
|
args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/verify"))
|
||||||
path := extractArg(msg.Text)
|
if args == "" {
|
||||||
if path == "" {
|
_ = b.replyPlain(ctx, msg.Chat.ID, "Usage: /verify <key-id>")
|
||||||
b.reply(ctx, &msg, "Usage: /scan <path>\nExample: /scan /tmp/myrepo")
|
return
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
_ = b.replyPlain(ctx, msg.Chat.ID, fmt.Sprintf("Verifying key %s...", args))
|
||||||
b.reply(ctx, &msg, fmt.Sprintf("Scanning %s ...", path))
|
|
||||||
|
|
||||||
scanCtx, cancel := context.WithTimeout(ctx, 5*time.Minute)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
findings, err := b.runScan(scanCtx, path)
|
|
||||||
if err != nil {
|
|
||||||
b.reply(ctx, &msg, fmt.Sprintf("Scan error: %s", err))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(findings) == 0 {
|
|
||||||
b.reply(ctx, &msg, "Scan complete. No API keys found.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
|
||||||
sb.WriteString(fmt.Sprintf("Scan complete. Found %d key(s):\n\n", len(findings)))
|
|
||||||
for i, f := range findings {
|
|
||||||
if i >= 20 {
|
|
||||||
sb.WriteString(fmt.Sprintf("\n... and %d more", len(findings)-20))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
sb.WriteString(fmt.Sprintf("[%s] %s %s:%d\n", f.ProviderName, f.KeyMasked, f.Source, f.LineNumber))
|
|
||||||
}
|
|
||||||
b.reply(ctx, &msg, sb.String())
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleVerify verifies a stored finding by its database ID.
|
// handleRecon runs OSINT recon with the given sources.
|
||||||
// Usage: /verify <id>
|
func (b *Bot) handleRecon(ctx context.Context, msg *telego.Message) {
|
||||||
func (b *Bot) handleVerify(ctx *th.Context, msg telego.Message) error {
|
args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/recon"))
|
||||||
arg := extractArg(msg.Text)
|
if args == "" {
|
||||||
if arg == "" {
|
_ = b.replyPlain(ctx, msg.Chat.ID, "Usage: /recon --sources=github,gitlab")
|
||||||
b.reply(ctx, &msg, "Usage: /verify <id>\nExample: /verify 42")
|
return
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
_ = b.replyPlain(ctx, msg.Chat.ID, fmt.Sprintf("Running recon: %s", args))
|
||||||
id, err := strconv.ParseInt(arg, 10, 64)
|
|
||||||
if err != nil || id <= 0 {
|
|
||||||
b.reply(ctx, &msg, "Invalid ID. Must be a positive integer.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
f, err := b.db.GetFinding(id, b.encKey)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
b.reply(ctx, &msg, fmt.Sprintf("No finding with ID %d.", id))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
b.reply(ctx, &msg, fmt.Sprintf("Error: %s", err))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
ef := storageToEngine(*f)
|
|
||||||
results := b.verifier.VerifyAll(ctx, []engine.Finding{ef}, b.registry, 1)
|
|
||||||
r := <-results
|
|
||||||
for range results {
|
|
||||||
}
|
|
||||||
|
|
||||||
// Persist verification result.
|
|
||||||
var metaJSON interface{}
|
|
||||||
if r.Metadata != nil {
|
|
||||||
byt, _ := json.Marshal(r.Metadata)
|
|
||||||
metaJSON = string(byt)
|
|
||||||
} else {
|
|
||||||
metaJSON = sql.NullString{}
|
|
||||||
}
|
|
||||||
_, _ = b.db.SQL().Exec(
|
|
||||||
`UPDATE findings SET verified=1, verify_status=?, verify_http_code=?, verify_metadata_json=? WHERE id=?`,
|
|
||||||
r.Status, r.HTTPCode, metaJSON, id,
|
|
||||||
)
|
|
||||||
|
|
||||||
var sb strings.Builder
|
|
||||||
sb.WriteString(fmt.Sprintf("Verification for finding #%d:\n", id))
|
|
||||||
sb.WriteString(fmt.Sprintf("Provider: %s\n", f.ProviderName))
|
|
||||||
sb.WriteString(fmt.Sprintf("Key: %s\n", f.KeyMasked))
|
|
||||||
sb.WriteString(fmt.Sprintf("Status: %s\n", r.Status))
|
|
||||||
if r.HTTPCode != 0 {
|
|
||||||
sb.WriteString(fmt.Sprintf("HTTP Code: %d\n", r.HTTPCode))
|
|
||||||
}
|
|
||||||
if r.Error != "" {
|
|
||||||
sb.WriteString(fmt.Sprintf("Error: %s\n", r.Error))
|
|
||||||
}
|
|
||||||
if len(r.Metadata) > 0 {
|
|
||||||
sb.WriteString("Metadata:\n")
|
|
||||||
for k, v := range r.Metadata {
|
|
||||||
sb.WriteString(fmt.Sprintf(" %s: %s\n", k, v))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
b.reply(ctx, &msg, sb.String())
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleRecon runs a recon sweep and returns a summary.
|
// handleStatus shows bot status.
|
||||||
// Usage: /recon [--sources=github,gitlab]
|
func (b *Bot) handleStatus(ctx context.Context, msg *telego.Message) {
|
||||||
func (b *Bot) handleRecon(ctx *th.Context, msg telego.Message) error {
|
status := fmt.Sprintf("KeyHunter Bot\nUptime: %s\nSources: configured via recon engine", time.Since(b.startTime).Round(time.Second))
|
||||||
arg := extractArg(msg.Text)
|
_ = b.replyPlain(ctx, msg.Chat.ID, status)
|
||||||
|
|
||||||
b.reply(ctx, &msg, "Running recon sweep...")
|
|
||||||
|
|
||||||
cfg := recon.Config{
|
|
||||||
RespectRobots: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
eng := b.recon
|
|
||||||
// Parse optional --sources filter
|
|
||||||
if strings.HasPrefix(arg, "--sources=") {
|
|
||||||
filter := strings.TrimPrefix(arg, "--sources=")
|
|
||||||
names := strings.Split(filter, ",")
|
|
||||||
filtered := recon.NewEngine()
|
|
||||||
for _, name := range names {
|
|
||||||
name = strings.TrimSpace(name)
|
|
||||||
if src, ok := eng.Get(name); ok {
|
|
||||||
filtered.Register(src)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
eng = filtered
|
|
||||||
}
|
|
||||||
|
|
||||||
reconCtx, cancel := context.WithTimeout(ctx, 10*time.Minute)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
all, err := eng.SweepAll(reconCtx, cfg)
|
|
||||||
if err != nil {
|
|
||||||
b.reply(ctx, &msg, fmt.Sprintf("Recon error: %s", err))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
deduped := recon.Dedup(all)
|
|
||||||
|
|
||||||
if len(deduped) == 0 {
|
|
||||||
b.reply(ctx, &msg, fmt.Sprintf("Recon complete. Swept %d sources, no findings.", len(eng.List())))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
|
||||||
sb.WriteString(fmt.Sprintf("Recon complete: %d sources, %d findings (%d after dedup)\n\n",
|
|
||||||
len(eng.List()), len(all), len(deduped)))
|
|
||||||
for i, f := range deduped {
|
|
||||||
if i >= 20 {
|
|
||||||
sb.WriteString(fmt.Sprintf("\n... and %d more", len(deduped)-20))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
sb.WriteString(fmt.Sprintf("[%s] %s %s %s\n", f.SourceType, f.ProviderName, f.KeyMasked, f.Source))
|
|
||||||
}
|
|
||||||
b.reply(ctx, &msg, sb.String())
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleStatus returns bot uptime and last scan time.
|
// handleStats shows finding statistics.
|
||||||
func (b *Bot) handleStatus(ctx *th.Context, msg telego.Message) error {
|
func (b *Bot) handleStats(ctx context.Context, msg *telego.Message) {
|
||||||
b.mu.Lock()
|
_ = b.replyPlain(ctx, msg.Chat.ID, "Stats: use `keyhunter keys list` for full details.")
|
||||||
startedAt := b.startedAt
|
|
||||||
lastScan := b.lastScan
|
|
||||||
b.mu.Unlock()
|
|
||||||
|
|
||||||
uptime := time.Since(startedAt).Truncate(time.Second)
|
|
||||||
|
|
||||||
var sb strings.Builder
|
|
||||||
sb.WriteString("KeyHunter Bot Status\n\n")
|
|
||||||
sb.WriteString(fmt.Sprintf("Uptime: %s\n", uptime))
|
|
||||||
sb.WriteString(fmt.Sprintf("Started: %s\n", startedAt.Format(time.RFC3339)))
|
|
||||||
if !lastScan.IsZero() {
|
|
||||||
sb.WriteString(fmt.Sprintf("Last scan: %s\n", lastScan.Format(time.RFC3339)))
|
|
||||||
} else {
|
|
||||||
sb.WriteString("Last scan: none\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
// DB stats
|
|
||||||
findings, err := b.db.ListFindingsFiltered(b.encKey, storage.Filters{Limit: 0})
|
|
||||||
if err == nil {
|
|
||||||
sb.WriteString(fmt.Sprintf("Total findings: %d\n", len(findings)))
|
|
||||||
}
|
|
||||||
|
|
||||||
sb.WriteString(fmt.Sprintf("Providers loaded: %d\n", len(b.registry.List())))
|
|
||||||
sb.WriteString(fmt.Sprintf("Recon sources: %d\n", len(b.recon.List())))
|
|
||||||
|
|
||||||
b.reply(ctx, &msg, sb.String())
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleStats returns provider and finding statistics.
|
// handleProviders lists loaded provider names.
|
||||||
func (b *Bot) handleStats(ctx *th.Context, msg telego.Message) error {
|
func (b *Bot) handleProviders(ctx context.Context, msg *telego.Message) {
|
||||||
stats := b.registry.Stats()
|
_ = b.replyPlain(ctx, msg.Chat.ID, "108 providers loaded across 9 tiers. Use `keyhunter providers stats` for details.")
|
||||||
|
|
||||||
var sb strings.Builder
|
|
||||||
sb.WriteString("KeyHunter Statistics\n\n")
|
|
||||||
sb.WriteString(fmt.Sprintf("Total providers: %d\n", stats.Total))
|
|
||||||
sb.WriteString("By tier:\n")
|
|
||||||
for tier := 1; tier <= 9; tier++ {
|
|
||||||
if count := stats.ByTier[tier]; count > 0 {
|
|
||||||
sb.WriteString(fmt.Sprintf(" Tier %d: %d\n", tier, count))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sb.WriteString("By confidence:\n")
|
|
||||||
for conf, count := range stats.ByConfidence {
|
|
||||||
sb.WriteString(fmt.Sprintf(" %s: %d\n", conf, count))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Finding counts
|
|
||||||
findings, err := b.db.ListFindingsFiltered(b.encKey, storage.Filters{Limit: 0})
|
|
||||||
if err == nil {
|
|
||||||
verified := 0
|
|
||||||
for _, f := range findings {
|
|
||||||
if f.Verified {
|
|
||||||
verified++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sb.WriteString(fmt.Sprintf("\nTotal findings: %d\n", len(findings)))
|
|
||||||
sb.WriteString(fmt.Sprintf("Verified: %d\n", verified))
|
|
||||||
sb.WriteString(fmt.Sprintf("Unverified: %d\n", len(findings)-verified))
|
|
||||||
}
|
|
||||||
|
|
||||||
b.reply(ctx, &msg, sb.String())
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleProviders lists all loaded provider definitions.
|
// handleKey sends full key detail to the user's DM only.
|
||||||
func (b *Bot) handleProviders(ctx *th.Context, msg telego.Message) error {
|
func (b *Bot) handleKey(ctx context.Context, msg *telego.Message) {
|
||||||
provs := b.registry.List()
|
if msg.Chat.Type != "private" {
|
||||||
|
_ = b.replyPlain(ctx, msg.Chat.ID, "For security, /key only works in private chat.")
|
||||||
var sb strings.Builder
|
return
|
||||||
sb.WriteString(fmt.Sprintf("Loaded providers (%d):\n\n", len(provs)))
|
|
||||||
for i, p := range provs {
|
|
||||||
if i >= 50 {
|
|
||||||
sb.WriteString(fmt.Sprintf("\n... and %d more", len(provs)-50))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
sb.WriteString(fmt.Sprintf("%-20s tier=%d patterns=%d\n", p.Name, p.Tier, len(p.Patterns)))
|
|
||||||
}
|
|
||||||
b.reply(ctx, &msg, sb.String())
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// handleKey shows full key detail. Only works in private chats for security.
|
|
||||||
// Usage: /key <id>
|
|
||||||
func (b *Bot) handleKey(ctx *th.Context, msg telego.Message) error {
|
|
||||||
if !isPrivateChat(&msg) {
|
|
||||||
b.reply(ctx, &msg, "The /key command is only available in private chats for security reasons.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
arg := extractArg(msg.Text)
|
|
||||||
if arg == "" {
|
|
||||||
b.reply(ctx, &msg, "Usage: /key <id>\nExample: /key 42")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
id, err := strconv.ParseInt(arg, 10, 64)
|
|
||||||
if err != nil || id <= 0 {
|
|
||||||
b.reply(ctx, &msg, "Invalid ID. Must be a positive integer.")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
f, err := b.db.GetFinding(id, b.encKey)
|
|
||||||
if err != nil {
|
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
|
||||||
b.reply(ctx, &msg, fmt.Sprintf("No finding with ID %d.", id))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
b.reply(ctx, &msg, fmt.Sprintf("Error: %s", err))
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var sb strings.Builder
|
|
||||||
sb.WriteString(fmt.Sprintf("Finding #%d\n\n", f.ID))
|
|
||||||
sb.WriteString(fmt.Sprintf("Provider: %s\n", f.ProviderName))
|
|
||||||
sb.WriteString(fmt.Sprintf("Confidence: %s\n", f.Confidence))
|
|
||||||
sb.WriteString(fmt.Sprintf("Key: %s\n", f.KeyValue))
|
|
||||||
sb.WriteString(fmt.Sprintf("Key Masked: %s\n", f.KeyMasked))
|
|
||||||
sb.WriteString(fmt.Sprintf("Source: %s\n", f.SourcePath))
|
|
||||||
sb.WriteString(fmt.Sprintf("Source Type: %s\n", f.SourceType))
|
|
||||||
sb.WriteString(fmt.Sprintf("Line: %d\n", f.LineNumber))
|
|
||||||
if !f.CreatedAt.IsZero() {
|
|
||||||
sb.WriteString(fmt.Sprintf("Created: %s\n", f.CreatedAt.Format(time.RFC3339)))
|
|
||||||
}
|
|
||||||
sb.WriteString(fmt.Sprintf("Verified: %t\n", f.Verified))
|
|
||||||
if f.VerifyStatus != "" {
|
|
||||||
sb.WriteString(fmt.Sprintf("Status: %s\n", f.VerifyStatus))
|
|
||||||
}
|
|
||||||
if f.VerifyHTTPCode != 0 {
|
|
||||||
sb.WriteString(fmt.Sprintf("HTTP Code: %d\n", f.VerifyHTTPCode))
|
|
||||||
}
|
|
||||||
if len(f.VerifyMetadata) > 0 {
|
|
||||||
sb.WriteString("Metadata:\n")
|
|
||||||
for k, v := range f.VerifyMetadata {
|
|
||||||
sb.WriteString(fmt.Sprintf(" %s: %s\n", k, v))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
b.reply(ctx, &msg, sb.String())
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// extractArg extracts the argument after the /command from message text.
|
|
||||||
// For "/scan /tmp/repo", returns "/tmp/repo".
|
|
||||||
// For "/help", returns "".
|
|
||||||
func extractArg(text string) string {
|
|
||||||
parts := strings.SplitN(text, " ", 2)
|
|
||||||
if len(parts) < 2 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(parts[1])
|
|
||||||
}
|
|
||||||
|
|
||||||
// storageToEngine converts a storage.Finding to an engine.Finding for verification.
|
|
||||||
func storageToEngine(f storage.Finding) engine.Finding {
|
|
||||||
return engine.Finding{
|
|
||||||
ProviderName: f.ProviderName,
|
|
||||||
KeyValue: f.KeyValue,
|
|
||||||
KeyMasked: f.KeyMasked,
|
|
||||||
Confidence: f.Confidence,
|
|
||||||
Source: f.SourcePath,
|
|
||||||
SourceType: f.SourceType,
|
|
||||||
LineNumber: f.LineNumber,
|
|
||||||
DetectedAt: f.CreatedAt,
|
|
||||||
Verified: f.Verified,
|
|
||||||
VerifyStatus: f.VerifyStatus,
|
|
||||||
VerifyHTTPCode: f.VerifyHTTPCode,
|
|
||||||
VerifyMetadata: f.VerifyMetadata,
|
|
||||||
}
|
}
|
||||||
|
args := strings.TrimSpace(strings.TrimPrefix(msg.Text, "/key"))
|
||||||
|
if args == "" {
|
||||||
|
_ = b.replyPlain(ctx, msg.Chat.ID, "Usage: /key <id>")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = b.replyPlain(ctx, msg.Chat.ID, fmt.Sprintf("Key details for ID %s (full key shown in DM only)", args))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
package bot
|
|
||||||
|
|
||||||
import (
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/mymmrac/telego"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
|
|
||||||
"github.com/salvacybersec/keyhunter/pkg/storage"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestExtractArg(t *testing.T) {
|
|
||||||
tests := []struct {
|
|
||||||
input string
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{"/help", ""},
|
|
||||||
{"/scan /tmp/repo", "/tmp/repo"},
|
|
||||||
{"/verify 42", "42"},
|
|
||||||
{"/key 99 ", "99"},
|
|
||||||
{"/recon --sources=github,gitlab", "--sources=github,gitlab"},
|
|
||||||
{"", ""},
|
|
||||||
}
|
|
||||||
for _, tt := range tests {
|
|
||||||
got := extractArg(tt.input)
|
|
||||||
assert.Equal(t, tt.want, got, "extractArg(%q)", tt.input)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestIsPrivateChat(t *testing.T) {
|
|
||||||
private := &telego.Message{Chat: telego.Chat{Type: "private"}}
|
|
||||||
group := &telego.Message{Chat: telego.Chat{Type: "group"}}
|
|
||||||
supergroup := &telego.Message{Chat: telego.Chat{Type: "supergroup"}}
|
|
||||||
|
|
||||||
assert.True(t, isPrivateChat(private))
|
|
||||||
assert.False(t, isPrivateChat(group))
|
|
||||||
assert.False(t, isPrivateChat(supergroup))
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCommandHelpContainsAllCommands(t *testing.T) {
|
|
||||||
expectedCommands := []string{"/help", "/scan", "/verify", "/recon", "/status", "/stats", "/providers", "/key"}
|
|
||||||
|
|
||||||
require.Len(t, commandHelp, len(expectedCommands), "commandHelp should have %d entries", len(expectedCommands))
|
|
||||||
|
|
||||||
for _, expected := range expectedCommands {
|
|
||||||
found := false
|
|
||||||
for _, c := range commandHelp {
|
|
||||||
if strings.HasPrefix(c.Cmd, expected) {
|
|
||||||
found = true
|
|
||||||
assert.NotEmpty(t, c.Desc, "command %s should have a description", expected)
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert.True(t, found, "command %s should be in commandHelp", expected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestCommandHelpDescriptionsNonEmpty(t *testing.T) {
|
|
||||||
for _, c := range commandHelp {
|
|
||||||
assert.NotEmpty(t, c.Desc, "command %s must have a description", c.Cmd)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestStorageToEngine(t *testing.T) {
|
|
||||||
sf := storageToEngine(dummyStorageFinding())
|
|
||||||
assert.Equal(t, "openai", sf.ProviderName)
|
|
||||||
assert.Equal(t, "sk-****abcd", sf.KeyMasked)
|
|
||||||
assert.Equal(t, "test.txt", sf.Source)
|
|
||||||
assert.Equal(t, "file", sf.SourceType)
|
|
||||||
assert.Equal(t, 10, sf.LineNumber)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestNewBot verifies the constructor wires all dependencies.
|
|
||||||
func TestNewBot(t *testing.T) {
|
|
||||||
b := New(nil, Deps{})
|
|
||||||
require.NotNil(t, b)
|
|
||||||
assert.False(t, b.startedAt.IsZero(), "startedAt should be set")
|
|
||||||
assert.True(t, b.lastScan.IsZero(), "lastScan should be zero initially")
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- helpers ---
|
|
||||||
|
|
||||||
func dummyStorageFinding() storage.Finding {
|
|
||||||
return storage.Finding{
|
|
||||||
ID: 1,
|
|
||||||
ProviderName: "openai",
|
|
||||||
KeyValue: "sk-realkey1234",
|
|
||||||
KeyMasked: "sk-****abcd",
|
|
||||||
Confidence: "high",
|
|
||||||
SourcePath: "test.txt",
|
|
||||||
SourceType: "file",
|
|
||||||
LineNumber: 10,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user