mirror of
https://github.com/anotherhadi/iknowyou.git
synced 2026-04-11 16:37:25 +02:00
init
This commit is contained in:
24
back/.air.toml
Normal file
24
back/.air.toml
Normal file
@@ -0,0 +1,24 @@
|
||||
root = "."
|
||||
testdata_dir = "testdata"
|
||||
tmp_dir = "tmp"
|
||||
|
||||
[build]
|
||||
bin = "./tmp/main"
|
||||
cmd = "go build -o ./tmp/main ./cmd/server/main.go"
|
||||
include_ext = ["go", "toml"]
|
||||
exclude_dir = ["tmp", "vendor", "node_modules"]
|
||||
delay = 1000
|
||||
stop_on_error = true
|
||||
clean_on_exit = true
|
||||
|
||||
[log]
|
||||
time = true
|
||||
|
||||
[color]
|
||||
main = "magenta"
|
||||
watcher = "cyan"
|
||||
build = "yellow"
|
||||
runner = "green"
|
||||
|
||||
[screen]
|
||||
clear_on_rebuild = true
|
||||
2
back/.gitignore
vendored
Normal file
2
back/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
tmp/
|
||||
config.yaml
|
||||
155
back/cmd/gendocs/main.go
Normal file
155
back/cmd/gendocs/main.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/anotherhadi/iknowyou/internal/registry"
|
||||
"github.com/anotherhadi/iknowyou/internal/tools"
|
||||
)
|
||||
|
||||
func main() {
|
||||
out := flag.String("out", "../.github/docs", "output directory for generated docs")
|
||||
flag.Parse()
|
||||
|
||||
toolsDir := filepath.Join(*out, "tools")
|
||||
if _, err := os.Stat(toolsDir); err == nil {
|
||||
if err := os.RemoveAll(toolsDir); err != nil {
|
||||
fatalf("removing tools dir: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(toolsDir, 0o755); err != nil {
|
||||
fatalf("mkdir: %v", err)
|
||||
}
|
||||
|
||||
runners := make([]tools.ToolRunner, len(registry.Factories))
|
||||
for i, f := range registry.Factories {
|
||||
runners[i] = f()
|
||||
}
|
||||
|
||||
if err := writeIndex(*out, runners); err != nil {
|
||||
fatalf("index: %v", err)
|
||||
}
|
||||
for _, r := range runners {
|
||||
if err := writeTool(*out, r); err != nil {
|
||||
fatalf("tool %s: %v", r.Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("✓ generated docs for %d tools → %s\n", len(runners), *out)
|
||||
}
|
||||
|
||||
// writeIndex writes the tools.md index table.
|
||||
func writeIndex(outDir string, runners []tools.ToolRunner) error {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString("# Tools\n\n")
|
||||
fmt.Fprintf(&b, "_%d tools registered._\n\n", len(runners))
|
||||
|
||||
b.WriteString("| Tool | Input types | Description | Link |\n")
|
||||
b.WriteString("|------|-------------|-------------|------|\n")
|
||||
|
||||
for _, r := range runners {
|
||||
types := make([]string, len(r.InputTypes()))
|
||||
for i, t := range r.InputTypes() {
|
||||
types[i] = fmt.Sprintf("`%s`", t)
|
||||
}
|
||||
link := fmt.Sprintf("[`%s`](tools/%s.md)", r.Name(), r.Name())
|
||||
projectLink := ""
|
||||
if r.Link() != "" {
|
||||
projectLink = fmt.Sprintf("[Link](%s)", r.Link())
|
||||
}
|
||||
fmt.Fprintf(&b, "| %s | %s | %s | %s |\n",
|
||||
link,
|
||||
strings.Join(types, ", "),
|
||||
r.Description(),
|
||||
projectLink,
|
||||
)
|
||||
}
|
||||
|
||||
return writeFile(filepath.Join(outDir, "tools.md"), b.String())
|
||||
}
|
||||
|
||||
// writeTool writes the per-tool detail page.
|
||||
func writeTool(outDir string, r tools.ToolRunner) error {
|
||||
var b strings.Builder
|
||||
|
||||
fmt.Fprintf(&b, "# `%s`\n\n", r.Name())
|
||||
fmt.Fprintf(&b, "%s\n\n", r.Description())
|
||||
|
||||
if r.Link() != "" {
|
||||
fmt.Fprintf(&b, "**Source / documentation:** [%s](%s)\n\n", r.Link(), r.Link())
|
||||
}
|
||||
|
||||
// Input types
|
||||
b.WriteString("## Input types\n\n")
|
||||
for _, t := range r.InputTypes() {
|
||||
fmt.Fprintf(&b, "- `%s`\n", t)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
// External binary dependencies
|
||||
if lister, ok := r.(tools.DependencyLister); ok {
|
||||
if deps := lister.Dependencies(); len(deps) > 0 {
|
||||
b.WriteString("## External dependencies\n\n")
|
||||
b.WriteString("The following binaries must be installed and available in `$PATH`:\n\n")
|
||||
for _, dep := range deps {
|
||||
fmt.Fprintf(&b, "- `%s`\n", dep)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Configuration
|
||||
if d, ok := r.(tools.ConfigDescriber); ok {
|
||||
fields := d.ConfigFields()
|
||||
if len(fields) > 0 {
|
||||
b.WriteString("## Configuration\n\n")
|
||||
b.WriteString("Configure globally via the Tools page or override per profile.\n\n")
|
||||
b.WriteString("| Field | Type | Required | Default | Description |\n")
|
||||
b.WriteString("|-------|------|:--------:|---------|-------------|\n")
|
||||
for _, f := range fields {
|
||||
req := "-"
|
||||
if f.Required {
|
||||
req = "**yes**"
|
||||
}
|
||||
def := "-"
|
||||
if f.Default != nil && fmt.Sprintf("%v", f.Default) != "" {
|
||||
def = fmt.Sprintf("`%v`", f.Default)
|
||||
}
|
||||
desc := f.Description
|
||||
if desc == "" {
|
||||
desc = "-"
|
||||
}
|
||||
fmt.Fprintf(&b, "| `%s` | `%s` | %s | %s | %s |\n",
|
||||
f.Name, f.Type, req, def, desc,
|
||||
)
|
||||
}
|
||||
b.WriteString("\n")
|
||||
} else {
|
||||
b.WriteString("## Configuration\n\nThis tool has no configuration fields.\n\n")
|
||||
}
|
||||
} else if _, ok := r.(tools.Configurable); ok {
|
||||
b.WriteString("## Configuration\n\nThis tool is configurable but does not expose field metadata.\n\n")
|
||||
} else {
|
||||
b.WriteString("## Configuration\n\nThis tool requires no configuration.\n\n")
|
||||
}
|
||||
|
||||
b.WriteString("---\n\n")
|
||||
b.WriteString("[← Back to tools index](../tools.md)\n")
|
||||
|
||||
return writeFile(filepath.Join(outDir, "tools", r.Name()+".md"), b.String())
|
||||
}
|
||||
|
||||
func writeFile(path, content string) error {
|
||||
return os.WriteFile(path, []byte(content), 0o644)
|
||||
}
|
||||
|
||||
func fatalf(format string, args ...any) {
|
||||
fmt.Fprintf(os.Stderr, "gendocs: "+format+"\n", args...)
|
||||
os.Exit(1)
|
||||
}
|
||||
62
back/cmd/server/main.go
Normal file
62
back/cmd/server/main.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/anotherhadi/iknowyou/config/env"
|
||||
internalapi "github.com/anotherhadi/iknowyou/internal/api"
|
||||
"github.com/anotherhadi/iknowyou/internal/registry"
|
||||
"github.com/anotherhadi/iknowyou/internal/search"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfg, err := env.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("env: %v", err)
|
||||
}
|
||||
|
||||
manager := search.NewManager(cfg.ConfigPath, registry.Factories, cfg.SearchTTL, cfg.CleanupInterval)
|
||||
defer manager.Stop()
|
||||
|
||||
if cfg.Demo {
|
||||
manager.InjectDemoSearches()
|
||||
log.Println("demo mode enabled")
|
||||
}
|
||||
|
||||
router := internalapi.NewRouter(manager, registry.Factories, cfg.ConfigPath, cfg.FrontDir, cfg.Demo)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", cfg.Port),
|
||||
Handler: router,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
go func() {
|
||||
log.Printf("listening on :%d (config: %s)", cfg.Port, cfg.ConfigPath)
|
||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("server error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
<-ctx.Done()
|
||||
log.Println("shutting down...")
|
||||
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||
log.Fatalf("graceful shutdown failed: %v", err)
|
||||
}
|
||||
|
||||
log.Println("stopped")
|
||||
}
|
||||
62
back/config/builtin.go
Normal file
62
back/config/builtin.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package config
|
||||
|
||||
import "gopkg.in/yaml.v3"
|
||||
|
||||
// BuiltinProfile is a hardcoded, read-only profile with optional tool config overrides.
|
||||
type BuiltinProfile struct {
|
||||
Notes string
|
||||
Profile Profile
|
||||
Tools map[string]map[string]any
|
||||
}
|
||||
|
||||
var BuiltinProfiles = map[string]BuiltinProfile{
|
||||
"default": {
|
||||
Notes: "Standard profile. All tools are active with default settings.",
|
||||
Profile: Profile{},
|
||||
},
|
||||
"hard": {
|
||||
Notes: "Aggressive profile. All tools are active, including those that may send notifications to the target.",
|
||||
Profile: Profile{},
|
||||
Tools: map[string]map[string]any{
|
||||
"user-scanner": {"allow_loud": true},
|
||||
"github-recon": {"deepscan": true},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
func ApplyBuiltinToolOverride(profileName, toolName string, dst any) error {
|
||||
builtin, ok := BuiltinProfiles[profileName]
|
||||
if !ok || builtin.Tools == nil {
|
||||
return nil
|
||||
}
|
||||
overrides, hasOverride := builtin.Tools[toolName]
|
||||
if !hasOverride {
|
||||
return nil
|
||||
}
|
||||
b, err := yaml.Marshal(overrides)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return yaml.Unmarshal(b, dst)
|
||||
}
|
||||
|
||||
func ActiveToolsForProfile(p Profile, allToolNames []string) []string {
|
||||
active := allToolNames
|
||||
if len(p.Enabled) > 0 {
|
||||
active = p.Enabled
|
||||
}
|
||||
if len(p.Disabled) > 0 {
|
||||
blacklist := make(map[string]struct{}, len(p.Disabled))
|
||||
for _, n := range p.Disabled {
|
||||
blacklist[n] = struct{}{}
|
||||
}
|
||||
var filtered []string
|
||||
for _, n := range active {
|
||||
if _, skip := blacklist[n]; !skip {
|
||||
filtered = append(filtered, n)
|
||||
}
|
||||
}
|
||||
active = filtered
|
||||
}
|
||||
return active
|
||||
}
|
||||
144
back/config/config.go
Normal file
144
back/config/config.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Tools map[string]yaml.Node `yaml:"tools" json:"tools"`
|
||||
Profiles map[string]Profile `yaml:"profiles" json:"profiles"`
|
||||
}
|
||||
|
||||
type Profile struct {
|
||||
Notes string `yaml:"notes,omitempty" json:"notes,omitempty"`
|
||||
Tools map[string]yaml.Node `yaml:"tools" json:"tools"`
|
||||
Enabled []string `yaml:"enabled" json:"enabled"`
|
||||
Disabled []string `yaml:"disabled" json:"disabled"`
|
||||
}
|
||||
|
||||
func (c *Config) DecodeEffective(toolName, profileName string, dst any) error {
|
||||
if node, ok := c.Tools[toolName]; ok {
|
||||
if err := node.Decode(dst); err != nil {
|
||||
return fmt.Errorf("config: decoding global config for tool %q: %w", toolName, err)
|
||||
}
|
||||
}
|
||||
|
||||
if profileName != "" {
|
||||
// Builtin profiles have their overrides defined in Go, not in YAML.
|
||||
if _, isBuiltin := BuiltinProfiles[profileName]; isBuiltin {
|
||||
return ApplyBuiltinToolOverride(profileName, toolName, dst)
|
||||
}
|
||||
p, ok := c.Profiles[profileName]
|
||||
if !ok {
|
||||
return fmt.Errorf("config: unknown profile %q", profileName)
|
||||
}
|
||||
if node, ok := p.Tools[toolName]; ok {
|
||||
if err := node.Decode(dst); err != nil {
|
||||
return fmt.Errorf("config: decoding profile %q override for tool %q: %w", profileName, toolName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Config) ActiveTools(profileName string, allToolNames []string) ([]string, error) {
|
||||
if profileName == "" {
|
||||
return allToolNames, nil
|
||||
}
|
||||
if builtin, ok := BuiltinProfiles[profileName]; ok {
|
||||
return ActiveToolsForProfile(builtin.Profile, allToolNames), nil
|
||||
}
|
||||
p, ok := c.Profiles[profileName]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("config: unknown profile %q", profileName)
|
||||
}
|
||||
return ActiveToolsForProfile(p, allToolNames), nil
|
||||
}
|
||||
|
||||
// IsReadonly reports whether the config file at path cannot be written to.
|
||||
// Returns false if the file does not exist (it can still be created).
|
||||
func IsReadonly(path string) bool {
|
||||
f, err := os.OpenFile(path, os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
return os.IsPermission(err)
|
||||
}
|
||||
f.Close()
|
||||
return false
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
f, err := os.Open(path)
|
||||
if os.IsNotExist(err) {
|
||||
log.Printf("config: %q not found, starting with empty config", path)
|
||||
return Default(), nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("config: open %q: %w", path, err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
var cfg Config
|
||||
dec := yaml.NewDecoder(f)
|
||||
dec.KnownFields(true)
|
||||
if err := dec.Decode(&cfg); err != nil {
|
||||
return nil, fmt.Errorf("config: decode: %w", err)
|
||||
}
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func Default() *Config {
|
||||
return &Config{
|
||||
Tools: make(map[string]yaml.Node),
|
||||
Profiles: make(map[string]Profile),
|
||||
}
|
||||
}
|
||||
|
||||
func Save(path string, cfg *Config) error {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("config: create %q: %w", path, err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
enc := yaml.NewEncoder(f)
|
||||
enc.SetIndent(2)
|
||||
if err := enc.Encode(cfg); err != nil {
|
||||
return fmt.Errorf("config: encode: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MergeNodePatch merges patch key-values into an existing yaml.Node (mapping).
|
||||
// If existing is a zero value, it starts from an empty mapping.
|
||||
func MergeNodePatch(existing yaml.Node, patch map[string]any) (yaml.Node, error) {
|
||||
var m map[string]any
|
||||
if existing.Kind != 0 {
|
||||
if err := existing.Decode(&m); err != nil {
|
||||
return yaml.Node{}, fmt.Errorf("config: decode node: %w", err)
|
||||
}
|
||||
}
|
||||
if m == nil {
|
||||
m = make(map[string]any)
|
||||
}
|
||||
for k, v := range patch {
|
||||
m[k] = v
|
||||
}
|
||||
|
||||
b, err := yaml.Marshal(m)
|
||||
if err != nil {
|
||||
return yaml.Node{}, fmt.Errorf("config: marshal: %w", err)
|
||||
}
|
||||
var doc yaml.Node
|
||||
if err := yaml.Unmarshal(b, &doc); err != nil {
|
||||
return yaml.Node{}, fmt.Errorf("config: unmarshal: %w", err)
|
||||
}
|
||||
if doc.Kind == yaml.DocumentNode && len(doc.Content) == 1 {
|
||||
return *doc.Content[0], nil
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
64
back/config/env/env.go
vendored
Normal file
64
back/config/env/env.go
vendored
Normal file
@@ -0,0 +1,64 @@
|
||||
package env
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Port int
|
||||
ConfigPath string
|
||||
FrontDir string // when set, serves the Astro static build at "/"
|
||||
SearchTTL time.Duration
|
||||
CleanupInterval time.Duration
|
||||
Demo bool // when true, disables searches and config mutations
|
||||
}
|
||||
|
||||
func Load() (*Config, error) {
|
||||
cfg := &Config{
|
||||
Port: 8080,
|
||||
ConfigPath: "/etc/iky/config.yaml",
|
||||
SearchTTL: 48 * time.Hour,
|
||||
CleanupInterval: time.Hour,
|
||||
}
|
||||
|
||||
if v := os.Getenv("IKY_PORT"); v != "" {
|
||||
p, err := strconv.Atoi(v)
|
||||
if err != nil || p < 1 || p > 65535 {
|
||||
return nil, fmt.Errorf("env: IKY_PORT %q is not a valid port number", v)
|
||||
}
|
||||
cfg.Port = p
|
||||
}
|
||||
|
||||
if v := os.Getenv("IKY_CONFIG"); v != "" {
|
||||
cfg.ConfigPath = v
|
||||
}
|
||||
|
||||
if v := os.Getenv("IKY_FRONT_DIR"); v != "" {
|
||||
cfg.FrontDir = v
|
||||
}
|
||||
|
||||
if v := os.Getenv("IKY_SEARCH_TTL"); v != "" {
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("env: IKY_SEARCH_TTL %q is not a valid duration", v)
|
||||
}
|
||||
cfg.SearchTTL = d
|
||||
}
|
||||
|
||||
if v := os.Getenv("IKY_CLEANUP_INTERVAL"); v != "" {
|
||||
d, err := time.ParseDuration(v)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("env: IKY_CLEANUP_INTERVAL %q is not a valid duration", v)
|
||||
}
|
||||
cfg.CleanupInterval = d
|
||||
}
|
||||
|
||||
if v := os.Getenv("IKY_DEMO"); v == "true" || v == "1" {
|
||||
cfg.Demo = true
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
20
back/go.mod
Normal file
20
back/go.mod
Normal file
@@ -0,0 +1,20 @@
|
||||
module github.com/anotherhadi/iknowyou
|
||||
|
||||
go 1.25.7
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.5
|
||||
github.com/google/uuid v1.6.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require github.com/creack/pty v1.1.24
|
||||
|
||||
require (
|
||||
github.com/projectdiscovery/wappalyzergo v0.2.75 // indirect
|
||||
github.com/tidwall/gjson v1.18.0 // indirect
|
||||
github.com/tidwall/match v1.1.1 // indirect
|
||||
github.com/tidwall/pretty v1.2.0 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
)
|
||||
22
back/go.sum
Normal file
22
back/go.sum
Normal file
@@ -0,0 +1,22 @@
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/projectdiscovery/wappalyzergo v0.2.75 h1:ScmpgoYuIzERh4lJpjWPPY89PUWbhUu6vFbCYAr0kWc=
|
||||
github.com/projectdiscovery/wappalyzergo v0.2.75/go.mod h1:hRsnKNleH693FFJsBOD5NMUDbxw/Q94f0Oq2OV04Q6M=
|
||||
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
|
||||
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
593
back/internal/api/handler/config.go
Normal file
593
back/internal/api/handler/config.go
Normal file
@@ -0,0 +1,593 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/anotherhadi/iknowyou/config"
|
||||
"github.com/anotherhadi/iknowyou/internal/respond"
|
||||
"github.com/anotherhadi/iknowyou/internal/tools"
|
||||
)
|
||||
|
||||
type ConfigHandler struct {
|
||||
configPath string
|
||||
factories []func() tools.ToolRunner
|
||||
demo bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewConfigHandler(configPath string, factories []func() tools.ToolRunner, demo bool) *ConfigHandler {
|
||||
return &ConfigHandler{configPath: configPath, factories: factories, demo: demo}
|
||||
}
|
||||
|
||||
// GET /api/config
|
||||
func (h *ConfigHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := config.Load(h.configPath)
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
toolConfigs := make(map[string]any, len(cfg.Tools))
|
||||
for toolName, node := range cfg.Tools {
|
||||
var m map[string]any
|
||||
if err := node.Decode(&m); err == nil {
|
||||
toolConfigs[toolName] = m
|
||||
}
|
||||
}
|
||||
respond.JSON(w, http.StatusOK, map[string]any{
|
||||
"tools": toolConfigs,
|
||||
"profiles": cfg.Profiles,
|
||||
"readonly": h.demo || config.IsReadonly(h.configPath),
|
||||
"demo": h.demo,
|
||||
})
|
||||
}
|
||||
|
||||
type profileSummary struct {
|
||||
Name string `json:"name"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Readonly bool `json:"readonly"`
|
||||
}
|
||||
|
||||
// GET /api/config/profiles
|
||||
func (h *ConfigHandler) ListProfiles(w http.ResponseWriter, r *http.Request) {
|
||||
cfg, err := config.Load(h.configPath)
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
builtinNames := make([]string, 0, len(config.BuiltinProfiles))
|
||||
for name := range config.BuiltinProfiles {
|
||||
builtinNames = append(builtinNames, name)
|
||||
}
|
||||
sort.Strings(builtinNames)
|
||||
summaries := make([]profileSummary, 0, len(builtinNames)+len(cfg.Profiles))
|
||||
for _, name := range builtinNames {
|
||||
summaries = append(summaries, profileSummary{Name: name, Notes: config.BuiltinProfiles[name].Notes, Readonly: true})
|
||||
}
|
||||
|
||||
names := make([]string, 0, len(cfg.Profiles))
|
||||
for name := range cfg.Profiles {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
p := cfg.Profiles[name]
|
||||
summaries = append(summaries, profileSummary{Name: name, Notes: p.Notes, Readonly: false})
|
||||
}
|
||||
|
||||
respond.JSON(w, http.StatusOK, summaries)
|
||||
}
|
||||
|
||||
type profileDetail struct {
|
||||
Name string `json:"name"`
|
||||
Notes string `json:"notes,omitempty"`
|
||||
Readonly bool `json:"readonly"`
|
||||
Enabled []string `json:"enabled"`
|
||||
Disabled []string `json:"disabled"`
|
||||
Tools map[string]any `json:"tools"`
|
||||
ActiveTools []string `json:"active_tools"`
|
||||
}
|
||||
|
||||
// GET /api/config/profiles/{name}
|
||||
func (h *ConfigHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
|
||||
name := chi.URLParam(r, "name")
|
||||
|
||||
allNames := make([]string, 0, len(h.factories))
|
||||
for _, factory := range h.factories {
|
||||
allNames = append(allNames, factory().Name())
|
||||
}
|
||||
|
||||
if builtin, ok := config.BuiltinProfiles[name]; ok {
|
||||
activeTools := config.ActiveToolsForProfile(builtin.Profile, allNames)
|
||||
if activeTools == nil {
|
||||
activeTools = allNames
|
||||
}
|
||||
enabled := builtin.Profile.Enabled
|
||||
if enabled == nil {
|
||||
enabled = []string{}
|
||||
}
|
||||
disabled := builtin.Profile.Disabled
|
||||
if disabled == nil {
|
||||
disabled = []string{}
|
||||
}
|
||||
respond.JSON(w, http.StatusOK, profileDetail{
|
||||
Name: name,
|
||||
Notes: builtin.Notes,
|
||||
Readonly: true,
|
||||
Enabled: enabled,
|
||||
Disabled: disabled,
|
||||
Tools: map[string]any{},
|
||||
ActiveTools: activeTools,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := config.Load(h.configPath)
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
p, ok := cfg.Profiles[name]
|
||||
if !ok {
|
||||
respond.Error(w, http.StatusNotFound, "profile not found")
|
||||
return
|
||||
}
|
||||
|
||||
toolOverrides := make(map[string]any, len(p.Tools))
|
||||
for toolName, node := range p.Tools {
|
||||
var m map[string]any
|
||||
if err := node.Decode(&m); err == nil {
|
||||
toolOverrides[toolName] = m
|
||||
}
|
||||
}
|
||||
|
||||
activeTools, _ := cfg.ActiveTools(name, allNames)
|
||||
|
||||
enabled := p.Enabled
|
||||
if enabled == nil {
|
||||
enabled = []string{}
|
||||
}
|
||||
disabled := p.Disabled
|
||||
if disabled == nil {
|
||||
disabled = []string{}
|
||||
}
|
||||
|
||||
respond.JSON(w, http.StatusOK, profileDetail{
|
||||
Name: name,
|
||||
Notes: p.Notes,
|
||||
Readonly: false,
|
||||
Enabled: enabled,
|
||||
Disabled: disabled,
|
||||
Tools: toolOverrides,
|
||||
ActiveTools: activeTools,
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/config/profiles
|
||||
func (h *ConfigHandler) CreateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
if h.demo {
|
||||
respond.Error(w, http.StatusForbidden, "demo mode: modifications are disabled")
|
||||
return
|
||||
}
|
||||
if config.IsReadonly(h.configPath) {
|
||||
respond.Error(w, http.StatusForbidden, "config is read-only")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Notes string `json:"notes"`
|
||||
Enabled []string `json:"enabled"`
|
||||
Disabled []string `json:"disabled"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respond.Error(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
if req.Name == "" {
|
||||
respond.Error(w, http.StatusBadRequest, "name is required")
|
||||
return
|
||||
}
|
||||
if err := validateProfileName(req.Name); err != nil {
|
||||
respond.Error(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
if _, isBuiltin := config.BuiltinProfiles[req.Name]; isBuiltin {
|
||||
respond.Error(w, http.StatusForbidden, fmt.Sprintf("profile %q is reserved", req.Name))
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
cfg, err := config.Load(h.configPath)
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if _, exists := cfg.Profiles[req.Name]; exists {
|
||||
respond.Error(w, http.StatusConflict, "profile already exists")
|
||||
return
|
||||
}
|
||||
|
||||
cfg.Profiles[req.Name] = config.Profile{
|
||||
Notes: req.Notes,
|
||||
Enabled: req.Enabled,
|
||||
Disabled: req.Disabled,
|
||||
}
|
||||
if err := config.Save(h.configPath, cfg); err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
respond.JSON(w, http.StatusCreated, cfg.Profiles[req.Name])
|
||||
}
|
||||
|
||||
// PATCH /api/config/profiles/{name}
|
||||
func (h *ConfigHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
if h.demo {
|
||||
respond.Error(w, http.StatusForbidden, "demo mode: modifications are disabled")
|
||||
return
|
||||
}
|
||||
if config.IsReadonly(h.configPath) {
|
||||
respond.Error(w, http.StatusForbidden, "config is read-only")
|
||||
return
|
||||
}
|
||||
name := chi.URLParam(r, "name")
|
||||
if _, isBuiltin := config.BuiltinProfiles[name]; isBuiltin {
|
||||
respond.Error(w, http.StatusForbidden, fmt.Sprintf("profile %q is read-only", name))
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Notes *string `json:"notes"`
|
||||
Enabled *[]string `json:"enabled"`
|
||||
Disabled *[]string `json:"disabled"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respond.Error(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
cfg, err := config.Load(h.configPath)
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
p, ok := cfg.Profiles[name]
|
||||
if !ok {
|
||||
respond.Error(w, http.StatusNotFound, "profile not found")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Notes != nil {
|
||||
p.Notes = *req.Notes
|
||||
}
|
||||
if req.Enabled != nil {
|
||||
p.Enabled = *req.Enabled
|
||||
}
|
||||
if req.Disabled != nil {
|
||||
p.Disabled = *req.Disabled
|
||||
}
|
||||
cfg.Profiles[name] = p
|
||||
|
||||
if err := config.Save(h.configPath, cfg); err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
respond.JSON(w, http.StatusOK, p)
|
||||
}
|
||||
|
||||
// DELETE /api/config/profiles/{name}
|
||||
func (h *ConfigHandler) DeleteProfile(w http.ResponseWriter, r *http.Request) {
|
||||
if h.demo {
|
||||
respond.Error(w, http.StatusForbidden, "demo mode: modifications are disabled")
|
||||
return
|
||||
}
|
||||
if config.IsReadonly(h.configPath) {
|
||||
respond.Error(w, http.StatusForbidden, "config is read-only")
|
||||
return
|
||||
}
|
||||
name := chi.URLParam(r, "name")
|
||||
if _, isBuiltin := config.BuiltinProfiles[name]; isBuiltin {
|
||||
respond.Error(w, http.StatusForbidden, fmt.Sprintf("profile %q is read-only", name))
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
cfg, err := config.Load(h.configPath)
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := cfg.Profiles[name]; !ok {
|
||||
respond.Error(w, http.StatusNotFound, "profile not found")
|
||||
return
|
||||
}
|
||||
delete(cfg.Profiles, name)
|
||||
|
||||
if err := config.Save(h.configPath, cfg); err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// PATCH /api/config/tools/{toolName}
|
||||
func (h *ConfigHandler) UpdateToolConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if h.demo {
|
||||
respond.Error(w, http.StatusForbidden, "demo mode: modifications are disabled")
|
||||
return
|
||||
}
|
||||
if config.IsReadonly(h.configPath) {
|
||||
respond.Error(w, http.StatusForbidden, "config is read-only")
|
||||
return
|
||||
}
|
||||
toolName := chi.URLParam(r, "toolName")
|
||||
|
||||
var patch map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
|
||||
respond.Error(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.validatePatch(toolName, patch); err != nil {
|
||||
respond.Error(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
cfg, err := config.Load(h.configPath)
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
merged, err := config.MergeNodePatch(cfg.Tools[toolName], patch)
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
cfg.Tools[toolName] = merged
|
||||
|
||||
if err := config.Save(h.configPath, cfg); err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
_ = merged.Decode(&result)
|
||||
respond.JSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// DELETE /api/config/tools/{toolName}
|
||||
func (h *ConfigHandler) DeleteToolConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if h.demo {
|
||||
respond.Error(w, http.StatusForbidden, "demo mode: modifications are disabled")
|
||||
return
|
||||
}
|
||||
if config.IsReadonly(h.configPath) {
|
||||
respond.Error(w, http.StatusForbidden, "config is read-only")
|
||||
return
|
||||
}
|
||||
toolName := chi.URLParam(r, "toolName")
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
cfg, err := config.Load(h.configPath)
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := cfg.Tools[toolName]; !ok {
|
||||
respond.Error(w, http.StatusNotFound, "no global config for this tool")
|
||||
return
|
||||
}
|
||||
delete(cfg.Tools, toolName)
|
||||
|
||||
if err := config.Save(h.configPath, cfg); err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// PATCH /api/config/profiles/{name}/tools/{toolName}
|
||||
func (h *ConfigHandler) UpdateProfileToolConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if h.demo {
|
||||
respond.Error(w, http.StatusForbidden, "demo mode: modifications are disabled")
|
||||
return
|
||||
}
|
||||
if config.IsReadonly(h.configPath) {
|
||||
respond.Error(w, http.StatusForbidden, "config is read-only")
|
||||
return
|
||||
}
|
||||
name := chi.URLParam(r, "name")
|
||||
toolName := chi.URLParam(r, "toolName")
|
||||
|
||||
if _, isBuiltin := config.BuiltinProfiles[name]; isBuiltin {
|
||||
respond.Error(w, http.StatusForbidden, fmt.Sprintf("profile %q is read-only", name))
|
||||
return
|
||||
}
|
||||
|
||||
var patch map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
|
||||
respond.Error(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := h.validatePatch(toolName, patch); err != nil {
|
||||
respond.Error(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
cfg, err := config.Load(h.configPath)
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
p, ok := cfg.Profiles[name]
|
||||
if !ok {
|
||||
respond.Error(w, http.StatusNotFound, "profile not found")
|
||||
return
|
||||
}
|
||||
if p.Tools == nil {
|
||||
p.Tools = make(map[string]yaml.Node)
|
||||
}
|
||||
|
||||
merged, err := config.MergeNodePatch(p.Tools[toolName], patch)
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
p.Tools[toolName] = merged
|
||||
cfg.Profiles[name] = p
|
||||
|
||||
if err := config.Save(h.configPath, cfg); err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var result map[string]any
|
||||
_ = merged.Decode(&result)
|
||||
respond.JSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// DELETE /api/config/profiles/{name}/tools/{toolName}
|
||||
func (h *ConfigHandler) DeleteProfileToolConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if h.demo {
|
||||
respond.Error(w, http.StatusForbidden, "demo mode: modifications are disabled")
|
||||
return
|
||||
}
|
||||
if config.IsReadonly(h.configPath) {
|
||||
respond.Error(w, http.StatusForbidden, "config is read-only")
|
||||
return
|
||||
}
|
||||
name := chi.URLParam(r, "name")
|
||||
toolName := chi.URLParam(r, "toolName")
|
||||
|
||||
if _, isBuiltin := config.BuiltinProfiles[name]; isBuiltin {
|
||||
respond.Error(w, http.StatusForbidden, fmt.Sprintf("profile %q is read-only", name))
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
cfg, err := config.Load(h.configPath)
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
p, ok := cfg.Profiles[name]
|
||||
if !ok {
|
||||
respond.Error(w, http.StatusNotFound, "profile not found")
|
||||
return
|
||||
}
|
||||
if _, ok := p.Tools[toolName]; !ok {
|
||||
respond.Error(w, http.StatusNotFound, "no config override for this tool in profile")
|
||||
return
|
||||
}
|
||||
delete(p.Tools, toolName)
|
||||
cfg.Profiles[name] = p
|
||||
|
||||
if err := config.Save(h.configPath, cfg); err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func validateProfileName(name string) error {
|
||||
for _, c := range name {
|
||||
if !((c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '-') {
|
||||
return fmt.Errorf("profile name must contain only lowercase letters (a-z), digits (0-9), and hyphens (-)")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *ConfigHandler) validatePatch(toolName string, patch map[string]any) error {
|
||||
var fields []tools.ConfigField
|
||||
for _, factory := range h.factories {
|
||||
t := factory()
|
||||
if t.Name() == toolName {
|
||||
if d, ok := t.(tools.ConfigDescriber); ok {
|
||||
fields = d.ConfigFields()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
fieldMap := make(map[string]tools.ConfigField, len(fields))
|
||||
for _, f := range fields {
|
||||
fieldMap[f.Name] = f
|
||||
}
|
||||
for key, val := range patch {
|
||||
f, ok := fieldMap[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := validateFieldValue(f, val); err != nil {
|
||||
return fmt.Errorf("field %q: %w", key, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateFieldValue(f tools.ConfigField, val any) error {
|
||||
if val == nil {
|
||||
return nil
|
||||
}
|
||||
switch f.Type {
|
||||
case "string":
|
||||
if _, ok := val.(string); !ok {
|
||||
return fmt.Errorf("expected string, got %T", val)
|
||||
}
|
||||
case "bool":
|
||||
if _, ok := val.(bool); !ok {
|
||||
return fmt.Errorf("expected bool, got %T", val)
|
||||
}
|
||||
case "int":
|
||||
switch v := val.(type) {
|
||||
case float64:
|
||||
if v != float64(int64(v)) {
|
||||
return fmt.Errorf("expected integer, got float")
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("expected int, got %T", val)
|
||||
}
|
||||
case "float":
|
||||
if _, ok := val.(float64); !ok {
|
||||
return fmt.Errorf("expected number, got %T", val)
|
||||
}
|
||||
case "enum":
|
||||
s, ok := val.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("expected string, got %T", val)
|
||||
}
|
||||
for _, opt := range f.Options {
|
||||
if s == opt {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("invalid value %q, must be one of: %v", s, f.Options)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
146
back/internal/api/handler/search.go
Normal file
146
back/internal/api/handler/search.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/anotherhadi/iknowyou/internal/respond"
|
||||
"github.com/anotherhadi/iknowyou/internal/search"
|
||||
"github.com/anotherhadi/iknowyou/internal/tools"
|
||||
)
|
||||
|
||||
type SearchHandler struct {
|
||||
manager *search.Manager
|
||||
demo bool
|
||||
}
|
||||
|
||||
func NewSearchHandler(manager *search.Manager, demo bool) *SearchHandler {
|
||||
return &SearchHandler{manager: manager, demo: demo}
|
||||
}
|
||||
|
||||
type postSearchRequest struct {
|
||||
Target string `json:"target"`
|
||||
InputType tools.InputType `json:"input_type"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
}
|
||||
|
||||
type searchSummary struct {
|
||||
ID string `json:"id"`
|
||||
Target string `json:"target"`
|
||||
InputType tools.InputType `json:"input_type"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
Status search.Status `json:"status"`
|
||||
StartedAt string `json:"started_at"`
|
||||
PlannedTools []search.ToolStatus `json:"planned_tools"`
|
||||
}
|
||||
|
||||
type searchDetail struct {
|
||||
searchSummary
|
||||
Events []tools.Event `json:"events"`
|
||||
}
|
||||
|
||||
func toSummary(s *search.Search) searchSummary {
|
||||
planned := s.PlannedTools
|
||||
if planned == nil {
|
||||
planned = []search.ToolStatus{}
|
||||
}
|
||||
return searchSummary{
|
||||
ID: s.ID,
|
||||
Target: s.Target,
|
||||
InputType: s.InputType,
|
||||
Profile: s.Profile,
|
||||
Status: s.Status(),
|
||||
StartedAt: s.StartedAt.UTC().Format("2006-01-02T15:04:05Z"),
|
||||
PlannedTools: planned,
|
||||
}
|
||||
}
|
||||
|
||||
var validInputTypes = map[tools.InputType]struct{}{
|
||||
tools.InputTypeEmail: {},
|
||||
tools.InputTypeUsername: {},
|
||||
tools.InputTypePhone: {},
|
||||
tools.InputTypeIP: {},
|
||||
tools.InputTypeDomain: {},
|
||||
tools.InputTypePassword: {},
|
||||
tools.InputTypeName: {},
|
||||
}
|
||||
|
||||
// POST /searches
|
||||
func (h *SearchHandler) Create(w http.ResponseWriter, r *http.Request) {
|
||||
if h.demo {
|
||||
respond.Error(w, http.StatusForbidden, "demo mode: searches are disabled")
|
||||
return
|
||||
}
|
||||
var req postSearchRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
respond.Error(w, http.StatusBadRequest, "invalid JSON body")
|
||||
return
|
||||
}
|
||||
if req.Target == "" {
|
||||
respond.Error(w, http.StatusBadRequest, "target is required")
|
||||
return
|
||||
}
|
||||
if len(req.Target) > 500 {
|
||||
respond.Error(w, http.StatusBadRequest, "target is too long (max 500 characters)")
|
||||
return
|
||||
}
|
||||
if req.Target[0] == '-' || req.Target[0] == '@' {
|
||||
respond.Error(w, http.StatusBadRequest, "invalid target")
|
||||
return
|
||||
}
|
||||
if req.InputType == "" {
|
||||
respond.Error(w, http.StatusBadRequest, "input_type is required")
|
||||
return
|
||||
}
|
||||
if _, ok := validInputTypes[req.InputType]; !ok {
|
||||
respond.Error(w, http.StatusBadRequest, "invalid input_type")
|
||||
return
|
||||
}
|
||||
|
||||
s, err := h.manager.Start(context.WithoutCancel(r.Context()), req.Target, req.InputType, req.Profile)
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
respond.JSON(w, http.StatusCreated, toSummary(s))
|
||||
}
|
||||
|
||||
// GET /searches
|
||||
func (h *SearchHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
all := h.manager.All()
|
||||
summaries := make([]searchSummary, len(all))
|
||||
for i, s := range all {
|
||||
summaries[i] = toSummary(s)
|
||||
}
|
||||
respond.JSON(w, http.StatusOK, summaries)
|
||||
}
|
||||
|
||||
// GET /searches/{id}
|
||||
func (h *SearchHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
s, err := h.manager.Get(id)
|
||||
if err != nil {
|
||||
respond.Error(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
detail := searchDetail{
|
||||
searchSummary: toSummary(s),
|
||||
Events: s.Events(),
|
||||
}
|
||||
respond.JSON(w, http.StatusOK, detail)
|
||||
}
|
||||
|
||||
// DELETE /searches/{id}
|
||||
func (h *SearchHandler) Delete(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if err := h.manager.Delete(id); err != nil {
|
||||
respond.Error(w, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
91
back/internal/api/handler/tools.go
Normal file
91
back/internal/api/handler/tools.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"github.com/anotherhadi/iknowyou/internal/respond"
|
||||
"github.com/anotherhadi/iknowyou/internal/tools"
|
||||
)
|
||||
|
||||
type ToolsHandler struct {
|
||||
factories []func() tools.ToolRunner
|
||||
}
|
||||
|
||||
func NewToolsHandler(factories []func() tools.ToolRunner) *ToolsHandler {
|
||||
return &ToolsHandler{factories: factories}
|
||||
}
|
||||
|
||||
type toolInfo struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Link string `json:"link,omitempty"`
|
||||
Icon string `json:"icon,omitempty"`
|
||||
InputTypes []tools.InputType `json:"input_types"`
|
||||
Configurable bool `json:"configurable"`
|
||||
ConfigFields []tools.ConfigField `json:"config_fields,omitempty"`
|
||||
Available *bool `json:"available,omitempty"`
|
||||
UnavailableReason string `json:"unavailable_reason,omitempty"`
|
||||
Dependencies []string `json:"dependencies,omitempty"`
|
||||
}
|
||||
|
||||
func toToolInfo(t tools.ToolRunner) toolInfo {
|
||||
_, configurable := t.(tools.Configurable)
|
||||
|
||||
var fields []tools.ConfigField
|
||||
if d, ok := t.(tools.ConfigDescriber); ok {
|
||||
fields = d.ConfigFields()
|
||||
}
|
||||
|
||||
var available *bool
|
||||
var unavailableReason string
|
||||
if checker, ok := t.(tools.AvailabilityChecker); ok {
|
||||
avail, reason := checker.Available()
|
||||
available = &avail
|
||||
if !avail {
|
||||
unavailableReason = reason
|
||||
}
|
||||
}
|
||||
|
||||
var dependencies []string
|
||||
if lister, ok := t.(tools.DependencyLister); ok {
|
||||
dependencies = lister.Dependencies()
|
||||
}
|
||||
|
||||
return toolInfo{
|
||||
Name: t.Name(),
|
||||
Description: t.Description(),
|
||||
Link: t.Link(),
|
||||
Icon: t.Icon(),
|
||||
InputTypes: t.InputTypes(),
|
||||
Configurable: configurable,
|
||||
ConfigFields: fields,
|
||||
Available: available,
|
||||
UnavailableReason: unavailableReason,
|
||||
Dependencies: dependencies,
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/tools
|
||||
func (h *ToolsHandler) List(w http.ResponseWriter, r *http.Request) {
|
||||
infos := make([]toolInfo, 0, len(h.factories))
|
||||
for _, factory := range h.factories {
|
||||
infos = append(infos, toToolInfo(factory()))
|
||||
}
|
||||
respond.JSON(w, http.StatusOK, infos)
|
||||
}
|
||||
|
||||
// GET /api/tools/{name}
|
||||
func (h *ToolsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
name := chi.URLParam(r, "name")
|
||||
for _, factory := range h.factories {
|
||||
t := factory()
|
||||
if t.Name() == name {
|
||||
respond.JSON(w, http.StatusOK, toToolInfo(t))
|
||||
return
|
||||
}
|
||||
}
|
||||
respond.Error(w, http.StatusNotFound, fmt.Sprintf("tool %q not found", name))
|
||||
}
|
||||
72
back/internal/api/middleware/ratelimit.go
Normal file
72
back/internal/api/middleware/ratelimit.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
type ipLimiter struct {
|
||||
limiter *rate.Limiter
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
type Limiter struct {
|
||||
mu sync.Mutex
|
||||
visitors map[string]*ipLimiter
|
||||
r rate.Limit
|
||||
burst int
|
||||
}
|
||||
|
||||
func New(r rate.Limit, burst int) *Limiter {
|
||||
l := &Limiter{
|
||||
visitors: make(map[string]*ipLimiter),
|
||||
r: r,
|
||||
burst: burst,
|
||||
}
|
||||
go l.cleanupLoop()
|
||||
return l
|
||||
}
|
||||
|
||||
func (l *Limiter) getLimiter(ip string) *rate.Limiter {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
v, exists := l.visitors[ip]
|
||||
if !exists {
|
||||
v = &ipLimiter{limiter: rate.NewLimiter(l.r, l.burst)}
|
||||
l.visitors[ip] = v
|
||||
}
|
||||
v.lastSeen = time.Now()
|
||||
return v.limiter
|
||||
}
|
||||
|
||||
func (l *Limiter) cleanupLoop() {
|
||||
ticker := time.NewTicker(10 * time.Minute)
|
||||
defer ticker.Stop()
|
||||
for range ticker.C {
|
||||
l.mu.Lock()
|
||||
for ip, v := range l.visitors {
|
||||
if time.Since(v.lastSeen) > 10*time.Minute {
|
||||
delete(l.visitors, ip)
|
||||
}
|
||||
}
|
||||
l.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Limiter) Handler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ip, _, err := net.SplitHostPort(r.RemoteAddr)
|
||||
if err != nil {
|
||||
ip = r.RemoteAddr
|
||||
}
|
||||
if !l.getLimiter(ip).Allow() {
|
||||
http.Error(w, `{"error":"rate limit exceeded, please slow down"}`, http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
73
back/internal/api/router.go
Normal file
73
back/internal/api/router.go
Normal file
@@ -0,0 +1,73 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||
"golang.org/x/time/rate"
|
||||
|
||||
"github.com/anotherhadi/iknowyou/internal/api/handler"
|
||||
ikymiddleware "github.com/anotherhadi/iknowyou/internal/api/middleware"
|
||||
"github.com/anotherhadi/iknowyou/internal/search"
|
||||
"github.com/anotherhadi/iknowyou/internal/tools"
|
||||
)
|
||||
|
||||
func NewRouter(
|
||||
manager *search.Manager,
|
||||
factories []func() tools.ToolRunner,
|
||||
configPath string,
|
||||
frontDir string,
|
||||
demo bool,
|
||||
) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
|
||||
r.Use(chimiddleware.Logger)
|
||||
r.Use(chimiddleware.Recoverer)
|
||||
r.Use(chimiddleware.RequestID)
|
||||
|
||||
searchHandler := handler.NewSearchHandler(manager, demo)
|
||||
toolsHandler := handler.NewToolsHandler(factories)
|
||||
configHandler := handler.NewConfigHandler(configPath, factories, demo)
|
||||
|
||||
searchLimiter := ikymiddleware.New(rate.Every(10*time.Second), 3)
|
||||
|
||||
r.Route("/api", func(r chi.Router) {
|
||||
r.Route("/searches", func(r chi.Router) {
|
||||
r.With(searchLimiter.Handler).Post("/", searchHandler.Create)
|
||||
r.Get("/", searchHandler.List)
|
||||
r.Get("/{id}", searchHandler.Get)
|
||||
r.Delete("/{id}", searchHandler.Delete)
|
||||
})
|
||||
|
||||
r.Route("/tools", func(r chi.Router) {
|
||||
r.Get("/", toolsHandler.List)
|
||||
r.Get("/{name}", toolsHandler.Get)
|
||||
})
|
||||
|
||||
r.Route("/config", func(r chi.Router) {
|
||||
r.Get("/", configHandler.Get)
|
||||
|
||||
r.Route("/tools", func(r chi.Router) {
|
||||
r.Patch("/{toolName}", configHandler.UpdateToolConfig)
|
||||
r.Delete("/{toolName}", configHandler.DeleteToolConfig)
|
||||
})
|
||||
|
||||
r.Route("/profiles", func(r chi.Router) {
|
||||
r.Get("/", configHandler.ListProfiles)
|
||||
r.Post("/", configHandler.CreateProfile)
|
||||
r.Get("/{name}", configHandler.GetProfile)
|
||||
r.Patch("/{name}", configHandler.UpdateProfile)
|
||||
r.Delete("/{name}", configHandler.DeleteProfile)
|
||||
r.Patch("/{name}/tools/{toolName}", configHandler.UpdateProfileToolConfig)
|
||||
r.Delete("/{name}/tools/{toolName}", configHandler.DeleteProfileToolConfig)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
if frontDir != "" {
|
||||
r.Handle("/*", newStaticHandler(frontDir))
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
56
back/internal/api/static.go
Normal file
56
back/internal/api/static.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// newStaticHandler serves the Astro static build with SPA fallbacks:
|
||||
// /search/<id> and /tools/<name> → their respective shell pages.
|
||||
func newStaticHandler(dir string) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
urlPath := r.URL.Path
|
||||
|
||||
if strings.HasPrefix(urlPath, "/search/") && len(urlPath) > len("/search/") {
|
||||
http.ServeFile(w, r, filepath.Join(dir, "search", "_", "index.html"))
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(urlPath, "/tools/") {
|
||||
rest := strings.TrimPrefix(urlPath, "/tools/")
|
||||
if rest != "" && !strings.Contains(rest, "/") {
|
||||
http.ServeFile(w, r, filepath.Join(dir, "tools", "_", "index.html"))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
rel := filepath.FromSlash(strings.TrimPrefix(urlPath, "/"))
|
||||
full := filepath.Join(dir, rel)
|
||||
|
||||
info, err := os.Stat(full)
|
||||
if err == nil && info.IsDir() {
|
||||
full = filepath.Join(full, "index.html")
|
||||
if _, err2 := os.Stat(full); err2 != nil {
|
||||
serve404(w, r, dir)
|
||||
return
|
||||
}
|
||||
} else if err != nil {
|
||||
serve404(w, r, dir)
|
||||
return
|
||||
}
|
||||
|
||||
http.ServeFile(w, r, full)
|
||||
})
|
||||
}
|
||||
|
||||
func serve404(w http.ResponseWriter, r *http.Request, dir string) {
|
||||
p := filepath.Join(dir, "404.html")
|
||||
if _, err := os.Stat(p); err == nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
http.ServeFile(w, r, p)
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
32
back/internal/registry/registry.go
Normal file
32
back/internal/registry/registry.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"github.com/anotherhadi/iknowyou/internal/tools"
|
||||
breachdirectory "github.com/anotherhadi/iknowyou/internal/tools/breachdirectory"
|
||||
crtsh "github.com/anotherhadi/iknowyou/internal/tools/crtsh"
|
||||
digtool "github.com/anotherhadi/iknowyou/internal/tools/dig"
|
||||
githubrecon "github.com/anotherhadi/iknowyou/internal/tools/github-recon"
|
||||
gravatarrecon "github.com/anotherhadi/iknowyou/internal/tools/gravatar-recon"
|
||||
ipinfotool "github.com/anotherhadi/iknowyou/internal/tools/ipinfo"
|
||||
leakcheck "github.com/anotherhadi/iknowyou/internal/tools/leakcheck"
|
||||
maigret "github.com/anotherhadi/iknowyou/internal/tools/maigret"
|
||||
userscanner "github.com/anotherhadi/iknowyou/internal/tools/user-scanner"
|
||||
wappalyzer "github.com/anotherhadi/iknowyou/internal/tools/wappalyzer"
|
||||
whoistool "github.com/anotherhadi/iknowyou/internal/tools/whois"
|
||||
whoisfreaks "github.com/anotherhadi/iknowyou/internal/tools/whoisfreaks"
|
||||
)
|
||||
|
||||
var Factories = []func() tools.ToolRunner{
|
||||
userscanner.New,
|
||||
githubrecon.New,
|
||||
whoistool.New,
|
||||
digtool.New,
|
||||
ipinfotool.New,
|
||||
gravatarrecon.New,
|
||||
whoisfreaks.New,
|
||||
maigret.New,
|
||||
leakcheck.New,
|
||||
crtsh.New,
|
||||
breachdirectory.New,
|
||||
wappalyzer.New,
|
||||
}
|
||||
18
back/internal/respond/respond.go
Normal file
18
back/internal/respond/respond.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package respond
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// JSON writes a JSON body with the given status code.
|
||||
func JSON(w http.ResponseWriter, status int, payload any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(payload)
|
||||
}
|
||||
|
||||
// Error writes a JSON error body: {"error": "message"}.
|
||||
func Error(w http.ResponseWriter, status int, msg string) {
|
||||
JSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
114
back/internal/search/demo.go
Normal file
114
back/internal/search/demo.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/anotherhadi/iknowyou/internal/tools"
|
||||
)
|
||||
|
||||
func ptr(n int) *int { return &n }
|
||||
|
||||
func (m *Manager) InjectDemoSearches() {
|
||||
now := time.Now()
|
||||
|
||||
_, cancel1 := context.WithCancel(context.Background())
|
||||
s1 := &Search{
|
||||
ID: uuid.NewString(),
|
||||
Target: "john.doe@example.com",
|
||||
InputType: tools.InputTypeEmail,
|
||||
Profile: "default",
|
||||
StartedAt: now.Add(-2 * time.Hour),
|
||||
PlannedTools: []ToolStatus{
|
||||
{Name: "user-scanner", ResultCount: ptr(10)},
|
||||
{Name: "github-recon", ResultCount: ptr(3)},
|
||||
},
|
||||
cancelFn: cancel1,
|
||||
status: StatusDone,
|
||||
finishedAt: now.Add(-2*time.Hour + 18*time.Second),
|
||||
}
|
||||
s1.events = []tools.Event{
|
||||
{Tool: "user-scanner", Type: tools.EventTypeOutput, Payload: "\x1b[35m== ADULT SITES ==\x1b[0m\n" +
|
||||
"\x1b[0m \x1b[32m[✔] Xvideos (john.doe@example.com): Registered\x1b[0m\n" +
|
||||
"\x1b[0m \x1b[32m[✔] Pornhub (john.doe@example.com): Registered\x1b[0m\n" +
|
||||
"\x1b[0m\n" +
|
||||
"\x1b[35m== CREATOR SITES ==\x1b[0m\n" +
|
||||
"\x1b[0m \x1b[32m[✔] Adobe (john.doe@example.com): Registered\x1b[0m\n" +
|
||||
"\x1b[0m\n" +
|
||||
"\x1b[35m== MUSIC SITES ==\x1b[0m\n" +
|
||||
"\x1b[0m \x1b[32m[✔] Spotify (john.doe@example.com): Registered\x1b[0m\n" +
|
||||
"\x1b[0m\n" +
|
||||
"\x1b[35m== LEARNING SITES ==\x1b[0m\n" +
|
||||
"\x1b[0m \x1b[32m[✔] Duolingo (john.doe@example.com): Registered\x1b[0m\n" +
|
||||
"\x1b[0m \x1b[32m[✔] Vedantu (john.doe@example.com): Registered\n \x1b[36m└── Phone: +9112****07\x1b[0m\n" +
|
||||
"\x1b[0m\n" +
|
||||
"\x1b[35m== SOCIAL SITES ==\x1b[0m\n" +
|
||||
"\x1b[0m \x1b[32m[✔] Pinterest (john.doe@example.com): Registered\x1b[0m\n" +
|
||||
"\x1b[0m \x1b[32m[✔] Facebook (john.doe@example.com): Registered\x1b[0m\n" +
|
||||
"\x1b[0m\n" +
|
||||
"\x1b[35m== GAMING SITES ==\x1b[0m\n" +
|
||||
"\x1b[0m \x1b[32m[✔] Chess.com (john.doe@example.com): Registered\x1b[0m\n" +
|
||||
"\x1b[0m\n" +
|
||||
"\x1b[35m== SHOPPING SITES ==\x1b[0m\n" +
|
||||
"\x1b[0m \x1b[32m[✔] Amazon (john.doe@example.com): Registered\x1b[0m\n"},
|
||||
{Tool: "user-scanner", Type: tools.EventTypeDone},
|
||||
{Tool: "github-recon", Type: tools.EventTypeOutput, Payload: "\x1b[1;38;2;113;135;253m👤 Commits author\x1b[0m\n\n" +
|
||||
" \x1b[38;2;125;125;125mName:\x1b[0m \x1b[38;2;166;227;161m\"fastHack2025\"\x1b[0m\n" +
|
||||
" \x1b[38;2;125;125;125mEmail:\x1b[0m \x1b[38;2;166;227;161m\"john.doe@example.com\"\x1b[0m\n" +
|
||||
" \x1b[38;2;125;125;125mUsername:\x1b[0m \x1b[38;2;166;227;161m\"Unknown\"\x1b[0m\n" +
|
||||
" \x1b[38;2;125;125;125mOccurrences:\x1b[0m \x1b[38;2;166;227;161m36\x1b[0m\n\n" +
|
||||
" \x1b[38;2;125;125;125mName:\x1b[0m \x1b[38;2;166;227;161m\"Anthony\"\x1b[0m\n" +
|
||||
" \x1b[38;2;125;125;125mEmail:\x1b[0m \x1b[38;2;166;227;161m\"john.doe@example.com\"\x1b[0m\n" +
|
||||
" \x1b[38;2;125;125;125mUsername:\x1b[0m \x1b[38;2;166;227;161m\"Unknown\"\x1b[0m\n" +
|
||||
" \x1b[38;2;125;125;125mOccurrences:\x1b[0m \x1b[38;2;166;227;161m52\x1b[0m\n\n" +
|
||||
" \x1b[38;2;125;125;125mName:\x1b[0m \x1b[38;2;166;227;161m\"Gill\"\x1b[0m\n" +
|
||||
" \x1b[38;2;125;125;125mEmail:\x1b[0m \x1b[38;2;166;227;161m\"john.doe@example.com\"\x1b[0m\n" +
|
||||
" \x1b[38;2;125;125;125mUsername:\x1b[0m \x1b[38;2;166;227;161m\"johndoe\"\x1b[0m\n" +
|
||||
" \x1b[38;2;125;125;125mOccurrences:\x1b[0m \x1b[38;2;166;227;161m60\x1b[0m"},
|
||||
{Tool: "github-recon", Type: tools.EventTypeDone},
|
||||
}
|
||||
|
||||
_, cancel2 := context.WithCancel(context.Background())
|
||||
s2 := &Search{
|
||||
ID: uuid.NewString(),
|
||||
Target: "janedoe",
|
||||
InputType: tools.InputTypeUsername,
|
||||
Profile: "default",
|
||||
StartedAt: now.Add(-30 * time.Minute),
|
||||
PlannedTools: []ToolStatus{
|
||||
{Name: "user-scanner", ResultCount: ptr(10)},
|
||||
{Name: "github-recon", ResultCount: ptr(0)},
|
||||
},
|
||||
cancelFn: cancel2,
|
||||
status: StatusDone,
|
||||
finishedAt: now.Add(-30*time.Minute + 22*time.Second),
|
||||
}
|
||||
s2.events = []tools.Event{
|
||||
{Tool: "user-scanner", Type: tools.EventTypeOutput, Payload: "\x1b[35m== SOCIAL SITES ==\x1b[0m\n" +
|
||||
"\x1b[0m \x1b[32m[✔] Reddit (janedoe): Found\x1b[0m\n" +
|
||||
"\x1b[0m \x1b[32m[✔] Threads (janedoe): Found\x1b[0m\n" +
|
||||
"\x1b[0m \x1b[32m[✔] X (twitter) (janedoe): Found\x1b[0m\n" +
|
||||
"\x1b[0m \x1b[32m[✔] Youtube (janedoe): Found\x1b[0m\n" +
|
||||
"\x1b[0m \x1b[32m[✔] Telegram (janedoe): Found\x1b[0m\n" +
|
||||
"\x1b[0m \x1b[32m[✔] Tiktok (janedoe): Found\x1b[0m\n" +
|
||||
"\x1b[0m \x1b[32m[✔] Instagram (janedoe): Found\x1b[0m\n" +
|
||||
"\x1b[0m\n" +
|
||||
"\x1b[35m== GAMING SITES ==\x1b[0m\n" +
|
||||
"\x1b[0m \x1b[32m[✔] Chess.com (janedoe): Found\x1b[0m\n" +
|
||||
"\x1b[0m \x1b[32m[✔] Roblox (janedoe): Found\x1b[0m\n" +
|
||||
"\x1b[0m\n" +
|
||||
"\x1b[35m== EMAIL SITES ==\x1b[0m\n" +
|
||||
"\x1b[0m \x1b[32m[✔] Protonmail (janedoe): Found\x1b[0m"},
|
||||
{Tool: "user-scanner", Type: tools.EventTypeDone},
|
||||
{Tool: "github-recon", Type: tools.EventTypeOutput, Payload: "\x1b[1;38;2;113;135;253m👤 User informations\x1b[0m\n\n" +
|
||||
" \x1b[38;2;125;125;125mNo data found\x1b[0m"},
|
||||
{Tool: "github-recon", Type: tools.EventTypeDone},
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.searches[s1.ID] = s1
|
||||
m.searches[s2.ID] = s2
|
||||
m.mu.Unlock()
|
||||
}
|
||||
274
back/internal/search/manager.go
Normal file
274
back/internal/search/manager.go
Normal file
@@ -0,0 +1,274 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/anotherhadi/iknowyou/config"
|
||||
"github.com/anotherhadi/iknowyou/internal/tools"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
searches map[string]*Search
|
||||
|
||||
configPath string
|
||||
factories []func() tools.ToolRunner
|
||||
searchTTL time.Duration
|
||||
cleanupInterval time.Duration
|
||||
|
||||
done chan struct{} // closed by Stop()
|
||||
}
|
||||
|
||||
func NewManager(configPath string, factories []func() tools.ToolRunner, searchTTL, cleanupInterval time.Duration) *Manager {
|
||||
m := &Manager{
|
||||
searches: make(map[string]*Search),
|
||||
configPath: configPath,
|
||||
factories: factories,
|
||||
searchTTL: searchTTL,
|
||||
cleanupInterval: cleanupInterval,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
go m.cleanupLoop()
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *Manager) Stop() {
|
||||
close(m.done)
|
||||
}
|
||||
|
||||
func (m *Manager) Start(
|
||||
parentCtx context.Context,
|
||||
target string,
|
||||
inputType tools.InputType,
|
||||
profileName string,
|
||||
) (*Search, error) {
|
||||
|
||||
// "default" is the canonical UI name for the no-filter profile.
|
||||
if profileName == "default" {
|
||||
profileName = ""
|
||||
}
|
||||
|
||||
cfg, err := config.Load(m.configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("manager: loading config: %w", err)
|
||||
}
|
||||
|
||||
activeTools, statuses, err := m.instantiate(cfg, inputType, profileName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(parentCtx)
|
||||
|
||||
s := &Search{
|
||||
ID: uuid.NewString(),
|
||||
Target: target,
|
||||
InputType: inputType,
|
||||
Profile: profileName,
|
||||
StartedAt: time.Now(),
|
||||
PlannedTools: statuses,
|
||||
cancelFn: cancel,
|
||||
status: StatusRunning,
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
m.searches[s.ID] = s
|
||||
m.mu.Unlock()
|
||||
|
||||
go m.runAll(ctx, s, activeTools)
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (m *Manager) Get(id string) (*Search, error) {
|
||||
return m.get(id)
|
||||
}
|
||||
|
||||
func (m *Manager) All() []*Search {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
out := make([]*Search, 0, len(m.searches))
|
||||
for _, s := range m.searches {
|
||||
out = append(out, s)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *Manager) Delete(id string) error {
|
||||
s, err := m.get(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.Cancel()
|
||||
|
||||
m.mu.Lock()
|
||||
delete(m.searches, id)
|
||||
m.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Manager) cleanupLoop() {
|
||||
ticker := time.NewTicker(m.cleanupInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
m.purgeExpired()
|
||||
case <-m.done:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) purgeExpired() {
|
||||
now := time.Now()
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
for id, s := range m.searches {
|
||||
ft := s.FinishedAt()
|
||||
if ft.IsZero() {
|
||||
continue // still running
|
||||
}
|
||||
if now.Sub(ft) > m.searchTTL {
|
||||
delete(m.searches, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) instantiate(cfg *config.Config, inputType tools.InputType, profileName string) ([]tools.ToolRunner, []ToolStatus, error) {
|
||||
allNames := make([]string, len(m.factories))
|
||||
allInstances := make([]tools.ToolRunner, len(m.factories))
|
||||
for i, factory := range m.factories {
|
||||
t := factory()
|
||||
allNames[i] = t.Name()
|
||||
allInstances[i] = t
|
||||
}
|
||||
|
||||
activeNames, err := cfg.ActiveTools(profileName, allNames)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
activeSet := make(map[string]struct{}, len(activeNames))
|
||||
for _, n := range activeNames {
|
||||
activeSet[n] = struct{}{}
|
||||
}
|
||||
|
||||
var runners []tools.ToolRunner
|
||||
var statuses []ToolStatus
|
||||
|
||||
for _, tool := range allInstances {
|
||||
if _, ok := activeSet[tool.Name()]; !ok {
|
||||
continue
|
||||
}
|
||||
if !acceptsInputType(tool, inputType) {
|
||||
continue
|
||||
}
|
||||
|
||||
if a, ok := tool.(tools.AvailabilityChecker); ok {
|
||||
if available, reason := a.Available(); !available {
|
||||
statuses = append(statuses, ToolStatus{
|
||||
Name: tool.Name(),
|
||||
Skipped: true,
|
||||
Reason: reason,
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
if c, ok := tool.(tools.Configurable); ok {
|
||||
if err := cfg.DecodeEffective(tool.Name(), profileName, c.ConfigPtr()); err != nil {
|
||||
return nil, nil, fmt.Errorf("manager: configuring tool %q: %w", tool.Name(), err)
|
||||
}
|
||||
|
||||
if d, ok := tool.(tools.ConfigDescriber); ok {
|
||||
if missing, fieldName := missingRequiredField(d.ConfigFields()); missing {
|
||||
statuses = append(statuses, ToolStatus{
|
||||
Name: tool.Name(),
|
||||
Skipped: true,
|
||||
Reason: fmt.Sprintf("missing required config field: %s", fieldName),
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
statuses = append(statuses, ToolStatus{Name: tool.Name()})
|
||||
runners = append(runners, tool)
|
||||
}
|
||||
|
||||
return runners, statuses, nil
|
||||
}
|
||||
|
||||
func (m *Manager) runAll(ctx context.Context, s *Search, runners []tools.ToolRunner) {
|
||||
var wg sync.WaitGroup
|
||||
for _, tool := range runners {
|
||||
wg.Add(1)
|
||||
go func(t tools.ToolRunner) {
|
||||
defer wg.Done()
|
||||
m.runOne(ctx, s, t)
|
||||
}(tool)
|
||||
}
|
||||
wg.Wait()
|
||||
s.markDone()
|
||||
}
|
||||
|
||||
func (m *Manager) runOne(ctx context.Context, s *Search, tool tools.ToolRunner) {
|
||||
out := make(chan tools.Event)
|
||||
go func() {
|
||||
_ = tool.Run(ctx, s.Target, s.InputType, out)
|
||||
}()
|
||||
|
||||
var count int
|
||||
var hasCount bool
|
||||
for e := range out {
|
||||
if e.Type == tools.EventTypeCount {
|
||||
if n, ok := e.Payload.(int); ok {
|
||||
count += n
|
||||
hasCount = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
s.append(e)
|
||||
}
|
||||
|
||||
if hasCount {
|
||||
s.setToolResultCount(tool.Name(), count)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) get(id string) (*Search, error) {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
s, ok := m.searches[id]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("search %q not found", id)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func acceptsInputType(tool tools.ToolRunner, inputType tools.InputType) bool {
|
||||
for _, t := range tool.InputTypes() {
|
||||
if t == inputType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func missingRequiredField(fields []tools.ConfigField) (missing bool, fieldName string) {
|
||||
for _, f := range fields {
|
||||
if !f.Required {
|
||||
continue
|
||||
}
|
||||
if f.Value == nil || reflect.DeepEqual(f.Value, reflect.Zero(reflect.TypeOf(f.Value)).Interface()) {
|
||||
return true, f.Name
|
||||
}
|
||||
}
|
||||
return false, ""
|
||||
}
|
||||
97
back/internal/search/search.go
Normal file
97
back/internal/search/search.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package search
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/anotherhadi/iknowyou/internal/tools"
|
||||
)
|
||||
|
||||
type Status string
|
||||
|
||||
const (
|
||||
StatusRunning Status = "running"
|
||||
StatusDone Status = "done"
|
||||
StatusCancelled Status = "cancelled"
|
||||
)
|
||||
|
||||
type ToolStatus struct {
|
||||
Name string `json:"name"`
|
||||
Skipped bool `json:"skipped"`
|
||||
Reason string `json:"reason,omitempty"` // non-empty only when Skipped is true
|
||||
ResultCount *int `json:"result_count,omitempty"` // nil = pending, 0 = no results
|
||||
}
|
||||
|
||||
type Search struct {
|
||||
ID string
|
||||
Target string
|
||||
InputType tools.InputType
|
||||
Profile string
|
||||
StartedAt time.Time
|
||||
PlannedTools []ToolStatus
|
||||
|
||||
cancelFn context.CancelFunc
|
||||
|
||||
mu sync.RWMutex
|
||||
events []tools.Event
|
||||
status Status
|
||||
finishedAt time.Time
|
||||
}
|
||||
|
||||
func (s *Search) Events() []tools.Event {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := make([]tools.Event, len(s.events))
|
||||
copy(out, s.events)
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Search) Status() Status {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.status
|
||||
}
|
||||
|
||||
func (s *Search) FinishedAt() time.Time {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.finishedAt
|
||||
}
|
||||
|
||||
func (s *Search) Cancel() {
|
||||
s.mu.Lock()
|
||||
if s.status == StatusRunning {
|
||||
s.status = StatusCancelled
|
||||
s.finishedAt = time.Now()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
s.cancelFn()
|
||||
}
|
||||
|
||||
func (s *Search) setToolResultCount(toolName string, count int) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
for i, t := range s.PlannedTools {
|
||||
if t.Name == toolName {
|
||||
s.PlannedTools[i].ResultCount = &count
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Search) append(e tools.Event) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.events = append(s.events, e)
|
||||
}
|
||||
|
||||
func (s *Search) markDone() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.status == StatusRunning {
|
||||
s.status = StatusDone
|
||||
s.finishedAt = time.Now()
|
||||
}
|
||||
}
|
||||
162
back/internal/tools/breachdirectory/tool.go
Normal file
162
back/internal/tools/breachdirectory/tool.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package breachdirectory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/anotherhadi/iknowyou/internal/tools"
|
||||
)
|
||||
|
||||
const (
|
||||
name = "breachdirectory"
|
||||
description = "Data breach search via BreachDirectory — checks if an email, username, or phone appears in known data breaches and returns exposed passwords/hashes."
|
||||
link = "https://breachdirectory.org"
|
||||
icon = ""
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
APIKey string `yaml:"api_key" iky:"desc=RapidAPI key for BreachDirectory (required — get one at rapidapi.com/rohan-patra/api/breachdirectory);required=true"`
|
||||
}
|
||||
|
||||
type Runner struct {
|
||||
cfg Config
|
||||
}
|
||||
|
||||
func New() tools.ToolRunner {
|
||||
cfg := Config{}
|
||||
tools.ApplyDefaults(&cfg)
|
||||
return &Runner{cfg: cfg}
|
||||
}
|
||||
|
||||
func (r *Runner) Name() string { return name }
|
||||
func (r *Runner) Description() string { return description }
|
||||
func (r *Runner) Link() string { return link }
|
||||
func (r *Runner) Icon() string { return icon }
|
||||
|
||||
func (r *Runner) InputTypes() []tools.InputType {
|
||||
return []tools.InputType{
|
||||
tools.InputTypeEmail,
|
||||
tools.InputTypeUsername,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) ConfigPtr() interface{} { return &r.cfg }
|
||||
|
||||
func (r *Runner) ConfigFields() []tools.ConfigField {
|
||||
return tools.ReflectConfigFields(r.cfg)
|
||||
}
|
||||
|
||||
type bdResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Found int `json:"found"`
|
||||
Result json.RawMessage `json:"result"`
|
||||
}
|
||||
|
||||
type bdEntry struct {
|
||||
Email string `json:"email"`
|
||||
Password string `json:"password"`
|
||||
Hash string `json:"hash"`
|
||||
SHA1 string `json:"sha1"`
|
||||
Sources string `json:"sources"`
|
||||
HasPassword bool `json:"has_password"`
|
||||
}
|
||||
|
||||
func (r *Runner) Run(ctx context.Context, target string, _ tools.InputType, out chan<- tools.Event) error {
|
||||
defer close(out)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||
"https://breachdirectory.p.rapidapi.com/?func=auto&term="+target, nil)
|
||||
if err != nil {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: err.Error()}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 0}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
req.Header.Set("X-RapidAPI-Key", r.cfg.APIKey)
|
||||
req.Header.Set("X-RapidAPI-Host", "breachdirectory.p.rapidapi.com")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "scan cancelled"}
|
||||
} else {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: err.Error()}
|
||||
}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 0}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "failed to read response"}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 0}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "invalid or exhausted API key"}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 0}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: fmt.Sprintf("API error %d: %s", resp.StatusCode, string(body))}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 0}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
|
||||
var parsed bdResponse
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "failed to parse response"}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 0}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
|
||||
if !parsed.Success || parsed.Found == 0 {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 0}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
|
||||
var entries []bdEntry
|
||||
if err := json.Unmarshal(parsed.Result, &entries); err != nil || len(entries) == 0 {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 0}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Found in %d breach record(s)\n\n", parsed.Found))
|
||||
|
||||
for _, entry := range entries {
|
||||
if entry.Sources != "" {
|
||||
sb.WriteString(fmt.Sprintf("Source: %s\n", entry.Sources))
|
||||
}
|
||||
if entry.Password != "" {
|
||||
sb.WriteString(fmt.Sprintf("Password: %s\n", entry.Password))
|
||||
}
|
||||
if entry.Hash != "" {
|
||||
sb.WriteString(fmt.Sprintf("Hash: %s\n", entry.Hash))
|
||||
}
|
||||
if entry.SHA1 != "" {
|
||||
sb.WriteString(fmt.Sprintf("SHA1: %s\n", entry.SHA1))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeOutput, Payload: strings.TrimSpace(sb.String())}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: parsed.Found}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
155
back/internal/tools/config_reflect.go
Normal file
155
back/internal/tools/config_reflect.go
Normal file
@@ -0,0 +1,155 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ReflectConfigFields builds []ConfigField from a struct using yaml/iky tags.
|
||||
// iky tag format: iky:"desc=...;default=...;required=true;options=a|b|c"
|
||||
func ReflectConfigFields(cfg any) []ConfigField {
|
||||
v := reflect.ValueOf(cfg)
|
||||
if v.Kind() == reflect.Ptr {
|
||||
v = v.Elem()
|
||||
}
|
||||
t := v.Type()
|
||||
|
||||
var fields []ConfigField
|
||||
for i := range t.NumField() {
|
||||
sf := t.Field(i)
|
||||
fv := v.Field(i)
|
||||
|
||||
yamlKey := sf.Tag.Get("yaml")
|
||||
if yamlKey == "" || yamlKey == "-" {
|
||||
continue
|
||||
}
|
||||
yamlKey = strings.SplitN(yamlKey, ",", 2)[0]
|
||||
|
||||
meta := parseIkyTag(sf.Tag.Get("iky"))
|
||||
|
||||
fieldType := goKindToString(sf.Type.Kind())
|
||||
if len(meta.options) > 0 {
|
||||
fieldType = "enum"
|
||||
}
|
||||
|
||||
fields = append(fields, ConfigField{
|
||||
Name: yamlKey,
|
||||
Type: fieldType,
|
||||
Required: meta.required,
|
||||
Description: meta.desc,
|
||||
Default: parseTypedDefault(meta.rawDefault, sf.Type.Kind()),
|
||||
Value: fv.Interface(),
|
||||
Options: meta.options,
|
||||
})
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// ApplyDefaults sets each field to its iky default if the field is zero.
|
||||
func ApplyDefaults(cfg any) {
|
||||
v := reflect.ValueOf(cfg)
|
||||
if v.Kind() == reflect.Ptr {
|
||||
v = v.Elem()
|
||||
}
|
||||
t := v.Type()
|
||||
|
||||
for i := range t.NumField() {
|
||||
sf := t.Field(i)
|
||||
fv := v.Field(i)
|
||||
|
||||
if !fv.CanSet() {
|
||||
continue
|
||||
}
|
||||
meta := parseIkyTag(sf.Tag.Get("iky"))
|
||||
if meta.rawDefault == "" || !fv.IsZero() {
|
||||
continue
|
||||
}
|
||||
applyDefault(fv, sf.Type.Kind(), meta.rawDefault)
|
||||
}
|
||||
}
|
||||
|
||||
type ikyMeta struct {
|
||||
desc string
|
||||
rawDefault string
|
||||
required bool
|
||||
options []string
|
||||
}
|
||||
|
||||
func parseIkyTag(tag string) ikyMeta {
|
||||
var m ikyMeta
|
||||
for _, part := range strings.Split(tag, ";") {
|
||||
k, v, ok := strings.Cut(strings.TrimSpace(part), "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch strings.TrimSpace(k) {
|
||||
case "desc":
|
||||
m.desc = strings.TrimSpace(v)
|
||||
case "default":
|
||||
m.rawDefault = strings.TrimSpace(v)
|
||||
case "required":
|
||||
m.required = strings.TrimSpace(v) == "true"
|
||||
case "options":
|
||||
for _, opt := range strings.Split(v, "|") {
|
||||
if o := strings.TrimSpace(opt); o != "" {
|
||||
m.options = append(m.options, o)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func goKindToString(k reflect.Kind) string {
|
||||
switch k {
|
||||
case reflect.String:
|
||||
return "string"
|
||||
case reflect.Bool:
|
||||
return "bool"
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
return "int"
|
||||
case reflect.Float32, reflect.Float64:
|
||||
return "float"
|
||||
default:
|
||||
return k.String()
|
||||
}
|
||||
}
|
||||
|
||||
func parseTypedDefault(raw string, k reflect.Kind) any {
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
switch k {
|
||||
case reflect.Bool:
|
||||
b, _ := strconv.ParseBool(raw)
|
||||
return b
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
n, _ := strconv.ParseInt(raw, 10, 64)
|
||||
return int(n)
|
||||
case reflect.Float32, reflect.Float64:
|
||||
f, _ := strconv.ParseFloat(raw, 64)
|
||||
return f
|
||||
default:
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
func applyDefault(fv reflect.Value, k reflect.Kind, raw string) {
|
||||
switch k {
|
||||
case reflect.String:
|
||||
fv.SetString(raw)
|
||||
case reflect.Bool:
|
||||
if b, err := strconv.ParseBool(raw); err == nil {
|
||||
fv.SetBool(b)
|
||||
}
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
if n, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||
fv.SetInt(n)
|
||||
}
|
||||
case reflect.Float32, reflect.Float64:
|
||||
if f, err := strconv.ParseFloat(raw, 64); err == nil {
|
||||
fv.SetFloat(f)
|
||||
}
|
||||
}
|
||||
}
|
||||
137
back/internal/tools/crtsh/tool.go
Normal file
137
back/internal/tools/crtsh/tool.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package crtsh
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/anotherhadi/iknowyou/internal/tools"
|
||||
)
|
||||
|
||||
const (
|
||||
name = "crt.sh"
|
||||
description = "SSL/TLS certificate transparency log search via crt.sh — enumerates subdomains and certificates issued for a domain."
|
||||
link = "https://crt.sh"
|
||||
icon = ""
|
||||
)
|
||||
|
||||
type Runner struct{}
|
||||
|
||||
func New() tools.ToolRunner {
|
||||
return &Runner{}
|
||||
}
|
||||
|
||||
func (r *Runner) Name() string { return name }
|
||||
func (r *Runner) Description() string { return description }
|
||||
func (r *Runner) Link() string { return link }
|
||||
func (r *Runner) Icon() string { return icon }
|
||||
|
||||
func (r *Runner) InputTypes() []tools.InputType {
|
||||
return []tools.InputType{tools.InputTypeDomain}
|
||||
}
|
||||
|
||||
type crtEntry struct {
|
||||
IssuerName string `json:"issuer_name"`
|
||||
CommonName string `json:"common_name"`
|
||||
NameValue string `json:"name_value"`
|
||||
NotBefore string `json:"not_before"`
|
||||
NotAfter string `json:"not_after"`
|
||||
EntryTimestamp string `json:"entry_timestamp"`
|
||||
}
|
||||
|
||||
func (r *Runner) Run(ctx context.Context, target string, _ tools.InputType, out chan<- tools.Event) error {
|
||||
defer close(out)
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("q", "%."+target)
|
||||
params.Set("output", "json")
|
||||
apiURL := "https://crt.sh/?" + params.Encode()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: err.Error()}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 0}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; crtsh-scanner/1.0)")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "scan cancelled"}
|
||||
} else {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: err.Error()}
|
||||
}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 0}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "failed to read response"}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 0}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
msg := fmt.Sprintf("API error %d", resp.StatusCode)
|
||||
if resp.StatusCode == http.StatusBadGateway || resp.StatusCode == http.StatusServiceUnavailable || resp.StatusCode == http.StatusGatewayTimeout {
|
||||
msg = fmt.Sprintf("crt.sh is temporarily unavailable (%d), try again later", resp.StatusCode)
|
||||
}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: msg}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 0}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
|
||||
var entries []crtEntry
|
||||
if err := json.Unmarshal(body, &entries); err != nil {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "failed to parse response"}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 0}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 0}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Deduplicate subdomains from name_value fields
|
||||
seen := make(map[string]struct{})
|
||||
for _, e := range entries {
|
||||
for _, line := range strings.Split(e.NameValue, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" && !strings.HasPrefix(line, "*") {
|
||||
seen[line] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
subdomains := make([]string, 0, len(seen))
|
||||
for s := range seen {
|
||||
subdomains = append(subdomains, s)
|
||||
}
|
||||
sort.Strings(subdomains)
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Found %d unique subdomains across %d certificate entries\n\n", len(subdomains), len(entries)))
|
||||
for _, s := range subdomains {
|
||||
sb.WriteString(s + "\n")
|
||||
}
|
||||
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeOutput, Payload: strings.TrimSpace(sb.String())}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: len(subdomains)}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
90
back/internal/tools/dig/tool.go
Normal file
90
back/internal/tools/dig/tool.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package dig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/anotherhadi/iknowyou/internal/tools"
|
||||
)
|
||||
|
||||
const (
|
||||
name = "dig"
|
||||
description = "DNS lookup querying A, AAAA, MX, NS, TXT, and SOA records for a domain, or reverse DNS (PTR) for an IP."
|
||||
link = "https://linux.die.net/man/1/dig"
|
||||
icon = ""
|
||||
)
|
||||
|
||||
var recordTypes = []string{"A", "AAAA", "MX", "NS", "TXT", "SOA"}
|
||||
|
||||
type Runner struct{}
|
||||
|
||||
func New() tools.ToolRunner { return &Runner{} }
|
||||
|
||||
func (r *Runner) Name() string { return name }
|
||||
func (r *Runner) Description() string { return description }
|
||||
func (r *Runner) Link() string { return link }
|
||||
func (r *Runner) Icon() string { return icon }
|
||||
|
||||
func (r *Runner) InputTypes() []tools.InputType {
|
||||
return []tools.InputType{tools.InputTypeDomain, tools.InputTypeIP}
|
||||
}
|
||||
|
||||
func (r *Runner) Available() (bool, string) {
|
||||
if _, err := exec.LookPath("dig"); err != nil {
|
||||
return false, "dig binary not found in PATH"
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
func (r *Runner) Dependencies() []string { return []string{"dig"} }
|
||||
|
||||
func (r *Runner) Run(ctx context.Context, target string, inputType tools.InputType, out chan<- tools.Event) error {
|
||||
defer close(out)
|
||||
|
||||
var sb strings.Builder
|
||||
totalCount := 0
|
||||
|
||||
if inputType == tools.InputTypeIP {
|
||||
cmd := exec.CommandContext(ctx, "dig", "-x", target, "+noall", "+answer")
|
||||
output, err := cmd.Output()
|
||||
if err != nil && ctx.Err() != nil {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "scan cancelled"}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 0}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
result := strings.TrimSpace(string(output))
|
||||
if result != "" {
|
||||
sb.WriteString("=== Reverse DNS (PTR) ===\n")
|
||||
sb.WriteString(result)
|
||||
totalCount += strings.Count(result, "\n") + 1
|
||||
}
|
||||
} else {
|
||||
for _, rtype := range recordTypes {
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
cmd := exec.CommandContext(ctx, "dig", target, rtype, "+noall", "+answer")
|
||||
output, _ := cmd.Output()
|
||||
result := strings.TrimSpace(string(output))
|
||||
if result == "" {
|
||||
continue
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("=== %s ===\n", rtype))
|
||||
sb.WriteString(result)
|
||||
sb.WriteString("\n\n")
|
||||
totalCount += strings.Count(result, "\n") + 1
|
||||
}
|
||||
}
|
||||
|
||||
if ctx.Err() != nil {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "scan cancelled"}
|
||||
} else if sb.Len() > 0 {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeOutput, Payload: strings.TrimSpace(sb.String())}
|
||||
}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: totalCount}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
91
back/internal/tools/github-recon/tool.go
Normal file
91
back/internal/tools/github-recon/tool.go
Normal file
@@ -0,0 +1,91 @@
|
||||
package githubrecon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/anotherhadi/iknowyou/internal/tools"
|
||||
)
|
||||
|
||||
const (
|
||||
name = "github-recon"
|
||||
description = "GitHub OSINT reconnaissance tool. Gathers profile info, social links, organisations, SSH/GPG keys, commits, and more from a GitHub username or email."
|
||||
link = "https://github.com/anotherhadi/nur-osint"
|
||||
icon = "github"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Token string `yaml:"token" iky:"desc=GitHub personal access token (enables higher rate limits and more data);required=false"`
|
||||
Deepscan bool `yaml:"deepscan" iky:"desc=Enable deep scan (slower - scans all repositories for authors/emails);default=false"`
|
||||
SpoofEmail bool `yaml:"spoof_email" iky:"desc=Include email spoofing check (email mode only, requires token);default=false"`
|
||||
}
|
||||
|
||||
type Runner struct {
|
||||
cfg Config
|
||||
}
|
||||
|
||||
func New() tools.ToolRunner {
|
||||
cfg := Config{}
|
||||
tools.ApplyDefaults(&cfg)
|
||||
return &Runner{cfg: cfg}
|
||||
}
|
||||
|
||||
func (r *Runner) Name() string { return name }
|
||||
func (r *Runner) Description() string { return description }
|
||||
func (r *Runner) Link() string { return link }
|
||||
func (r *Runner) Icon() string { return icon }
|
||||
|
||||
func (r *Runner) InputTypes() []tools.InputType {
|
||||
return []tools.InputType{
|
||||
tools.InputTypeUsername,
|
||||
tools.InputTypeEmail,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) ConfigPtr() interface{} { return &r.cfg }
|
||||
|
||||
func (r *Runner) ConfigFields() []tools.ConfigField {
|
||||
return tools.ReflectConfigFields(r.cfg)
|
||||
}
|
||||
|
||||
func (r *Runner) Available() (bool, string) {
|
||||
if _, err := exec.LookPath("github-recon"); err != nil {
|
||||
return false, "github-recon binary not found in PATH"
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
func (r *Runner) Dependencies() []string { return []string{"github-recon"} }
|
||||
|
||||
func (r *Runner) Run(ctx context.Context, target string, inputType tools.InputType, out chan<- tools.Event) error {
|
||||
defer close(out)
|
||||
|
||||
args := []string{target}
|
||||
if r.cfg.Token != "" {
|
||||
args = append(args, "--token", r.cfg.Token)
|
||||
}
|
||||
if r.cfg.Deepscan {
|
||||
args = append(args, "--deepscan")
|
||||
}
|
||||
if r.cfg.SpoofEmail && inputType == tools.InputTypeEmail {
|
||||
args = append(args, "--spoof-email")
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, "github-recon", args...)
|
||||
output, err := tools.RunWithPTY(ctx, cmd)
|
||||
|
||||
// Remove banner
|
||||
output = tools.RemoveFirstLines(output, 10)
|
||||
|
||||
count := 0
|
||||
if err != nil && ctx.Err() != nil {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "scan cancelled"}
|
||||
} else if output != "" {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeOutput, Payload: output}
|
||||
count = strings.Count(output, "Username:")
|
||||
}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: count}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
55
back/internal/tools/gravatar-recon/tool.go
Normal file
55
back/internal/tools/gravatar-recon/tool.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package gravatarrecon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
|
||||
"github.com/anotherhadi/iknowyou/internal/tools"
|
||||
)
|
||||
|
||||
const (
|
||||
name = "gravatar-recon"
|
||||
description = "Gravatar OSINT tool. Extracts public profile data from a Gravatar account: name, bio, location, employment, social accounts, phone, and more."
|
||||
link = "https://github.com/anotherhadi/gravatar-recon"
|
||||
icon = ""
|
||||
)
|
||||
|
||||
type Runner struct{}
|
||||
|
||||
func New() tools.ToolRunner { return &Runner{} }
|
||||
|
||||
func (r *Runner) Name() string { return name }
|
||||
func (r *Runner) Description() string { return description }
|
||||
func (r *Runner) Link() string { return link }
|
||||
func (r *Runner) Icon() string { return icon }
|
||||
|
||||
func (r *Runner) InputTypes() []tools.InputType {
|
||||
return []tools.InputType{tools.InputTypeEmail}
|
||||
}
|
||||
|
||||
func (r *Runner) Available() (bool, string) {
|
||||
if _, err := exec.LookPath("gravatar-recon"); err != nil {
|
||||
return false, "gravatar-recon binary not found in PATH"
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
func (r *Runner) Dependencies() []string { return []string{"gravatar-recon"} }
|
||||
|
||||
func (r *Runner) Run(ctx context.Context, target string, _ tools.InputType, out chan<- tools.Event) error {
|
||||
defer close(out)
|
||||
|
||||
cmd := exec.CommandContext(ctx, "gravatar-recon", "--silent", target)
|
||||
output, err := tools.RunWithPTY(ctx, cmd)
|
||||
|
||||
count := 0
|
||||
if err != nil && ctx.Err() != nil {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "scan cancelled"}
|
||||
} else if output != "" {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeOutput, Payload: output}
|
||||
count = 1
|
||||
}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: count}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
133
back/internal/tools/ipinfo/tool.go
Normal file
133
back/internal/tools/ipinfo/tool.go
Normal file
@@ -0,0 +1,133 @@
|
||||
package ipinfo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/anotherhadi/iknowyou/internal/tools"
|
||||
)
|
||||
|
||||
const (
|
||||
name = "ipinfo"
|
||||
description = "IP geolocation via ipinfo.io — returns city, region, country, coordinates, ASN/org, timezone, and hostname."
|
||||
link = "https://ipinfo.io"
|
||||
icon = ""
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Token string `yaml:"token" iky:"desc=ipinfo.io API token (optional — free tier allows 50k req/month without one);required=false"`
|
||||
}
|
||||
|
||||
type Runner struct {
|
||||
cfg Config
|
||||
}
|
||||
|
||||
func New() tools.ToolRunner {
|
||||
cfg := Config{}
|
||||
tools.ApplyDefaults(&cfg)
|
||||
return &Runner{cfg: cfg}
|
||||
}
|
||||
|
||||
func (r *Runner) Name() string { return name }
|
||||
func (r *Runner) Description() string { return description }
|
||||
func (r *Runner) Link() string { return link }
|
||||
func (r *Runner) Icon() string { return icon }
|
||||
|
||||
func (r *Runner) InputTypes() []tools.InputType {
|
||||
return []tools.InputType{tools.InputTypeIP}
|
||||
}
|
||||
|
||||
func (r *Runner) ConfigPtr() interface{} { return &r.cfg }
|
||||
|
||||
func (r *Runner) ConfigFields() []tools.ConfigField {
|
||||
return tools.ReflectConfigFields(r.cfg)
|
||||
}
|
||||
|
||||
type ipinfoResponse struct {
|
||||
IP string `json:"ip"`
|
||||
Hostname string `json:"hostname"`
|
||||
City string `json:"city"`
|
||||
Region string `json:"region"`
|
||||
Country string `json:"country"`
|
||||
Loc string `json:"loc"`
|
||||
Org string `json:"org"`
|
||||
Postal string `json:"postal"`
|
||||
Timezone string `json:"timezone"`
|
||||
Bogon bool `json:"bogon"`
|
||||
}
|
||||
|
||||
func (r *Runner) Run(ctx context.Context, target string, _ tools.InputType, out chan<- tools.Event) error {
|
||||
defer close(out)
|
||||
|
||||
url := fmt.Sprintf("https://ipinfo.io/%s/json", target)
|
||||
if r.cfg.Token != "" {
|
||||
url += "?token=" + r.cfg.Token
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: err.Error()}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 0}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "scan cancelled"}
|
||||
} else {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: err.Error()}
|
||||
}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 0}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var info ipinfoResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&info); err != nil {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "failed to parse response"}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 0}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
|
||||
if info.Bogon {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeOutput, Payload: fmt.Sprintf("IP: %s\nType: Bogon/Private address", info.IP)}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 1}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
field := func(label, value string) {
|
||||
if value != "" {
|
||||
sb.WriteString(fmt.Sprintf("%-12s %s\n", label+":", value))
|
||||
}
|
||||
}
|
||||
|
||||
field("IP", info.IP)
|
||||
field("Hostname", info.Hostname)
|
||||
field("City", info.City)
|
||||
field("Region", info.Region)
|
||||
field("Country", info.Country)
|
||||
field("Coordinates", info.Loc)
|
||||
field("Postal", info.Postal)
|
||||
field("Timezone", info.Timezone)
|
||||
field("Org/ASN", info.Org)
|
||||
|
||||
result := strings.TrimSpace(sb.String())
|
||||
count := 0
|
||||
if result != "" {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeOutput, Payload: result}
|
||||
count = 1
|
||||
}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: count}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
178
back/internal/tools/leakcheck/tool.go
Normal file
178
back/internal/tools/leakcheck/tool.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package leakcheck
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/anotherhadi/iknowyou/internal/tools"
|
||||
)
|
||||
|
||||
const (
|
||||
name = "leakcheck"
|
||||
description = "Data breach lookup via LeakCheck.io — searches 7B+ leaked records for email addresses, usernames, and phone numbers across hundreds of breaches."
|
||||
link = "https://leakcheck.io"
|
||||
icon = ""
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
APIKey string `yaml:"api_key" iky:"desc=LeakCheck API key (required — get one at leakcheck.io);required=true"`
|
||||
}
|
||||
|
||||
type Runner struct {
|
||||
cfg Config
|
||||
}
|
||||
|
||||
func New() tools.ToolRunner {
|
||||
cfg := Config{}
|
||||
tools.ApplyDefaults(&cfg)
|
||||
return &Runner{cfg: cfg}
|
||||
}
|
||||
|
||||
func (r *Runner) Name() string { return name }
|
||||
func (r *Runner) Description() string { return description }
|
||||
func (r *Runner) Link() string { return link }
|
||||
func (r *Runner) Icon() string { return icon }
|
||||
|
||||
func (r *Runner) InputTypes() []tools.InputType {
|
||||
return []tools.InputType{
|
||||
tools.InputTypeEmail,
|
||||
tools.InputTypeUsername,
|
||||
tools.InputTypePhone,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) ConfigPtr() interface{} { return &r.cfg }
|
||||
|
||||
func (r *Runner) ConfigFields() []tools.ConfigField {
|
||||
return tools.ReflectConfigFields(r.cfg)
|
||||
}
|
||||
|
||||
type leakCheckResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Found int `json:"found"`
|
||||
Result []struct {
|
||||
Email string `json:"email"`
|
||||
Username string `json:"username"`
|
||||
Phone string `json:"phone"`
|
||||
Password string `json:"password"`
|
||||
Hash string `json:"hash"`
|
||||
Sources []string `json:"sources"`
|
||||
Fields []string `json:"fields"`
|
||||
} `json:"result"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
func (r *Runner) Run(ctx context.Context, target string, inputType tools.InputType, out chan<- tools.Event) error {
|
||||
defer close(out)
|
||||
|
||||
queryType := "auto"
|
||||
switch inputType {
|
||||
case tools.InputTypeEmail:
|
||||
queryType = "email"
|
||||
case tools.InputTypeUsername:
|
||||
queryType = "login"
|
||||
case tools.InputTypePhone:
|
||||
queryType = "phone"
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("https://leakcheck.io/api/v2/query/%s?type=%s", target, queryType)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: err.Error()}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 0}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
req.Header.Set("X-API-Key", r.cfg.APIKey)
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "scan cancelled"}
|
||||
} else {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: err.Error()}
|
||||
}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 0}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "failed to read response"}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 0}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "invalid or exhausted API key"}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 0}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
|
||||
var result leakCheckResponse
|
||||
if err := json.Unmarshal(body, &result); err != nil {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "failed to parse response"}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 0}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
|
||||
if !result.Success {
|
||||
msg := result.Error
|
||||
if msg == "" {
|
||||
msg = "API returned failure"
|
||||
}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: msg}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 0}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
|
||||
if result.Found == 0 || len(result.Result) == 0 {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 0}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Found in %d breach(es)\n\n", result.Found))
|
||||
|
||||
for _, entry := range result.Result {
|
||||
if len(entry.Sources) > 0 {
|
||||
sb.WriteString(fmt.Sprintf("Sources: %s\n", strings.Join(entry.Sources, ", ")))
|
||||
}
|
||||
if entry.Email != "" {
|
||||
sb.WriteString(fmt.Sprintf(" Email: %s\n", entry.Email))
|
||||
}
|
||||
if entry.Username != "" {
|
||||
sb.WriteString(fmt.Sprintf(" Username: %s\n", entry.Username))
|
||||
}
|
||||
if entry.Phone != "" {
|
||||
sb.WriteString(fmt.Sprintf(" Phone: %s\n", entry.Phone))
|
||||
}
|
||||
if entry.Password != "" {
|
||||
sb.WriteString(fmt.Sprintf(" Password: %s\n", entry.Password))
|
||||
}
|
||||
if entry.Hash != "" {
|
||||
sb.WriteString(fmt.Sprintf(" Hash: %s\n", entry.Hash))
|
||||
}
|
||||
if len(entry.Fields) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(" Fields: %s\n", strings.Join(entry.Fields, ", ")))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeOutput, Payload: strings.TrimSpace(sb.String())}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: result.Found}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
89
back/internal/tools/maigret/tool.go
Normal file
89
back/internal/tools/maigret/tool.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package maigret
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/anotherhadi/iknowyou/internal/tools"
|
||||
)
|
||||
|
||||
const (
|
||||
name = "maigret"
|
||||
description = "Username OSINT across 3000+ sites. Searches social networks, forums, and online platforms for an account matching the target username."
|
||||
link = "https://github.com/soxoj/maigret"
|
||||
icon = ""
|
||||
)
|
||||
|
||||
var accountsRe = regexp.MustCompile(`returned (\d+) accounts`)
|
||||
|
||||
type Config struct {
|
||||
AllSites bool `yaml:"all_sites" iky:"desc=Scan all sites in the database instead of just the top 500 (slower);default=false"`
|
||||
}
|
||||
|
||||
type Runner struct {
|
||||
cfg Config
|
||||
}
|
||||
|
||||
func New() tools.ToolRunner {
|
||||
cfg := Config{}
|
||||
tools.ApplyDefaults(&cfg)
|
||||
return &Runner{cfg: cfg}
|
||||
}
|
||||
|
||||
func (r *Runner) Name() string { return name }
|
||||
func (r *Runner) Description() string { return description }
|
||||
func (r *Runner) Link() string { return link }
|
||||
func (r *Runner) Icon() string { return icon }
|
||||
|
||||
func (r *Runner) InputTypes() []tools.InputType {
|
||||
return []tools.InputType{tools.InputTypeUsername}
|
||||
}
|
||||
|
||||
func (r *Runner) ConfigPtr() interface{} { return &r.cfg }
|
||||
|
||||
func (r *Runner) ConfigFields() []tools.ConfigField {
|
||||
return tools.ReflectConfigFields(r.cfg)
|
||||
}
|
||||
|
||||
func (r *Runner) Available() (bool, string) {
|
||||
if _, err := exec.LookPath("maigret"); err != nil {
|
||||
return false, "maigret binary not found in PATH"
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
func (r *Runner) Dependencies() []string { return []string{"maigret"} }
|
||||
|
||||
func (r *Runner) Run(ctx context.Context, target string, _ tools.InputType, out chan<- tools.Event) error {
|
||||
defer close(out)
|
||||
|
||||
args := []string{"--no-progressbar", target}
|
||||
if r.cfg.AllSites {
|
||||
args = append(args, "-a")
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, "maigret", args...)
|
||||
output, err := tools.RunWithPTY(ctx, cmd)
|
||||
|
||||
// Crop at Python traceback (NixOS read-only store error — results are unaffected)
|
||||
if idx := strings.Index(output, "Traceback (most recent call last)"); idx != -1 {
|
||||
output = strings.TrimSpace(output[:idx])
|
||||
}
|
||||
|
||||
count := 0
|
||||
if err != nil && ctx.Err() != nil {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "scan cancelled"}
|
||||
} else if output != "" {
|
||||
// Parse count from summary line: "returned N accounts"
|
||||
if m := accountsRe.FindStringSubmatch(output); len(m) == 2 {
|
||||
count, _ = strconv.Atoi(m[1])
|
||||
}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeOutput, Payload: output}
|
||||
}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: count}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
28
back/internal/tools/ptyrun.go
Normal file
28
back/internal/tools/ptyrun.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package tools
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
|
||||
"github.com/creack/pty"
|
||||
)
|
||||
|
||||
// oscRe strips OSC terminal sequences emitted by the PTY (e.g. colour queries).
|
||||
var oscRe = regexp.MustCompile(`\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)`)
|
||||
|
||||
// RunWithPTY runs cmd under a pseudo-terminal (preserving ANSI colours) and
|
||||
// returns the full output once the process exits.
|
||||
func RunWithPTY(ctx context.Context, cmd *exec.Cmd) (string, error) {
|
||||
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: 50, Cols: 220})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer func() { _ = ptmx.Close() }()
|
||||
|
||||
output, _ := io.ReadAll(ptmx)
|
||||
_ = cmd.Wait()
|
||||
|
||||
return oscRe.ReplaceAllString(string(output), ""), ctx.Err()
|
||||
}
|
||||
72
back/internal/tools/tools.go
Normal file
72
back/internal/tools/tools.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package tools
|
||||
|
||||
import "context"
|
||||
|
||||
type EventType string
|
||||
|
||||
const (
|
||||
EventTypeOutput EventType = "output" // raw ANSI text, payload is a plain string
|
||||
EventTypeError EventType = "error"
|
||||
EventTypeCount EventType = "count" // payload is int, additive — emit once or multiple times from Run
|
||||
EventTypeDone EventType = "done"
|
||||
)
|
||||
|
||||
type InputType string
|
||||
|
||||
const (
|
||||
InputTypeEmail InputType = "email"
|
||||
InputTypeUsername InputType = "username"
|
||||
InputTypePhone InputType = "phone"
|
||||
InputTypeIP InputType = "ip"
|
||||
InputTypeDomain InputType = "domain"
|
||||
InputTypePassword InputType = "password"
|
||||
InputTypeName InputType = "name"
|
||||
)
|
||||
|
||||
type Event struct {
|
||||
Tool string `json:"tool"`
|
||||
Type EventType `json:"type"`
|
||||
Payload interface{} `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
// ToolRunner is the core interface every tool must implement.
|
||||
type ToolRunner interface {
|
||||
Name() string
|
||||
Description() string
|
||||
Link() string // URL to source or documentation
|
||||
Icon() string // Simple Icons slug, empty if none
|
||||
|
||||
InputTypes() []InputType
|
||||
|
||||
// Run executes the tool and sends Events to out. Must close out when done.
|
||||
// inputType indicates what kind of value target is (email, username, ...).
|
||||
Run(ctx context.Context, target string, inputType InputType, out chan<- Event) error
|
||||
}
|
||||
|
||||
type Configurable interface {
|
||||
ConfigPtr() interface{}
|
||||
}
|
||||
|
||||
type ConfigField struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"` // "string", "bool", "int", "float", "enum"
|
||||
Required bool `json:"required"`
|
||||
Default any `json:"default"`
|
||||
Description string `json:"description"`
|
||||
Value any `json:"value"`
|
||||
Options []string `json:"options,omitempty"` // non-empty when Type == "enum"
|
||||
}
|
||||
|
||||
type ConfigDescriber interface {
|
||||
ConfigFields() []ConfigField
|
||||
}
|
||||
|
||||
// AvailabilityChecker is implemented by tools that require an external binary.
|
||||
type AvailabilityChecker interface {
|
||||
Available() (ok bool, reason string)
|
||||
}
|
||||
|
||||
type DependencyLister interface {
|
||||
Dependencies() []string
|
||||
}
|
||||
|
||||
95
back/internal/tools/user-scanner/tool.go
Normal file
95
back/internal/tools/user-scanner/tool.go
Normal file
@@ -0,0 +1,95 @@
|
||||
package userscanner
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/anotherhadi/iknowyou/internal/tools"
|
||||
)
|
||||
|
||||
const (
|
||||
name = "user-scanner"
|
||||
description = "🕵️♂️ (2-in-1) Email & Username OSINT suite. Analyzes 195+ scan vectors (95+ email / 100+ username) for security research, investigations, and digital footprinting."
|
||||
link = "https://github.com/kaifcodec/user-scanner"
|
||||
icon = ""
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
AllowLoud bool `yaml:"allow_loud" iky:"desc=Enable scanning sites that may send emails/notifications (password resets, etc.);default=false"`
|
||||
OnlyFound bool `yaml:"only_found" iky:"desc=Only show sites where the username/email was found;default=true"`
|
||||
}
|
||||
|
||||
type Runner struct {
|
||||
cfg Config
|
||||
}
|
||||
|
||||
func New() tools.ToolRunner {
|
||||
cfg := Config{}
|
||||
tools.ApplyDefaults(&cfg)
|
||||
return &Runner{cfg: cfg}
|
||||
}
|
||||
|
||||
func (r *Runner) Name() string { return name }
|
||||
func (r *Runner) Description() string { return description }
|
||||
func (r *Runner) Link() string { return link }
|
||||
func (r *Runner) Icon() string { return icon }
|
||||
|
||||
func (r *Runner) InputTypes() []tools.InputType {
|
||||
return []tools.InputType{
|
||||
tools.InputTypeEmail,
|
||||
tools.InputTypeUsername,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) ConfigPtr() interface{} { return &r.cfg }
|
||||
|
||||
func (r *Runner) ConfigFields() []tools.ConfigField {
|
||||
return tools.ReflectConfigFields(r.cfg)
|
||||
}
|
||||
|
||||
func (r *Runner) Available() (bool, string) {
|
||||
if _, err := exec.LookPath("user-scanner"); err != nil {
|
||||
return false, "user-scanner binary not found in PATH"
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
func (r *Runner) Dependencies() []string { return []string{"user-scanner"} }
|
||||
|
||||
func (r *Runner) Run(ctx context.Context, target string, inputType tools.InputType, out chan<- tools.Event) error {
|
||||
defer close(out)
|
||||
|
||||
args := make([]string, 0, 6)
|
||||
switch inputType {
|
||||
case tools.InputTypeEmail:
|
||||
args = append(args, "-e", target)
|
||||
default:
|
||||
args = append(args, "-u", target)
|
||||
}
|
||||
if r.cfg.AllowLoud {
|
||||
args = append(args, "--allow-loud")
|
||||
}
|
||||
if r.cfg.OnlyFound {
|
||||
args = append(args, "--only-found")
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, "user-scanner", args...)
|
||||
output, err := tools.RunWithPTY(ctx, cmd)
|
||||
|
||||
// Removing banner
|
||||
output = tools.RemoveFirstLines(output, 8)
|
||||
// count =
|
||||
output = tools.CropAfterExclude(output, "[i] Scan complete.")
|
||||
|
||||
count := 0
|
||||
if err != nil && ctx.Err() != nil {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "scan cancelled"}
|
||||
} else if output != "" {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeOutput, Payload: output}
|
||||
count = strings.Count(output, "[✔]")
|
||||
}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: count}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
61
back/internal/tools/utils.go
Normal file
61
back/internal/tools/utils.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package tools
|
||||
|
||||
import "strings"
|
||||
|
||||
// RemoveFirstLines removes the first n lines. Returns "" if n >= total lines.
|
||||
func RemoveFirstLines(input string, n int) string {
|
||||
lines := strings.Split(input, "\n")
|
||||
if n >= len(lines) {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(lines[n:], "\n")
|
||||
}
|
||||
|
||||
// RemoveLastLines removes the last n lines. Returns "" if n >= total lines.
|
||||
func RemoveLastLines(input string, n int) string {
|
||||
lines := strings.Split(input, "\n")
|
||||
if n >= len(lines) {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(lines[:len(lines)-n], "\n")
|
||||
}
|
||||
|
||||
// CropBefore removes everything before the first occurrence of y (inclusive of y).
|
||||
// Returns input unchanged if y is not found.
|
||||
func CropBefore(input string, y string) string {
|
||||
idx := strings.Index(input, y)
|
||||
if idx == -1 {
|
||||
return input
|
||||
}
|
||||
return input[idx:]
|
||||
}
|
||||
|
||||
// CropAfter removes everything after the last occurrence of y (inclusive of y).
|
||||
// Returns input unchanged if y is not found.
|
||||
func CropAfter(input string, y string) string {
|
||||
idx := strings.LastIndex(input, y)
|
||||
if idx == -1 {
|
||||
return input
|
||||
}
|
||||
return input[:idx+len(y)]
|
||||
}
|
||||
|
||||
// CropBeforeExclude removes everything before and including the first occurrence of y.
|
||||
// Returns input unchanged if y is not found.
|
||||
func CropBeforeExclude(input string, y string) string {
|
||||
idx := strings.Index(input, y)
|
||||
if idx == -1 {
|
||||
return input
|
||||
}
|
||||
return input[idx+len(y):]
|
||||
}
|
||||
|
||||
// CropAfterExclude removes everything from the last occurrence of y onwards.
|
||||
// Returns input unchanged if y is not found.
|
||||
func CropAfterExclude(input string, y string) string {
|
||||
idx := strings.LastIndex(input, y)
|
||||
if idx == -1 {
|
||||
return input
|
||||
}
|
||||
return input[:idx]
|
||||
}
|
||||
126
back/internal/tools/wappalyzer/tool.go
Normal file
126
back/internal/tools/wappalyzer/tool.go
Normal file
@@ -0,0 +1,126 @@
|
||||
package wappalyzer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
wappalyzergo "github.com/projectdiscovery/wappalyzergo"
|
||||
|
||||
"github.com/anotherhadi/iknowyou/internal/tools"
|
||||
)
|
||||
|
||||
const (
|
||||
name = "wappalyzer"
|
||||
description = "Web technology fingerprinting via wappalyzergo — detects CMS, frameworks, web servers, analytics, CDN, and 1500+ other technologies running on a domain."
|
||||
link = "https://github.com/projectdiscovery/wappalyzergo"
|
||||
icon = "wappalyzer"
|
||||
)
|
||||
|
||||
type Runner struct {
|
||||
wappalyze *wappalyzergo.Wappalyze
|
||||
}
|
||||
|
||||
func New() tools.ToolRunner {
|
||||
w, _ := wappalyzergo.New()
|
||||
return &Runner{wappalyze: w}
|
||||
}
|
||||
|
||||
func (r *Runner) Name() string { return name }
|
||||
func (r *Runner) Description() string { return description }
|
||||
func (r *Runner) Link() string { return link }
|
||||
func (r *Runner) Icon() string { return icon }
|
||||
|
||||
func (r *Runner) InputTypes() []tools.InputType {
|
||||
return []tools.InputType{tools.InputTypeDomain}
|
||||
}
|
||||
|
||||
func (r *Runner) Run(ctx context.Context, target string, _ tools.InputType, out chan<- tools.Event) error {
|
||||
defer close(out)
|
||||
|
||||
// Try HTTPS first, fall back to HTTP
|
||||
var (
|
||||
resp *http.Response
|
||||
body []byte
|
||||
err error
|
||||
)
|
||||
for _, scheme := range []string{"https", "http"} {
|
||||
targetURL := fmt.Sprintf("%s://%s", scheme, target)
|
||||
req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
|
||||
if reqErr != nil {
|
||||
continue
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0)")
|
||||
|
||||
resp, err = http.DefaultClient.Do(req)
|
||||
if err == nil {
|
||||
defer resp.Body.Close()
|
||||
body, err = io.ReadAll(resp.Body)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
if ctx.Err() != nil {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "scan cancelled"}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 0}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil || resp == nil {
|
||||
msg := "failed to connect to target"
|
||||
if err != nil {
|
||||
msg = err.Error()
|
||||
}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: msg}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 0}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
|
||||
fingerprints := r.wappalyze.FingerprintWithInfo(resp.Header, body)
|
||||
if len(fingerprints) == 0 {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 0}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Group by category
|
||||
byCategory := make(map[string][]string)
|
||||
for tech, info := range fingerprints {
|
||||
cats := info.Categories
|
||||
if len(cats) == 0 {
|
||||
cats = []string{"Other"}
|
||||
}
|
||||
for _, cat := range cats {
|
||||
byCategory[cat] = append(byCategory[cat], tech)
|
||||
}
|
||||
}
|
||||
|
||||
cats := make([]string, 0, len(byCategory))
|
||||
for c := range byCategory {
|
||||
cats = append(cats, c)
|
||||
}
|
||||
sort.Strings(cats)
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Detected %d technologies\n\n", len(fingerprints)))
|
||||
for _, cat := range cats {
|
||||
techs := byCategory[cat]
|
||||
sort.Strings(techs)
|
||||
sb.WriteString(fmt.Sprintf("%s:\n", cat))
|
||||
for _, t := range techs {
|
||||
sb.WriteString(fmt.Sprintf(" - %s\n", t))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeOutput, Payload: strings.TrimSpace(sb.String())}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: len(fingerprints)}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
62
back/internal/tools/whois/tool.go
Normal file
62
back/internal/tools/whois/tool.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package whois
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"github.com/anotherhadi/iknowyou/internal/tools"
|
||||
)
|
||||
|
||||
const (
|
||||
name = "whois"
|
||||
description = "WHOIS lookup for domain registration and IP ownership information."
|
||||
link = "https://en.wikipedia.org/wiki/WHOIS"
|
||||
icon = ""
|
||||
)
|
||||
|
||||
type Runner struct{}
|
||||
|
||||
func New() tools.ToolRunner { return &Runner{} }
|
||||
|
||||
func (r *Runner) Name() string { return name }
|
||||
func (r *Runner) Description() string { return description }
|
||||
func (r *Runner) Link() string { return link }
|
||||
func (r *Runner) Icon() string { return icon }
|
||||
|
||||
func (r *Runner) InputTypes() []tools.InputType {
|
||||
return []tools.InputType{tools.InputTypeDomain, tools.InputTypeIP}
|
||||
}
|
||||
|
||||
func (r *Runner) Available() (bool, string) {
|
||||
if _, err := exec.LookPath("whois"); err != nil {
|
||||
return false, "whois binary not found in PATH"
|
||||
}
|
||||
return true, ""
|
||||
}
|
||||
|
||||
func (r *Runner) Dependencies() []string { return []string{"whois"} }
|
||||
|
||||
func (r *Runner) Run(ctx context.Context, target string, _ tools.InputType, out chan<- tools.Event) error {
|
||||
defer close(out)
|
||||
|
||||
cmd := exec.CommandContext(ctx, "whois", target)
|
||||
output, err := cmd.Output()
|
||||
|
||||
if err != nil && ctx.Err() != nil {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "scan cancelled"}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 0}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
|
||||
result := strings.TrimSpace(string(output))
|
||||
count := 0
|
||||
if result != "" {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeOutput, Payload: result}
|
||||
count = 1
|
||||
}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: count}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
232
back/internal/tools/whoisfreaks/tool.go
Normal file
232
back/internal/tools/whoisfreaks/tool.go
Normal file
@@ -0,0 +1,232 @@
|
||||
package whoisfreaks
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/anotherhadi/iknowyou/internal/tools"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const (
|
||||
name = "whoisfreaks"
|
||||
description = "Reverse WHOIS lookup via WhoisFreaks — find all domains registered by an email, owner name, or keyword across 3.6B+ WHOIS records."
|
||||
link = "https://whoisfreaks.com"
|
||||
icon = ""
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
APIKey string `yaml:"api_key" iky:"desc=WhoisFreaks API key (required — free account at whoisfreaks.com);required=true"`
|
||||
}
|
||||
|
||||
type Runner struct {
|
||||
cfg Config
|
||||
}
|
||||
|
||||
func New() tools.ToolRunner {
|
||||
cfg := Config{}
|
||||
tools.ApplyDefaults(&cfg)
|
||||
return &Runner{cfg: cfg}
|
||||
}
|
||||
|
||||
func (r *Runner) Name() string { return name }
|
||||
func (r *Runner) Description() string { return description }
|
||||
func (r *Runner) Link() string { return link }
|
||||
func (r *Runner) Icon() string { return icon }
|
||||
|
||||
func (r *Runner) InputTypes() []tools.InputType {
|
||||
return []tools.InputType{
|
||||
tools.InputTypeEmail,
|
||||
tools.InputTypeName,
|
||||
tools.InputTypeDomain,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) ConfigPtr() interface{} { return &r.cfg }
|
||||
|
||||
func (r *Runner) ConfigFields() []tools.ConfigField {
|
||||
return tools.ReflectConfigFields(r.cfg)
|
||||
}
|
||||
|
||||
|
||||
var skipKeys = map[string]bool{
|
||||
"num": true, "status": true, "query_time": true, "update_date": true,
|
||||
"iana_id": true, "whois_server": true, "handle": true,
|
||||
"zip_code": true, "country_code": true, "mailing_address": true,
|
||||
"phone_number": true, "administrative_contact": true, "technical_contact": true,
|
||||
}
|
||||
|
||||
func prettyResult(r gjson.Result, depth int) string {
|
||||
indent := strings.Repeat(" ", depth)
|
||||
var sb strings.Builder
|
||||
r.ForEach(func(key, val gjson.Result) bool {
|
||||
k := key.String()
|
||||
if skipKeys[k] {
|
||||
return true
|
||||
}
|
||||
switch val.Type {
|
||||
case gjson.JSON:
|
||||
if val.IsArray() {
|
||||
arr := val.Array()
|
||||
if len(arr) == 0 {
|
||||
return true
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s%s:\n", indent, k))
|
||||
for _, item := range arr {
|
||||
if item.Type == gjson.JSON {
|
||||
sb.WriteString(fmt.Sprintf("%s -\n", indent))
|
||||
sb.WriteString(prettyResult(item, depth+2))
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("%s - %s\n", indent, item.String()))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sb.WriteString(fmt.Sprintf("%s%s:\n", indent, k))
|
||||
sb.WriteString(prettyResult(val, depth+1))
|
||||
}
|
||||
default:
|
||||
v := val.String()
|
||||
if v == "" {
|
||||
return true
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s%s: %s\n", indent, k, v))
|
||||
}
|
||||
return true
|
||||
})
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func doRequest(ctx context.Context, req *http.Request) ([]byte, *http.Response, error) {
|
||||
for {
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
if err != nil {
|
||||
return nil, resp, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusTooManyRequests {
|
||||
return body, resp, nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, resp, ctx.Err()
|
||||
case <-time.After(60 * time.Second):
|
||||
}
|
||||
// Rebuild the request since the body was consumed
|
||||
req2, err := http.NewRequestWithContext(ctx, req.Method, req.URL.String(), nil)
|
||||
if err != nil {
|
||||
return nil, resp, err
|
||||
}
|
||||
req2.Header = req.Header
|
||||
req = req2
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Runner) Run(ctx context.Context, target string, inputType tools.InputType, out chan<- tools.Event) error {
|
||||
defer close(out)
|
||||
|
||||
params := url.Values{}
|
||||
params.Set("whois", "reverse")
|
||||
params.Set("apiKey", r.cfg.APIKey)
|
||||
|
||||
switch inputType {
|
||||
case tools.InputTypeEmail:
|
||||
params.Set("email", target)
|
||||
case tools.InputTypeName:
|
||||
params.Set("owner", target)
|
||||
case tools.InputTypeDomain:
|
||||
params.Set("keyword", target)
|
||||
default:
|
||||
params.Set("keyword", target)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx,
|
||||
http.MethodGet,
|
||||
"https://api.whoisfreaks.com/v1.0/whois?"+params.Encode(),
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: err.Error()}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 0}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
body, resp, err := doRequest(ctx, req)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "scan cancelled"}
|
||||
} else {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: err.Error()}
|
||||
}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 0}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "invalid or exhausted API key"}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 0}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 0}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: fmt.Sprintf("API error %d: %s", resp.StatusCode, string(body))}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 0}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
|
||||
j := gjson.ParseBytes(body)
|
||||
|
||||
if !j.Get("whois_domains_historical").Exists() {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeError, Payload: "unexpected response format"}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 0}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
|
||||
domains := j.Get("whois_domains_historical").Array()
|
||||
if len(domains) == 0 {
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: 0}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
|
||||
total := j.Get("total_Result").Int()
|
||||
totalPages := j.Get("total_Pages").Int()
|
||||
currentPage := j.Get("current_Page").Int()
|
||||
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("Found %d domain(s)", total))
|
||||
if totalPages > 1 {
|
||||
sb.WriteString(fmt.Sprintf(" across %d pages (showing page %d)", totalPages, currentPage))
|
||||
}
|
||||
sb.WriteString("\n\n")
|
||||
|
||||
for _, d := range domains {
|
||||
sb.WriteString(prettyResult(d, 0))
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeOutput, Payload: strings.TrimSpace(sb.String())}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeCount, Payload: int(total)}
|
||||
out <- tools.Event{Tool: name, Type: tools.EventTypeDone}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user