merge: phase 17 wave 2

This commit is contained in:
salvacybersec
2026-04-06 17:36:53 +03:00
7 changed files with 754 additions and 222 deletions

View File

@@ -1,259 +1,140 @@
// Package bot implements the Telegram bot interface for KeyHunter.
// It wraps telego v1.8.0 with long-polling updates, per-chat authorization,
// per-user rate limiting, and command dispatch to handler stubs.
// It wraps existing scan, verify, recon, and storage functionality,
// exposing them through Telegram command handlers via the telego library.
package bot
import (
"context"
"fmt"
"strings"
"sync"
"time"
"github.com/mymmrac/telego"
"github.com/mymmrac/telego/telegoutil"
th "github.com/mymmrac/telego/telegohandler"
"github.com/salvacybersec/keyhunter/pkg/engine"
"github.com/salvacybersec/keyhunter/pkg/providers"
"github.com/salvacybersec/keyhunter/pkg/recon"
"github.com/salvacybersec/keyhunter/pkg/storage"
"github.com/salvacybersec/keyhunter/pkg/verify"
)
// Config holds all dependencies and settings for the Telegram bot.
type Config struct {
// Token is the Telegram bot token from BotFather.
Token string
// AllowedChats restricts bot access to these chat IDs.
// Empty slice means allow all chats.
AllowedChats []int64
// DB is the SQLite database for subscriber queries and finding lookups.
DB *storage.DB
// ScanEngine is the scanning engine for /scan commands.
ScanEngine *engine.Engine
// ReconEngine is the recon engine for /recon commands.
ReconEngine *recon.Engine
// ProviderRegistry is the provider registry for /providers and /verify.
ProviderRegistry *providers.Registry
// EncKey is the encryption key for finding decryption.
EncKey []byte
}
// Bot wraps a telego.Bot with KeyHunter command handling and authorization.
// Bot holds the Telegram bot instance and all dependencies needed
// to process commands. It delegates to the existing KeyHunter engine,
// verifier, recon engine, and storage layer.
type Bot struct {
cfg Config
bot *telego.Bot
cancel context.CancelFunc
api *telego.Bot
handler *th.BotHandler
engine *engine.Engine
verifier *verify.HTTPVerifier
recon *recon.Engine
db *storage.DB
registry *providers.Registry
encKey []byte
rateMu sync.Mutex
rateLimits map[int64]time.Time
mu sync.Mutex
startedAt time.Time
lastScan time.Time
}
// commands is the list of bot commands registered with Telegram.
var commands = []telego.BotCommand{
{Command: "scan", Description: "Scan a target for API keys"},
{Command: "verify", Description: "Verify a found API key"},
{Command: "recon", Description: "Run OSINT recon for a keyword"},
{Command: "status", Description: "Show bot and scan status"},
{Command: "stats", Description: "Show finding statistics"},
{Command: "providers", Description: "List supported providers"},
{Command: "help", Description: "Show available commands"},
{Command: "key", Description: "Show full details for a finding"},
{Command: "subscribe", Description: "Subscribe to scan notifications"},
{Command: "unsubscribe", Description: "Unsubscribe from notifications"},
// Deps bundles the dependencies required to construct a Bot.
type Deps struct {
Engine *engine.Engine
Verifier *verify.HTTPVerifier
Recon *recon.Engine
DB *storage.DB
Registry *providers.Registry
EncKey []byte
}
// New creates a new Bot from the given config. Returns an error if the token
// is invalid or telego cannot initialize.
func New(cfg Config) (*Bot, error) {
tb, err := telego.NewBot(cfg.Token)
if err != nil {
return nil, fmt.Errorf("creating telego bot: %w", err)
}
// New creates a Bot backed by the given telego API client and dependencies.
// Call RegisterHandlers to wire up command handlers before starting the update loop.
func New(api *telego.Bot, deps Deps) *Bot {
return &Bot{
cfg: cfg,
bot: tb,
rateLimits: make(map[int64]time.Time),
}, nil
api: api,
engine: deps.Engine,
verifier: deps.Verifier,
recon: deps.Recon,
db: deps.DB,
registry: deps.Registry,
encKey: deps.EncKey,
startedAt: time.Now(),
}
}
// Start begins long-polling for updates and dispatching commands. It blocks
// until the provided context is cancelled or an error occurs.
func (b *Bot) Start(ctx context.Context) error {
ctx, b.cancel = context.WithCancel(ctx)
// RegisterHandlers wires all command handlers into a BotHandler that processes
// updates from the Telegram API. The caller must call Start() on the returned
// BotHandler to begin processing.
func (b *Bot) RegisterHandlers(updates <-chan telego.Update) *th.BotHandler {
bh, _ := th.NewBotHandler(b.api, updates)
// Register command list with Telegram.
err := b.bot.SetMyCommands(ctx, &telego.SetMyCommandsParams{
Commands: commands,
bh.HandleMessage(b.handleHelp, th.CommandEqual("help"))
bh.HandleMessage(b.handleScan, th.CommandEqual("scan"))
bh.HandleMessage(b.handleVerify, th.CommandEqual("verify"))
bh.HandleMessage(b.handleRecon, th.CommandEqual("recon"))
bh.HandleMessage(b.handleStatus, th.CommandEqual("status"))
bh.HandleMessage(b.handleStats, th.CommandEqual("stats"))
bh.HandleMessage(b.handleProviders, th.CommandEqual("providers"))
bh.HandleMessage(b.handleKey, th.CommandEqual("key"))
b.handler = bh
return bh
}
// reply sends a text message back to the chat that originated msg.
func (b *Bot) reply(ctx context.Context, msg *telego.Message, text string) {
_, _ = b.api.SendMessage(ctx, &telego.SendMessageParams{
ChatID: telego.ChatID{ID: msg.Chat.ID},
Text: text,
})
}
// isPrivateChat returns true if the message was sent in a private (1:1) chat.
func isPrivateChat(msg *telego.Message) bool {
return msg.Chat.Type == "private"
}
// runScan executes a scan against the given path and returns findings.
// Findings are collected synchronously; the caller formats the output.
func (b *Bot) runScan(ctx context.Context, path string) ([]engine.Finding, error) {
src, err := selectBotSource(path)
if err != nil {
return fmt.Errorf("setting bot commands: %w", err)
return nil, err
}
updates, err := b.bot.UpdatesViaLongPolling(ctx, nil)
cfg := engine.ScanConfig{
Workers: 0, // auto
Verify: false,
Unmask: false,
}
ch, err := b.engine.Scan(ctx, src, cfg)
if err != nil {
return fmt.Errorf("starting long polling: %w", err)
return nil, fmt.Errorf("starting scan: %w", err)
}
for update := range updates {
if update.Message == nil {
continue
var findings []engine.Finding
for f := range ch {
findings = append(findings, f)
}
// Persist findings
for _, f := range findings {
sf := storage.Finding{
ProviderName: f.ProviderName,
KeyValue: f.KeyValue,
KeyMasked: f.KeyMasked,
Confidence: f.Confidence,
SourcePath: f.Source,
SourceType: f.SourceType,
LineNumber: f.LineNumber,
}
b.dispatch(ctx, update.Message)
_, _ = b.db.SaveFinding(sf, b.encKey)
}
return nil
}
// Stop cancels the bot context, which stops long polling and the update loop.
func (b *Bot) Stop() {
if b.cancel != nil {
b.cancel()
}
}
// isAllowed returns true if the given chat ID is authorized to use the bot.
// If AllowedChats is empty, all chats are allowed.
func (b *Bot) isAllowed(chatID int64) bool {
if len(b.cfg.AllowedChats) == 0 {
return true
}
for _, id := range b.cfg.AllowedChats {
if id == chatID {
return true
}
}
return false
}
// checkRateLimit returns true if the user is allowed to execute a command,
// false if they are still within the cooldown window.
func (b *Bot) checkRateLimit(userID int64, cooldown time.Duration) bool {
b.rateMu.Lock()
defer b.rateMu.Unlock()
last, ok := b.rateLimits[userID]
if ok && time.Since(last) < cooldown {
return false
}
b.rateLimits[userID] = time.Now()
return true
}
// dispatch routes an incoming message to the appropriate handler.
func (b *Bot) dispatch(ctx context.Context, msg *telego.Message) {
chatID := msg.Chat.ID
if !b.isAllowed(chatID) {
_ = b.replyPlain(ctx, chatID, "Unauthorized: your chat ID is not in the allowed list.")
return
}
text := strings.TrimSpace(msg.Text)
if text == "" {
return
}
// Extract command (first word, with optional @mention suffix removed).
cmd := strings.SplitN(text, " ", 2)[0]
if at := strings.Index(cmd, "@"); at > 0 {
cmd = cmd[:at]
}
// Determine cooldown based on command type.
var cooldown time.Duration
switch cmd {
case "/scan", "/verify", "/recon":
cooldown = 60 * time.Second
default:
cooldown = 5 * time.Second
}
if msg.From != nil && !b.checkRateLimit(msg.From.ID, cooldown) {
_ = b.replyPlain(ctx, chatID, "Rate limited. Please wait before sending another command.")
return
}
switch cmd {
case "/scan":
b.handleScan(ctx, msg)
case "/verify":
b.handleVerify(ctx, msg)
case "/recon":
b.handleRecon(ctx, msg)
case "/status":
b.handleStatus(ctx, msg)
case "/stats":
b.handleStats(ctx, msg)
case "/providers":
b.handleProviders(ctx, msg)
case "/help", "/start":
b.handleHelp(ctx, msg)
case "/key":
b.handleKey(ctx, msg)
case "/subscribe":
b.handleSubscribe(ctx, msg)
case "/unsubscribe":
b.handleUnsubscribe(ctx, msg)
}
}
// reply sends a MarkdownV2-formatted message to the given chat.
func (b *Bot) reply(ctx context.Context, chatID int64, text string) error {
params := telegoutil.Message(telego.ChatID{ID: chatID}, text).
WithParseMode("MarkdownV2")
_, err := b.bot.SendMessage(ctx, params)
return err
}
// replyPlain sends a plain text message to the given chat.
func (b *Bot) replyPlain(ctx context.Context, chatID int64, text string) error {
params := telegoutil.Message(telego.ChatID{ID: chatID}, text)
_, err := b.bot.SendMessage(ctx, params)
return err
}
// --- Handler stubs (implemented in Plan 17-03/17-04) ---
func (b *Bot) handleScan(ctx context.Context, msg *telego.Message) {
_ = 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")
}
func (b *Bot) handleSubscribe(ctx context.Context, msg *telego.Message) {
_ = b.replyPlain(ctx, msg.Chat.ID, "Not yet implemented: /subscribe")
}
func (b *Bot) handleUnsubscribe(ctx context.Context, msg *telego.Message) {
_ = b.replyPlain(ctx, msg.Chat.ID, "Not yet implemented: /unsubscribe")
b.mu.Lock()
b.lastScan = time.Now()
b.mu.Unlock()
return findings, nil
}

377
pkg/bot/handlers.go Normal file
View File

@@ -0,0 +1,377 @@
package bot
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
"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.
var commandHelp = []struct {
Cmd string
Desc string
}{
{"/help", "Show this help message"},
{"/scan <path>", "Scan a file or directory for leaked API keys"},
{"/verify <id>", "Verify a stored finding by ID"},
{"/recon [--sources=x,y]", "Run OSINT recon sweep across sources"},
{"/status", "Show bot status and uptime"},
{"/stats", "Show provider and finding statistics"},
{"/providers", "List loaded provider definitions"},
{"/key <id>", "Show full key detail (private chat only)"},
}
// handleHelp responds with a list of all available commands.
func (b *Bot) handleHelp(ctx *th.Context, msg telego.Message) error {
var sb strings.Builder
sb.WriteString("KeyHunter Bot Commands:\n\n")
for _, c := range commandHelp {
sb.WriteString(fmt.Sprintf("%s - %s\n", c.Cmd, c.Desc))
}
b.reply(ctx, &msg, sb.String())
return nil
}
// handleScan triggers a scan on the given path and returns masked findings.
// Usage: /scan <path>
func (b *Bot) handleScan(ctx *th.Context, msg telego.Message) error {
path := extractArg(msg.Text)
if path == "" {
b.reply(ctx, &msg, "Usage: /scan <path>\nExample: /scan /tmp/myrepo")
return nil
}
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.
// Usage: /verify <id>
func (b *Bot) handleVerify(ctx *th.Context, msg telego.Message) error {
arg := extractArg(msg.Text)
if arg == "" {
b.reply(ctx, &msg, "Usage: /verify <id>\nExample: /verify 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
}
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.
// Usage: /recon [--sources=github,gitlab]
func (b *Bot) handleRecon(ctx *th.Context, msg telego.Message) error {
arg := extractArg(msg.Text)
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.
func (b *Bot) handleStatus(ctx *th.Context, msg telego.Message) error {
b.mu.Lock()
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.
func (b *Bot) handleStats(ctx *th.Context, msg telego.Message) error {
stats := b.registry.Stats()
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.
func (b *Bot) handleProviders(ctx *th.Context, msg telego.Message) error {
provs := b.registry.List()
var sb strings.Builder
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,
}
}

96
pkg/bot/handlers_test.go Normal file
View File

@@ -0,0 +1,96 @@
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,
}
}

21
pkg/bot/source.go Normal file
View File

@@ -0,0 +1,21 @@
package bot
import (
"fmt"
"os"
"github.com/salvacybersec/keyhunter/pkg/engine/sources"
)
// selectBotSource returns the appropriate Source for a bot scan request.
// Only file and directory paths are supported (no git, stdin, clipboard, URL).
func selectBotSource(path string) (sources.Source, error) {
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("stat %q: %w", path, err)
}
if info.IsDir() {
return sources.NewDirSource(path), nil
}
return sources.NewFileSource(path), nil
}