osintkit 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +0 -0
  2. package/README.md +28 -24
  3. package/osintkit/__init__.py +1 -1
  4. package/osintkit/__pycache__/__init__.cpython-311.pyc +0 -0
  5. package/osintkit/__pycache__/cli.cpython-311.pyc +0 -0
  6. package/osintkit/__pycache__/config.cpython-311.pyc +0 -0
  7. package/osintkit/__pycache__/profiles.cpython-311.pyc +0 -0
  8. package/osintkit/__pycache__/risk.cpython-311.pyc +0 -0
  9. package/osintkit/__pycache__/scanner.cpython-311.pyc +0 -0
  10. package/osintkit/__pycache__/setup.cpython-311.pyc +0 -0
  11. package/osintkit/cli.py +59 -38
  12. package/osintkit/config.py +13 -0
  13. package/osintkit/modules/__init__.py +10 -0
  14. package/osintkit/modules/__pycache__/__init__.cpython-311.pyc +0 -0
  15. package/osintkit/modules/__pycache__/breach.cpython-311.pyc +0 -0
  16. package/osintkit/modules/__pycache__/brokers.cpython-311.pyc +0 -0
  17. package/osintkit/modules/__pycache__/certs.cpython-311.pyc +0 -0
  18. package/osintkit/modules/__pycache__/dark_web.cpython-311.pyc +0 -0
  19. package/osintkit/modules/__pycache__/gravatar.cpython-311.pyc +0 -0
  20. package/osintkit/modules/__pycache__/harvester.cpython-311.pyc +0 -0
  21. package/osintkit/modules/__pycache__/hibp.cpython-311.pyc +0 -0
  22. package/osintkit/modules/__pycache__/hibp_kanon.cpython-311.pyc +0 -0
  23. package/osintkit/modules/__pycache__/holehe.cpython-311.pyc +0 -0
  24. package/osintkit/modules/__pycache__/libphonenumber_info.cpython-311.pyc +0 -0
  25. package/osintkit/modules/__pycache__/paste.cpython-311.pyc +0 -0
  26. package/osintkit/modules/__pycache__/phone.cpython-311.pyc +0 -0
  27. package/osintkit/modules/__pycache__/sherlock.cpython-311.pyc +0 -0
  28. package/osintkit/modules/__pycache__/social.cpython-311.pyc +0 -0
  29. package/osintkit/modules/__pycache__/wayback.cpython-311.pyc +0 -0
  30. package/osintkit/modules/gravatar.py +0 -0
  31. package/osintkit/modules/libphonenumber_info.py +0 -0
  32. package/osintkit/modules/sherlock.py +0 -0
  33. package/osintkit/modules/stage2/__init__.py +0 -0
  34. package/osintkit/modules/stage2/__pycache__/__init__.cpython-311.pyc +0 -0
  35. package/osintkit/modules/stage2/__pycache__/github_api.cpython-311.pyc +0 -0
  36. package/osintkit/modules/stage2/__pycache__/hunter.cpython-311.pyc +0 -0
  37. package/osintkit/modules/stage2/__pycache__/leakcheck.cpython-311.pyc +0 -0
  38. package/osintkit/modules/stage2/__pycache__/numverify.cpython-311.pyc +0 -0
  39. package/osintkit/modules/stage2/__pycache__/securitytrails.cpython-311.pyc +0 -0
  40. package/osintkit/modules/stage2/github_api.py +12 -9
  41. package/osintkit/modules/stage2/hunter.py +7 -5
  42. package/osintkit/modules/stage2/leakcheck.py +7 -5
  43. package/osintkit/modules/stage2/numverify.py +7 -5
  44. package/osintkit/modules/stage2/securitytrails.py +7 -5
  45. package/osintkit/output/__pycache__/__init__.cpython-311.pyc +0 -0
  46. package/osintkit/output/__pycache__/html_writer.cpython-311.pyc +0 -0
  47. package/osintkit/output/__pycache__/json_writer.cpython-311.pyc +0 -0
  48. package/osintkit/output/__pycache__/md_writer.cpython-311.pyc +0 -0
  49. package/osintkit/output/md_writer.py +0 -0
  50. package/osintkit/output/templates/report.html +313 -50
  51. package/osintkit/scanner.py +25 -2
  52. package/osintkit/setup.py +21 -10
  53. package/package.json +10 -2
  54. package/pyproject.toml +1 -1
package/LICENSE CHANGED
File without changes
package/README.md CHANGED
@@ -33,7 +33,9 @@ osintkit open # View profile details + open latest report
33
33
  | `osintkit refresh [id]` | Re-run scan for a profile |
34
34
  | `osintkit open [id]` | Show profile details and open latest report |
35
35
  | `osintkit export [id]` | Export as JSON or Markdown |
36
- | `osintkit setup` | Configure API keys |
36
+ | `osintkit setup` | Configure API keys interactively (preserves existing keys) |
37
+ | `osintkit config set-key <key> <value>` | Update a single API key without touching others |
38
+ | `osintkit config show` | Show which API keys are set (values hidden) |
37
39
  | `osintkit delete [id]` | Delete a profile |
38
40
  | `osintkit version` | Show version |
39
41
 
@@ -56,19 +58,19 @@ osintkit open # View profile details + open latest report
56
58
  | Data brokers | name/email | Google CSE broker scan |
57
59
  | Dark web | email | Ahmia / public index |
58
60
  | Breach lookup | email | BreachDirectory |
61
+ | GitHub | username | Public profile (always runs, no key needed) |
59
62
 
60
- ### Stage 2 — Optional free API keys, runs first when configured
63
+ ### Stage 2 — Optional API keys, unlocks extra data sources
61
64
 
62
- | Service | Input | Free Tier |
63
- |---------|-------|-----------|
64
- | HaveIBeenPwned | email | Free w/ key |
65
+ | Service | Input | Tier |
66
+ |---------|-------|------|
67
+ | HaveIBeenPwned | email | Paid ($3.50/month) |
65
68
  | LeakCheck | email/phone/user | Free tier |
66
- | NumVerify | phone | 100/month |
67
- | Hunter.io | name + domain | 50/month |
68
- | GitHub API | username | 5000/hr w/ key |
69
- | SecurityTrails | domain | Free tier |
69
+ | NumVerify | phone | 100/month free |
70
+ | Hunter.io | email | 25/month free |
71
+ | SecurityTrails | domain | Paid |
70
72
 
71
- Stage 2 always runs first when a key is configured. If rate-limited or key missing, falls back to Stage 1 automatically.
73
+ Stage 2 modules only run when a key is configured. If rate-limited, the scan continues gracefully with Stage 1 results — rate-limited modules are shown as yellow, not red.
72
74
 
73
75
  ## Output
74
76
 
@@ -81,23 +83,25 @@ Risk score is 0–100 based on breach exposure, social footprint, data broker li
81
83
 
82
84
  ## API Keys (All Optional)
83
85
 
84
- Run `osintkit setup` to configure. All keys are optional — the tool works without any of them.
86
+ Keys are stored in `~/.osintkit/config.yaml` (permissions: 600).
85
87
 
86
- ```
87
- ~/.osintkit/config.yaml
88
+ ```bash
89
+ osintkit config set-key hunter YOUR_KEY # add or update one key
90
+ osintkit config show # see which keys are set
91
+ osintkit setup # interactive wizard (preserves existing keys)
88
92
  ```
89
93
 
90
- | Service | Get key at |
91
- |---------|-----------|
92
- | HaveIBeenPwned | haveibeenpwned.com/API/Key |
93
- | LeakCheck | leakcheck.io |
94
- | NumVerify | numverify.com |
95
- | Hunter.io | hunter.io |
96
- | GitHub | github.com/settings/tokens |
97
- | Intelbase | intelbase.is |
98
- | BreachDirectory | rapidapi.com |
99
- | Google CSE | developers.google.com/custom-search |
100
- | SecurityTrails | securitytrails.com |
94
+ | Service | Where to get it | Free? |
95
+ |---------|----------------|-------|
96
+ | HaveIBeenPwned | haveibeenpwned.com/API/Key | Paid ($3.50/mo) |
97
+ | LeakCheck | leakcheck.io | Free tier |
98
+ | NumVerify | numverify.com | 100 req/month free |
99
+ | Hunter.io | hunter.io | 25 req/month free |
100
+ | GitHub | github.com/settings/tokens | Free (raises rate limit) |
101
+ | Intelbase | intelbase.is | 100 req/month free |
102
+ | BreachDirectory | rapidapi.com (search "BreachDirectory") | 50 req/day free |
103
+ | Google CSE | developers.google.com/custom-search | 100 req/day free |
104
+ | SecurityTrails | securitytrails.com | Paid |
101
105
 
102
106
  ## Run from Source
103
107
 
@@ -1,3 +1,3 @@
1
1
  """osintkit - OSINT CLI tool for personal digital footprint analysis."""
2
2
 
3
- __version__ = "0.1.4"
3
+ __version__ = "0.1.5"
package/osintkit/cli.py CHANGED
@@ -17,8 +17,9 @@ from rich.progress import Progress, SpinnerColumn, TextColumn
17
17
 
18
18
  from osintkit import __version__
19
19
  from osintkit.scanner import Scanner
20
- from osintkit.config import load_config, Config, APIKeys
20
+ from osintkit.config import load_config, save_config, Config, APIKeys
21
21
  from osintkit.profiles import Profile, ProfileStore, ScanHistory
22
+ from osintkit.setup import update_api_key
22
23
 
23
24
  app = typer.Typer(help="OSINT CLI for personal digital footprint analysis", invoke_without_command=True)
24
25
  console = Console()
@@ -291,20 +292,15 @@ def run_scan_for_profile(profile: Profile) -> dict:
291
292
 
292
293
  @app.command()
293
294
  def setup():
294
- """Configure API keys."""
295
+ """Configure API keys. Existing keys are preserved unless a new value is entered."""
295
296
  config_path = Path.home() / ".osintkit" / "config.yaml"
296
-
297
- if config_path.exists():
298
- console.print(f"\n[yellow]Config already exists: {config_path}[/yellow]")
299
- if not Confirm.ask("Overwrite?"):
300
- return
301
-
297
+ existing = load_config(config_path)
298
+
302
299
  console.print("\n[bold cyan]═══ API Key Setup ═══[/bold cyan]\n")
303
- console.print("Enter your API keys (press Enter to skip).\n")
304
-
305
- keys = {}
300
+ console.print("Press [bold]Enter[/bold] to keep an existing key. Type a new value to update it.\n")
301
+
306
302
  api_key_list = [
307
- ("hibp", "HaveIBeenPwned (hibp)", "https://haveibeenpwned.com/API/Key"),
303
+ ("hibp", "HaveIBeenPwned", "https://haveibeenpwned.com/API/Key"),
308
304
  ("breachdirectory", "BreachDirectory", "https://rapidapi.com/"),
309
305
  ("leakcheck", "LeakCheck", "https://leakcheck.io/"),
310
306
  ("intelbase", "Intelbase", "https://intelbase.is/"),
@@ -316,37 +312,62 @@ def setup():
316
312
  ("github", "GitHub Personal Access Token", "https://github.com/settings/tokens"),
317
313
  ("securitytrails", "SecurityTrails", "https://securitytrails.com"),
318
314
  ]
319
-
315
+
316
+ keys_dict = existing.api_keys.model_dump()
317
+
320
318
  for key_name, label, url in api_key_list:
319
+ current = keys_dict.get(key_name, "")
320
+ status = "[green][set][/green]" if current else "[dim][not set][/dim]"
321
321
  hint = f" ({url})" if url else ""
322
- value = Prompt.ask(f"{label}{hint}", default="")
323
- keys[key_name] = value.strip()
324
-
325
- config_path.parent.mkdir(parents=True, exist_ok=True)
326
- config_content = f"""# osintkit Configuration
327
- output_dir: ~/osint-results
328
- timeout_seconds: 120
329
-
330
- api_keys:
331
- hibp: "{keys.get('hibp', '')}"
332
- breachdirectory: "{keys.get('breachdirectory', '')}"
333
- leakcheck: "{keys.get('leakcheck', '')}"
334
- intelbase: "{keys.get('intelbase', '')}"
335
- google_cse_key: "{keys.get('google_cse_key', '')}"
336
- google_cse_cx: "{keys.get('google_cse_cx', '')}"
337
- numverify: "{keys.get('numverify', '')}"
338
- emailrep: "{keys.get('emailrep', '')}"
339
- resend: ""
340
- hunter: "{keys.get('hunter', '')}"
341
- github: "{keys.get('github', '')}"
342
- securitytrails: "{keys.get('securitytrails', '')}"
343
- epieos: ""
344
- """
345
- config_path.write_text(config_content)
346
- config_path.chmod(0o600) # API keys must not be world-readable
322
+ value = Prompt.ask(f" {status} {label}{hint}", default="")
323
+ if value.strip():
324
+ keys_dict[key_name] = value.strip()
325
+
326
+ updated = Config(
327
+ output_dir=existing.output_dir,
328
+ timeout_seconds=existing.timeout_seconds,
329
+ api_keys=APIKeys(**keys_dict),
330
+ )
331
+ save_config(updated, config_path)
347
332
  console.print(f"\n[green]✓[/green] Config saved: {config_path}")
348
333
 
349
334
 
335
+ config_app = typer.Typer(help="Manage osintkit configuration.")
336
+ app.add_typer(config_app, name="config")
337
+
338
+
339
+ @config_app.command("set-key")
340
+ def config_set_key(
341
+ key: str = typer.Argument(..., help="API key name (e.g. github, hunter)"),
342
+ value: str = typer.Argument(..., help="The API key value"),
343
+ ):
344
+ """Update a single API key without touching others."""
345
+ valid_keys = set(APIKeys.model_fields.keys())
346
+ if key not in valid_keys:
347
+ console.print(f"[red]Unknown key '{key}'.[/red]")
348
+ console.print(f"Valid keys: {', '.join(sorted(valid_keys))}")
349
+ raise typer.Exit(1)
350
+ update_api_key(key, value)
351
+
352
+
353
+ @config_app.command("show")
354
+ def config_show():
355
+ """Show which API keys are set (values are not shown)."""
356
+ config_path = Path.home() / ".osintkit" / "config.yaml"
357
+ cfg = load_config(config_path)
358
+ keys_dict = cfg.api_keys.model_dump()
359
+
360
+ table = Table(title="API Keys", show_header=True)
361
+ table.add_column("Key", style="cyan")
362
+ table.add_column("Status")
363
+
364
+ for key_name in sorted(keys_dict.keys()):
365
+ status = "[green]set[/green]" if keys_dict[key_name] else "[dim]not set[/dim]"
366
+ table.add_row(key_name, status)
367
+
368
+ console.print(table)
369
+
370
+
350
371
  @app.command()
351
372
  def new():
352
373
  """Create a new person profile."""
@@ -32,6 +32,19 @@ class Config(BaseModel):
32
32
  api_keys: APIKeys = Field(default_factory=APIKeys)
33
33
 
34
34
 
35
+ def save_config(config: Config, config_path: Path) -> None:
36
+ """Save configuration to YAML file."""
37
+ config_path.parent.mkdir(parents=True, exist_ok=True)
38
+ data = {
39
+ "output_dir": config.output_dir,
40
+ "timeout_seconds": config.timeout_seconds,
41
+ "api_keys": config.api_keys.model_dump(),
42
+ }
43
+ with open(config_path, "w") as f:
44
+ yaml.dump(data, f, default_flow_style=False)
45
+ config_path.chmod(0o600)
46
+
47
+
35
48
  def load_config(config_path: Path) -> Config:
36
49
  """Load configuration from YAML file.
37
50
 
@@ -3,4 +3,14 @@
3
3
 
4
4
  class ModuleError(Exception):
5
5
  """Base exception for module failures."""
6
+ pass
7
+
8
+
9
+ class RateLimitError(ModuleError):
10
+ """Raised when an API rate limit (429) is hit."""
11
+ pass
12
+
13
+
14
+ class InvalidKeyError(ModuleError):
15
+ """Raised when an API key is invalid or unauthorized (401/403)."""
6
16
  pass
File without changes
File without changes
File without changes
File without changes
@@ -4,6 +4,8 @@ from typing import Dict, List
4
4
 
5
5
  import httpx
6
6
 
7
+ from osintkit.modules import RateLimitError, InvalidKeyError
8
+
7
9
 
8
10
  async def run(inputs: dict, api_key: str) -> List[Dict]:
9
11
  """Look up a GitHub user profile via the GitHub REST API.
@@ -23,19 +25,20 @@ async def run(inputs: dict, api_key: str) -> List[Dict]:
23
25
  return []
24
26
 
25
27
  try:
28
+ headers = {"Accept": "application/vnd.github.v3+json"}
29
+ if api_key:
30
+ headers["Authorization"] = f"token {api_key}"
31
+
26
32
  async with httpx.AsyncClient(timeout=httpx.Timeout(10.0, connect=5.0)) as client:
27
33
  response = await client.get(
28
34
  f"https://api.github.com/users/{username}",
29
- headers={
30
- "Authorization": f"token {api_key}",
31
- "Accept": "application/vnd.github.v3+json",
32
- },
35
+ headers=headers,
33
36
  )
34
37
 
35
38
  if response.status_code == 429:
36
- raise Exception("429 rate limited")
39
+ raise RateLimitError("GitHub API rate limit reached")
37
40
  if response.status_code in (401, 403):
38
- raise Exception("401 invalid key")
41
+ raise InvalidKeyError("GitHub token invalid or unauthorized")
39
42
  if response.status_code == 404:
40
43
  return []
41
44
 
@@ -59,7 +62,7 @@ async def run(inputs: dict, api_key: str) -> List[Dict]:
59
62
  "url": data.get("html_url"),
60
63
  }]
61
64
 
62
- except Exception as e:
63
- if "429" in str(e) or "401" in str(e):
64
- raise
65
+ except (RateLimitError, InvalidKeyError):
66
+ raise
67
+ except Exception:
65
68
  return []
@@ -4,6 +4,8 @@ from typing import Dict, List
4
4
 
5
5
  import httpx
6
6
 
7
+ from osintkit.modules import RateLimitError, InvalidKeyError
8
+
7
9
 
8
10
  async def run(inputs: dict, api_key: str) -> List[Dict]:
9
11
  """Verify an email address via Hunter.io email-verifier endpoint.
@@ -30,9 +32,9 @@ async def run(inputs: dict, api_key: str) -> List[Dict]:
30
32
  )
31
33
 
32
34
  if response.status_code == 429:
33
- raise Exception("429 rate limited")
35
+ raise RateLimitError("Hunter.io rate limit reached")
34
36
  if response.status_code in (401, 403):
35
- raise Exception("401 invalid key")
37
+ raise InvalidKeyError("Hunter.io API key invalid or unauthorized")
36
38
 
37
39
  data = response.json()
38
40
  result = data.get("data", {})
@@ -58,7 +60,7 @@ async def run(inputs: dict, api_key: str) -> List[Dict]:
58
60
  "url": None,
59
61
  }]
60
62
 
61
- except Exception as e:
62
- if "429" in str(e) or "401" in str(e):
63
- raise
63
+ except (RateLimitError, InvalidKeyError):
64
+ raise
65
+ except Exception:
64
66
  return []
@@ -4,6 +4,8 @@ from typing import Dict, List
4
4
 
5
5
  import httpx
6
6
 
7
+ from osintkit.modules import RateLimitError, InvalidKeyError
8
+
7
9
 
8
10
  async def run(inputs: dict, api_key: str) -> List[Dict]:
9
11
  """Query LeakCheck.io for email breach records.
@@ -30,9 +32,9 @@ async def run(inputs: dict, api_key: str) -> List[Dict]:
30
32
  )
31
33
 
32
34
  if response.status_code == 429:
33
- raise Exception("429 rate limited")
35
+ raise RateLimitError("LeakCheck rate limit reached")
34
36
  if response.status_code in (401, 403):
35
- raise Exception("401 invalid key")
37
+ raise InvalidKeyError("LeakCheck API key invalid or unauthorized")
36
38
 
37
39
  data = response.json()
38
40
  if not data.get("success") or not data.get("result"):
@@ -52,7 +54,7 @@ async def run(inputs: dict, api_key: str) -> List[Dict]:
52
54
  })
53
55
  return findings
54
56
 
55
- except Exception as e:
56
- if "429" in str(e) or "401" in str(e):
57
- raise
57
+ except (RateLimitError, InvalidKeyError):
58
+ raise
59
+ except Exception:
58
60
  return []
@@ -4,6 +4,8 @@ from typing import Dict, List
4
4
 
5
5
  import httpx
6
6
 
7
+ from osintkit.modules import RateLimitError, InvalidKeyError
8
+
7
9
 
8
10
  async def run(inputs: dict, api_key: str) -> List[Dict]:
9
11
  """Validate a phone number via the NumVerify/apilayer API.
@@ -30,9 +32,9 @@ async def run(inputs: dict, api_key: str) -> List[Dict]:
30
32
  )
31
33
 
32
34
  if response.status_code == 429:
33
- raise Exception("429 rate limited")
35
+ raise RateLimitError("NumVerify rate limit reached")
34
36
  if response.status_code in (401, 403):
35
- raise Exception("401 invalid key")
37
+ raise InvalidKeyError("NumVerify API key invalid or unauthorized")
36
38
 
37
39
  data = response.json()
38
40
  if not data.get("valid") and data.get("error"):
@@ -56,7 +58,7 @@ async def run(inputs: dict, api_key: str) -> List[Dict]:
56
58
  "url": None,
57
59
  }]
58
60
 
59
- except Exception as e:
60
- if "429" in str(e) or "401" in str(e):
61
- raise
61
+ except (RateLimitError, InvalidKeyError):
62
+ raise
63
+ except Exception:
62
64
  return []
@@ -4,6 +4,8 @@ from typing import Dict, List
4
4
 
5
5
  import httpx
6
6
 
7
+ from osintkit.modules import RateLimitError, InvalidKeyError
8
+
7
9
 
8
10
  async def run(inputs: dict, api_key: str) -> List[Dict]:
9
11
  """Enumerate subdomains for the target's email domain via SecurityTrails API.
@@ -35,9 +37,9 @@ async def run(inputs: dict, api_key: str) -> List[Dict]:
35
37
  )
36
38
 
37
39
  if response.status_code == 429:
38
- raise Exception("429 rate limited")
40
+ raise RateLimitError("SecurityTrails rate limit reached")
39
41
  if response.status_code in (401, 403):
40
- raise Exception("401 invalid key")
42
+ raise InvalidKeyError("SecurityTrails API key invalid or unauthorized")
41
43
 
42
44
  data = response.json()
43
45
  subdomains = data.get("subdomains", [])
@@ -59,7 +61,7 @@ async def run(inputs: dict, api_key: str) -> List[Dict]:
59
61
  })
60
62
  return findings
61
63
 
62
- except Exception as e:
63
- if "429" in str(e) or "401" in str(e):
64
- raise
64
+ except (RateLimitError, InvalidKeyError):
65
+ raise
66
+ except Exception:
65
67
  return []
File without changes
@@ -3,72 +3,335 @@
3
3
  <head>
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>osintkit Report</title>
6
+ <title>osintkit Report — {{ inputs.name or inputs.email or inputs.username or 'Unknown' }}</title>
7
7
  <style>
8
- body { font-family: system-ui, sans-serif; max-width: 800px; margin: 20px auto; padding: 20px; }
9
- h1 { color: #333; border-bottom: 2px solid #ddd; padding-bottom: 10px; }
10
- .risk-gauge { padding: 20px; border-radius: 8px; text-align: center; margin: 20px 0; }
11
- .risk-high { background: #fee; color: #c00; }
12
- .risk-medium { background: #ffe; color: #960; }
13
- .risk-low { background: #efe; color: #060; }
14
- .module-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px; margin: 20px 0; }
15
- .module-card { padding: 15px; border: 1px solid #ddd; border-radius: 4px; }
16
- .module-done { background: #efe; }
17
- .module-failed { background: #fee; }
18
- .module-skipped { background: #eee; color: #888; }
19
- .findings-section { margin: 20px 0; }
20
- .finding-item { padding: 10px; border-bottom: 1px solid #eee; }
8
+ * { box-sizing: border-box; margin: 0; padding: 0; }
9
+ body { font-family: system-ui, -apple-system, sans-serif; background: #f5f5f5; color: #222; }
10
+ .container { max-width: 900px; margin: 0 auto; padding: 30px 20px; }
11
+
12
+ /* Header */
13
+ .header { background: #1a1a2e; color: white; padding: 30px; border-radius: 10px; margin-bottom: 24px; }
14
+ .header h1 { font-size: 1.4rem; font-weight: 600; margin-bottom: 4px; opacity: 0.7; }
15
+ .header .target { font-size: 1.8rem; font-weight: 700; margin-bottom: 12px; }
16
+ .header .meta { font-size: 0.85rem; opacity: 0.6; }
17
+
18
+ /* Risk score */
19
+ .risk-block { border-radius: 10px; padding: 24px 30px; margin-bottom: 24px; display: flex; align-items: center; gap: 30px; }
20
+ .risk-high { background: #fff0f0; border: 2px solid #e53e3e; }
21
+ .risk-medium { background: #fffbeb; border: 2px solid #d69e2e; }
22
+ .risk-low { background: #f0fff4; border: 2px solid #38a169; }
23
+ .risk-score { font-size: 3.5rem; font-weight: 800; line-height: 1; }
24
+ .risk-high .risk-score { color: #c53030; }
25
+ .risk-medium .risk-score { color: #b7791f; }
26
+ .risk-low .risk-score { color: #276749; }
27
+ .risk-label { font-size: 1.1rem; font-weight: 600; margin-bottom: 4px; }
28
+ .risk-desc { font-size: 0.9rem; opacity: 0.8; max-width: 500px; }
29
+
30
+ /* Score bar */
31
+ .score-bar-wrap { flex: 1; }
32
+ .score-bar-bg { background: #e2e8f0; border-radius: 999px; height: 12px; overflow: hidden; margin-top: 8px; }
33
+ .score-bar-fill { height: 100%; border-radius: 999px; transition: width 0.5s; }
34
+ .risk-high .score-bar-fill { background: #e53e3e; }
35
+ .risk-medium .score-bar-fill { background: #d69e2e; }
36
+ .risk-low .score-bar-fill { background: #38a169; }
37
+
38
+ /* Risk breakdown */
39
+ .breakdown { background: white; border-radius: 10px; padding: 20px 24px; margin-bottom: 24px; }
40
+ .breakdown h2 { font-size: 1rem; font-weight: 600; margin-bottom: 14px; color: #555; text-transform: uppercase; letter-spacing: 0.05em; }
41
+ .breakdown-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); gap: 12px; }
42
+ .breakdown-item { background: #f7f7f7; border-radius: 8px; padding: 12px; text-align: center; }
43
+ .breakdown-item .bi-val { font-size: 1.6rem; font-weight: 700; color: #2d3748; }
44
+ .breakdown-item .bi-label { font-size: 0.78rem; color: #718096; margin-top: 2px; }
45
+
46
+ /* Section card */
47
+ .card { background: white; border-radius: 10px; padding: 24px; margin-bottom: 20px; }
48
+ .card h2 { font-size: 1.05rem; font-weight: 700; margin-bottom: 16px; padding-bottom: 10px; border-bottom: 1px solid #eee; display: flex; align-items: center; gap: 8px; }
49
+ .badge { font-size: 0.75rem; padding: 2px 8px; border-radius: 999px; font-weight: 600; }
50
+ .badge-found { background: #fed7d7; color: #c53030; }
51
+ .badge-none { background: #e2e8f0; color: #4a5568; }
52
+ .badge-failed { background: #feebc8; color: #c05621; }
53
+
54
+ /* Finding items */
55
+ .finding { border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px; margin-bottom: 12px; }
56
+ .finding-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
57
+ .finding-type { font-weight: 600; font-size: 0.95rem; }
58
+ .finding-source { font-size: 0.8rem; color: #718096; background: #edf2f7; padding: 2px 8px; border-radius: 4px; white-space: nowrap; }
59
+ .finding-explanation { font-size: 0.88rem; color: #4a5568; margin-bottom: 10px; line-height: 1.5; }
60
+ .finding-data { background: #f7fafc; border-radius: 6px; padding: 10px 14px; font-size: 0.85rem; }
61
+ .finding-data table { width: 100%; border-collapse: collapse; }
62
+ .finding-data td { padding: 3px 8px 3px 0; vertical-align: top; }
63
+ .finding-data td:first-child { color: #718096; white-space: nowrap; padding-right: 16px; width: 1%; font-size: 0.82rem; }
64
+ .finding-data td a { color: #3182ce; word-break: break-all; }
65
+ .finding-url { margin-top: 8px; font-size: 0.85rem; }
66
+ .finding-url a { color: #3182ce; }
67
+
68
+ /* Module grid */
69
+ .module-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 10px; }
70
+ .module-card { border-radius: 8px; padding: 12px 14px; border: 1px solid transparent; }
71
+ .module-card .mc-name { font-weight: 600; font-size: 0.88rem; margin-bottom: 3px; }
72
+ .module-card .mc-detail { font-size: 0.8rem; opacity: 0.8; }
73
+ .mc-done { background: #f0fff4; border-color: #9ae6b4; }
74
+ .mc-failed { background: #fffaf0; border-color: #fbd38d; }
75
+ .mc-skipped { background: #f7fafc; border-color: #e2e8f0; color: #a0aec0; }
76
+
77
+ /* Recommendations */
78
+ .rec-item { display: flex; gap: 12px; padding: 14px 0; border-bottom: 1px solid #f0f0f0; }
79
+ .rec-item:last-child { border-bottom: none; }
80
+ .rec-icon { font-size: 1.3rem; flex-shrink: 0; margin-top: 1px; }
81
+ .rec-title { font-weight: 600; font-size: 0.92rem; margin-bottom: 3px; }
82
+ .rec-body { font-size: 0.87rem; color: #4a5568; line-height: 1.5; }
83
+
84
+ /* Inputs summary */
85
+ .inputs-row { display: flex; flex-wrap: wrap; gap: 20px; }
86
+ .input-item .input-label { font-size: 0.78rem; color: #718096; text-transform: uppercase; letter-spacing: 0.05em; }
87
+ .input-item .input-val { font-size: 0.95rem; font-weight: 500; }
88
+
89
+ .empty-state { color: #a0aec0; font-size: 0.9rem; text-align: center; padding: 20px; }
21
90
  </style>
22
91
  </head>
23
92
  <body>
24
- <h1>osintkit OSINT Report</h1>
25
- <p><strong>Scan Date:</strong> {{ scan_date }}</p>
26
- <p><strong>Target:</strong> {{ inputs.name or inputs.email or inputs.username or inputs.phone or 'Unknown' }}</p>
27
-
93
+ <div class="container">
94
+
95
+ <!-- Header -->
96
+ <div class="header">
97
+ <h1>osintkit · OSINT Report</h1>
98
+ <div class="target">{{ inputs.name or inputs.email or inputs.username or inputs.phone or 'Unknown Target' }}</div>
99
+ <div class="meta">Scanned: {{ scan_date[:19]|replace('T', ' ') if scan_date else '—' }}</div>
100
+ </div>
101
+
102
+ <!-- Target info -->
103
+ <div class="card">
104
+ <h2>Target Information</h2>
105
+ <div class="inputs-row">
106
+ {% if inputs.name %}
107
+ <div class="input-item"><div class="input-label">Name</div><div class="input-val">{{ inputs.name }}</div></div>
108
+ {% endif %}
109
+ {% if inputs.email %}
110
+ <div class="input-item"><div class="input-label">Email</div><div class="input-val">{{ inputs.email }}</div></div>
111
+ {% endif %}
112
+ {% if inputs.username %}
113
+ <div class="input-item"><div class="input-label">Username</div><div class="input-val">{{ inputs.username }}</div></div>
114
+ {% endif %}
115
+ {% if inputs.phone %}
116
+ <div class="input-item"><div class="input-label">Phone</div><div class="input-val">{{ inputs.phone }}</div></div>
117
+ {% endif %}
118
+ </div>
119
+ </div>
120
+
121
+ <!-- Risk score -->
28
122
  {% set score = risk_score %}
29
123
  {% if score >= 70 %}
30
- <div class="risk-gauge risk-high">
124
+ <div class="risk-block risk-high">
125
+ {% set risk_label = "High Risk" %}
126
+ {% set risk_desc = "Significant personal data is publicly exposed. Multiple findings across breaches, social media, and data brokers. Review and act on recommendations below." %}
31
127
  {% elif score >= 40 %}
32
- <div class="risk-gauge risk-medium">
128
+ <div class="risk-block risk-medium">
129
+ {% set risk_label = "Medium Risk" %}
130
+ {% set risk_desc = "Some personal data is publicly accessible. A moderate digital footprint was found. Consider reviewing the findings and taking action on the most sensitive ones." %}
33
131
  {% else %}
34
- <div class="risk-gauge risk-low">
132
+ <div class="risk-block risk-low">
133
+ {% set risk_label = "Low Risk" %}
134
+ {% set risk_desc = "Limited public exposure found. Your digital footprint appears small. Review any findings below to make sure nothing unexpected was discovered." %}
35
135
  {% endif %}
36
- <h2>Risk Score: {{ score }}/100</h2>
136
+ <div>
137
+ <div class="risk-score">{{ score }}</div>
138
+ <div style="font-size:0.8rem;opacity:0.6;margin-top:2px;">out of 100</div>
139
+ </div>
140
+ <div style="flex:1">
141
+ <div class="risk-label">{{ risk_label }}</div>
142
+ <div class="risk-desc">{{ risk_desc }}</div>
143
+ <div class="score-bar-wrap">
144
+ <div class="score-bar-bg"><div class="score-bar-fill" style="width:{{ score }}%"></div></div>
145
+ </div>
146
+ </div>
37
147
  </div>
38
-
39
- <h2>Module Status</h2>
40
- <div class="module-grid">
41
- {% for module_name, module_data in modules.items() %}
42
- <div class="module-card module-{{ module_data.status }}">
43
- <strong>{{ module_name }}</strong><br>
44
- {% if module_data.status == 'done' %}
45
- {{ module_data.count }} findings
46
- {% elif module_data.status == 'failed' %}
47
- {{ module_data.error }}
48
- {% else %}
49
- skipped
50
- {% endif %}
148
+
149
+ <!-- Score breakdown -->
150
+ {% set breach_count = findings.get('breach_exposure', [])|length %}
151
+ {% set social_count = (findings.get('social_profiles', [])|length) + (findings.get('sherlock', [])|length) %}
152
+ {% set broker_count = findings.get('data_brokers', [])|length %}
153
+ {% set dark_count = (findings.get('dark_web', [])|length) + (findings.get('paste_sites', [])|length) %}
154
+ {% set pw_count = (findings.get('password_exposure', [])|length) + (findings.get('hibp_kanon', [])|length) %}
155
+ {% set total_findings = findings.values()|map('length')|sum %}
156
+
157
+ <div class="breakdown">
158
+ <h2>Score Breakdown</h2>
159
+ <div class="breakdown-grid">
160
+ <div class="breakdown-item">
161
+ <div class="bi-val">{{ total_findings }}</div>
162
+ <div class="bi-label">Total findings</div>
51
163
  </div>
52
- {% endfor %}
164
+ <div class="breakdown-item">
165
+ <div class="bi-val">{{ breach_count }}</div>
166
+ <div class="bi-label">Data breaches</div>
167
+ </div>
168
+ <div class="breakdown-item">
169
+ <div class="bi-val">{{ social_count }}</div>
170
+ <div class="bi-label">Social profiles</div>
171
+ </div>
172
+ <div class="breakdown-item">
173
+ <div class="bi-val">{{ broker_count }}</div>
174
+ <div class="bi-label">Data broker listings</div>
175
+ </div>
176
+ <div class="breakdown-item">
177
+ <div class="bi-val">{{ dark_count }}</div>
178
+ <div class="bi-label">Dark web / paste sites</div>
179
+ </div>
180
+ <div class="breakdown-item">
181
+ <div class="bi-val">{{ pw_count }}</div>
182
+ <div class="bi-label">Password exposures</div>
183
+ </div>
184
+ </div>
53
185
  </div>
54
-
55
- <h2>Findings</h2>
186
+
187
+ <!-- Findings by module -->
188
+ {% set type_explanations = {
189
+ 'social_profile': 'An account linked to this username was found on a public platform. This means the username is registered and potentially active there.',
190
+ 'email_profile': 'This email address is linked to a public profile. Other people can potentially find your name, photo, or other info via this address.',
191
+ 'email_account': 'This email was used to register on this platform. The registration is detectable without logging in.',
192
+ 'password_exposure':'This email or password has appeared in a known data breach. If you reuse passwords, those accounts may be at risk.',
193
+ 'breach': 'Your email or data was found in a breach database. The leaked data may include passwords, personal info, or other sensitive fields.',
194
+ 'web_archive': 'A historical snapshot of content related to this target was found in web archives. Old pages may contain data you thought was deleted.',
195
+ 'certificate': 'An SSL certificate was issued for a domain linked to this target. This can reveal subdomains, services, or infrastructure.',
196
+ 'phone_info': 'Technical information about this phone number was retrieved. This includes carrier, region, and line type — without contacting the number.',
197
+ 'paste': 'Content containing this target\'s data was found on a paste site. Paste sites are often used to dump leaked credentials or personal data.',
198
+ 'dark_web': 'A reference to this target was found on a dark web index or public darknet search. This may indicate leaked data or illicit activity.',
199
+ 'data_broker': 'This target appears in a data broker listing. Data brokers collect and sell personal information including name, address, and relatives.',
200
+ 'web_presence': 'The email domain or username was found in web crawl data, subdomains, or public web content.',
201
+ } %}
202
+
56
203
  {% for module_name, module_findings in findings.items() %}
57
- {% if module_findings %}
58
- <div class="findings-section">
59
- <h3>{{ module_name }} ({{ module_findings|length }})</h3>
60
- {% for finding in module_findings %}
61
- <div class="finding-item">
62
- <strong>{{ finding.type }}</strong>
63
- <span style="color:#888">via {{ finding.source }}</span>
64
- {% if finding.url %}
65
- <a href="{{ finding.url }}" target="_blank">[link]</a>
204
+ {% if module_findings %}
205
+ <div class="card">
206
+ <h2>
207
+ {{ module_name | replace('_', ' ') | title }}
208
+ <span class="badge badge-found">{{ module_findings|length }} found</span>
209
+ </h2>
210
+ {% for finding in module_findings %}
211
+ <div class="finding">
212
+ <div class="finding-header">
213
+ <div class="finding-type">{{ finding.type | replace('_', ' ') | title }}</div>
214
+ <div class="finding-source">via {{ finding.source }}</div>
215
+ </div>
216
+ {% if finding.type in type_explanations %}
217
+ <div class="finding-explanation">{{ type_explanations[finding.type] }}</div>
218
+ {% endif %}
219
+ {% if finding.data %}
220
+ <div class="finding-data">
221
+ <table>
222
+ {% for key, val in finding.data.items() %}
223
+ {% if val and val != '' and val != 'unknown' and key != 'note' %}
224
+ <tr>
225
+ <td>{{ key | replace('_', ' ') }}</td>
226
+ <td>
227
+ {% if val is string and val.startswith('http') %}
228
+ <a href="{{ val }}" target="_blank">{{ val }}</a>
229
+ {% else %}
230
+ {{ val }}
66
231
  {% endif %}
67
- <br><small>Confidence: {{ finding.confidence }}</small>
68
- </div>
232
+ </td>
233
+ </tr>
234
+ {% endif %}
69
235
  {% endfor %}
236
+ {% if finding.data.get('note') %}
237
+ <tr><td colspan="2" style="color:#718096;font-style:italic;padding-top:6px;">{{ finding.data.note }}</td></tr>
238
+ {% endif %}
239
+ </table>
70
240
  </div>
71
- {% endif %}
241
+ {% endif %}
242
+ {% if finding.url %}
243
+ <div class="finding-url">🔗 <a href="{{ finding.url }}" target="_blank">{{ finding.url }}</a></div>
244
+ {% endif %}
245
+ </div>
246
+ {% endfor %}
247
+ </div>
248
+ {% endif %}
72
249
  {% endfor %}
250
+
251
+ <!-- Recommendations -->
252
+ {% if total_findings > 0 %}
253
+ <div class="card">
254
+ <h2>What You Should Do</h2>
255
+ {% if pw_count > 0 %}
256
+ <div class="rec-item">
257
+ <div class="rec-icon">🔑</div>
258
+ <div>
259
+ <div class="rec-title">Change passwords for affected accounts</div>
260
+ <div class="rec-body">Your email appeared in one or more breach databases. Change the password for any account using this email, especially if you reuse passwords. Use a password manager to keep them unique.</div>
261
+ </div>
262
+ </div>
263
+ {% endif %}
264
+ {% if breach_count > 0 %}
265
+ <div class="rec-item">
266
+ <div class="rec-icon">🛡️</div>
267
+ <div>
268
+ <div class="rec-title">Enable two-factor authentication</div>
269
+ <div class="rec-body">Your data was found in breach records. Enable 2FA on your email, banking, and any other important accounts to prevent unauthorized access even if passwords are known.</div>
270
+ </div>
271
+ </div>
272
+ {% endif %}
273
+ {% if broker_count > 0 %}
274
+ <div class="rec-item">
275
+ <div class="rec-icon">🗑️</div>
276
+ <div>
277
+ <div class="rec-title">Request removal from data brokers</div>
278
+ <div class="rec-body">Your information appears in data broker listings. You can request removal directly from each broker. Services like DeleteMe or Incogni can automate this across hundreds of brokers.</div>
279
+ </div>
280
+ </div>
281
+ {% endif %}
282
+ {% if social_count > 0 %}
283
+ <div class="rec-item">
284
+ <div class="rec-icon">👤</div>
285
+ <div>
286
+ <div class="rec-title">Review your social media privacy settings</div>
287
+ <div class="rec-body">{{ social_count }} social profile(s) were found. Check the privacy settings on each platform — restrict who can see your posts, contact info, and friend/follower lists.</div>
288
+ </div>
289
+ </div>
290
+ {% endif %}
291
+ {% if dark_count > 0 %}
292
+ <div class="rec-item">
293
+ <div class="rec-icon">⚠️</div>
294
+ <div>
295
+ <div class="rec-title">Monitor for identity fraud</div>
296
+ <div class="rec-body">References to your data were found on dark web indexes or paste sites. Consider placing a credit freeze with the major credit bureaus and setting up identity monitoring alerts.</div>
297
+ </div>
298
+ </div>
299
+ {% endif %}
300
+ <div class="rec-item">
301
+ <div class="rec-icon">🔄</div>
302
+ <div>
303
+ <div class="rec-title">Re-scan periodically</div>
304
+ <div class="rec-body">Your digital footprint changes over time as new breaches occur and data spreads. Run <code>osintkit refresh</code> every few months to stay up to date.</div>
305
+ </div>
306
+ </div>
307
+ </div>
308
+ {% endif %}
309
+
310
+ <!-- Module status -->
311
+ <div class="card">
312
+ <h2>Modules Run</h2>
313
+ <div class="module-grid">
314
+ {% for module_name, module_data in modules.items() %}
315
+ <div class="module-card mc-{{ module_data.status }}">
316
+ <div class="mc-name">{{ module_name | replace('_', ' ') | title }}</div>
317
+ <div class="mc-detail">
318
+ {% if module_data.status == 'done' %}
319
+ ✓ {{ module_data.count }} finding{{ 's' if module_data.count != 1 else '' }}
320
+ {% elif module_data.status == 'failed' %}
321
+ ⚠ {{ module_data.error | truncate(40) }}
322
+ {% else %}
323
+ — skipped
324
+ {% endif %}
325
+ </div>
326
+ </div>
327
+ {% endfor %}
328
+ </div>
329
+ </div>
330
+
331
+ <div style="text-align:center;color:#a0aec0;font-size:0.8rem;margin-top:20px;padding-bottom:20px;">
332
+ Generated by osintkit · Only use on targets you have permission to investigate · GDPR applies to EU subjects
333
+ </div>
334
+
335
+ </div>
73
336
  </body>
74
337
  </html>
@@ -8,6 +8,7 @@ from rich.console import Console
8
8
  from rich.progress import Progress
9
9
 
10
10
  from osintkit.config import Config
11
+ from osintkit.modules import RateLimitError, InvalidKeyError
11
12
  from osintkit.output.json_writer import write_json
12
13
  from osintkit.output.html_writer import write_html
13
14
  from osintkit.output.md_writer import write_md
@@ -42,6 +43,7 @@ class Scanner:
42
43
  ("wayback", self._run_wayback, "Wayback Machine"),
43
44
  ("phone_info", self._run_phone_info, "Phone analysis"),
44
45
  ("hibp_kanon", self._run_hibp_kanon, "Password k-anonymity check"),
46
+ ("github_api", self._run_stage2_github, "GitHub profile"), # always runs; token optional
45
47
  ]
46
48
 
47
49
  # Stage 2 modules — only included when corresponding API key is set
@@ -50,7 +52,6 @@ class Scanner:
50
52
  ("leakcheck", api_keys.leakcheck, self._run_stage2_leakcheck, "LeakCheck breach lookup"),
51
53
  ("hunter", api_keys.hunter, self._run_stage2_hunter, "Hunter email verify"),
52
54
  ("numverify", api_keys.numverify, self._run_stage2_numverify, "NumVerify phone"),
53
- ("github_api", api_keys.github, self._run_stage2_github, "GitHub profile"),
54
55
  (
55
56
  "securitytrails",
56
57
  api_keys.securitytrails,
@@ -166,6 +167,12 @@ class Scanner:
166
167
  result = await func(inputs)
167
168
  findings["modules"][name] = {"status": "done", "count": len(result)}
168
169
  findings["findings"][name] = result
170
+ except RateLimitError as e:
171
+ findings["modules"][name] = {"status": "rate_limited", "error": str(e)}
172
+ findings["findings"][name] = []
173
+ except InvalidKeyError as e:
174
+ findings["modules"][name] = {"status": "invalid_key", "error": str(e)}
175
+ findings["findings"][name] = []
169
176
  except Exception as e:
170
177
  findings["modules"][name] = {"status": "failed", "error": str(e)}
171
178
  findings["findings"][name] = []
@@ -203,13 +210,29 @@ class Scanner:
203
210
  completed=True,
204
211
  description=f"[green]done {task_info['desc']} ({len(result)})[/green]",
205
212
  )
213
+ except RateLimitError as e:
214
+ findings["modules"][name] = {"status": "rate_limited", "error": str(e)}
215
+ findings["findings"][name] = []
216
+ progress.update(
217
+ task_info["task_id"],
218
+ completed=True,
219
+ description=f"[yellow]rate limited {task_info['desc']}[/yellow]",
220
+ )
221
+ except InvalidKeyError as e:
222
+ findings["modules"][name] = {"status": "invalid_key", "error": str(e)}
223
+ findings["findings"][name] = []
224
+ progress.update(
225
+ task_info["task_id"],
226
+ completed=True,
227
+ description=f"[yellow]invalid key {task_info['desc']}[/yellow]",
228
+ )
206
229
  except Exception as e:
207
230
  findings["modules"][name] = {"status": "failed", "error": str(e)}
208
231
  findings["findings"][name] = []
209
232
  progress.update(
210
233
  task_info["task_id"],
211
234
  completed=True,
212
- description=f"[yellow]failed {task_info['desc']}[/yellow]",
235
+ description=f"[red]failed {task_info['desc']}[/red]",
213
236
  )
214
237
 
215
238
  async def main():
package/osintkit/setup.py CHANGED
@@ -108,19 +108,29 @@ def run_setup_wizard():
108
108
  "google_cse_cx": "",
109
109
  }
110
110
 
111
- # Save config
111
+ # Save config — merge with existing keys so we never wipe anything
112
112
  config_dir = Path.home() / ".osintkit"
113
113
  config_dir.mkdir(parents=True, exist_ok=True)
114
-
115
- config = {
116
- "output_dir": "~/osint-results",
117
- "timeout_seconds": 120,
118
- "api_keys": api_keys,
119
- }
120
-
121
114
  config_path = config_dir / "config.yaml"
115
+
116
+ if config_path.exists():
117
+ with open(config_path) as f:
118
+ existing = yaml.safe_load(f) or {}
119
+ existing_keys = existing.get("api_keys", {})
120
+ else:
121
+ existing = {"output_dir": "~/osint-results", "timeout_seconds": 120}
122
+ existing_keys = {}
123
+
124
+ # Only overwrite keys the user actually provided (non-empty)
125
+ for k, v in api_keys.items():
126
+ if v:
127
+ existing_keys[k] = v
128
+ elif k not in existing_keys:
129
+ existing_keys[k] = ""
130
+
131
+ existing["api_keys"] = existing_keys
122
132
  with open(config_path, "w") as f:
123
- yaml.dump(config, f, default_flow_style=False)
133
+ yaml.dump(existing, f, default_flow_style=False)
124
134
  config_path.chmod(0o600) # owner read/write only — API keys must not be world-readable
125
135
 
126
136
  # Create profiles file
@@ -155,5 +165,6 @@ def update_api_key(key_name: str, key_value: str):
155
165
 
156
166
  with open(config_path, "w") as f:
157
167
  yaml.dump(config, f, default_flow_style=False)
158
-
168
+ config_path.chmod(0o600)
169
+
159
170
  console.print(f"[green]✓[/green] Updated {key_name}")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "osintkit",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "OSINT CLI for personal digital footprint analysis",
5
5
  "bin": {
6
6
  "osintkit": "./bin/osintkit.js"
@@ -24,8 +24,16 @@
24
24
  "cli",
25
25
  "recon"
26
26
  ],
27
- "author": "",
27
+ "author": "diesesschnitzel",
28
28
  "license": "MIT",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "https://github.com/diesesschnitzel/osintkit.git"
32
+ },
33
+ "homepage": "https://github.com/diesesschnitzel/osintkit#api-keys-all-optional",
34
+ "bugs": {
35
+ "url": "https://github.com/diesesschnitzel/osintkit/issues"
36
+ },
29
37
  "engines": {
30
38
  "node": ">=16",
31
39
  "python": ">=3.10"
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "osintkit"
3
- version = "0.1.4"
3
+ version = "0.1.5"
4
4
  description = "OSINT CLI tool for personal digital footprint analysis"
5
5
  authors = ["Your Name <you@example.com>"]
6
6
  license = "MIT"