abyss-scanner 0.1.0__tar.gz

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.
@@ -0,0 +1,82 @@
1
+ Metadata-Version: 2.4
2
+ Name: abyss-scanner
3
+ Version: 0.1.0
4
+ Summary: Abyss — CLI que mergulha nas profundezas do seu código pra achar secrets vazados e dependências vulneráveis
5
+ Author-email: Lohane <lohane.mdev@gmail.com>
6
+ License: MIT
7
+ Requires-Python: >=3.9
8
+ Requires-Dist: packaging>=24.0
9
+ Requires-Dist: pygithub>=2.3.0
10
+ Requires-Dist: questionary>=2.0.0
11
+ Requires-Dist: requests>=2.31.0
12
+ Requires-Dist: rich>=13.7.0
13
+ Requires-Dist: typer>=0.12.0
14
+ Description-Content-Type: text/markdown
15
+
16
+ # abyss
17
+
18
+ > CLI que mergulha nas profundezas do seu código pra achar o que tava
19
+ > escondido: secrets vazados e dependências vulneráveis.
20
+ >
21
+ > Nome e mascote inspirados na lula-vampira-do-inferno
22
+ > (*Vampyroteuthis infernalis*) — criatura que vive nas profundezas onde
23
+ > não chega luz.
24
+
25
+ Multiplataforma (Windows, macOS, Linux). Escaneia repositórios em busca de:
26
+
27
+ - **Secrets vazados** (chaves de API, tokens, senhas commitadas) via
28
+ [gitleaks](https://github.com/gitleaks/gitleaks)
29
+ - **Dependências vulneráveis** em projetos Python (`pip-audit`) e Node
30
+ (`npm audit`)
31
+
32
+ ## Instalação (dev)
33
+
34
+ ```bash
35
+ pip install -e .
36
+ ```
37
+
38
+ ## Uso
39
+
40
+ Rodar sem argumento nenhum abre o menu interativo:
41
+
42
+ ```bash
43
+ abyss
44
+ ```
45
+
46
+ Ou direto por linha de comando:
47
+
48
+ ```bash
49
+ abyss scan . # escaneia o diretório atual
50
+ abyss scan /caminho/pro/repo # escaneia outro caminho
51
+ abyss scan . --secrets-only # só secrets
52
+ abyss scan . --deps-only # só dependências
53
+ abyss scan . --json report.json # salva relatório em JSON
54
+ ```
55
+
56
+ Na primeira execução, o binário do `gitleaks` é baixado automaticamente
57
+ para a plataforma atual e guardado em cache local (não precisa instalar
58
+ nada manualmente).
59
+
60
+ Para o scan de dependências, `pip-audit` (Python) e `npm` (Node) precisam
61
+ estar disponíveis no PATH quando aplicável — se não estiverem, aquele
62
+ scan é pulado silenciosamente.
63
+
64
+ ## Segurança
65
+
66
+ Secrets encontrados **nunca** aparecem em texto puro — são sempre
67
+ redigidos (`redact()`), mostrando só os últimos 4 caracteres. Isso vale
68
+ pra saída de terminal, JSON exportado e qualquer log.
69
+
70
+ ## Mascote
71
+
72
+ O ASCII art da lula-vampira fica em `abyss/ui/banner.py`, na constante
73
+ `MASCOT_ART` — troque o placeholder pelo desenho final quando estiver
74
+ pronto.
75
+
76
+ ## Roadmap
77
+
78
+ - [ ] Publicar no PyPI
79
+ - [ ] Integração com GitHub API — abrir issue automática ao encontrar problema
80
+ - [ ] Suporte a mais ecossistemas (Cargo, Go modules)
81
+ - [ ] Modo `--watch` para rodar em CI/pre-commit hook
82
+ - [x] Nome definitivo: **Abyss**
@@ -0,0 +1,67 @@
1
+ # abyss
2
+
3
+ > CLI que mergulha nas profundezas do seu código pra achar o que tava
4
+ > escondido: secrets vazados e dependências vulneráveis.
5
+ >
6
+ > Nome e mascote inspirados na lula-vampira-do-inferno
7
+ > (*Vampyroteuthis infernalis*) — criatura que vive nas profundezas onde
8
+ > não chega luz.
9
+
10
+ Multiplataforma (Windows, macOS, Linux). Escaneia repositórios em busca de:
11
+
12
+ - **Secrets vazados** (chaves de API, tokens, senhas commitadas) via
13
+ [gitleaks](https://github.com/gitleaks/gitleaks)
14
+ - **Dependências vulneráveis** em projetos Python (`pip-audit`) e Node
15
+ (`npm audit`)
16
+
17
+ ## Instalação (dev)
18
+
19
+ ```bash
20
+ pip install -e .
21
+ ```
22
+
23
+ ## Uso
24
+
25
+ Rodar sem argumento nenhum abre o menu interativo:
26
+
27
+ ```bash
28
+ abyss
29
+ ```
30
+
31
+ Ou direto por linha de comando:
32
+
33
+ ```bash
34
+ abyss scan . # escaneia o diretório atual
35
+ abyss scan /caminho/pro/repo # escaneia outro caminho
36
+ abyss scan . --secrets-only # só secrets
37
+ abyss scan . --deps-only # só dependências
38
+ abyss scan . --json report.json # salva relatório em JSON
39
+ ```
40
+
41
+ Na primeira execução, o binário do `gitleaks` é baixado automaticamente
42
+ para a plataforma atual e guardado em cache local (não precisa instalar
43
+ nada manualmente).
44
+
45
+ Para o scan de dependências, `pip-audit` (Python) e `npm` (Node) precisam
46
+ estar disponíveis no PATH quando aplicável — se não estiverem, aquele
47
+ scan é pulado silenciosamente.
48
+
49
+ ## Segurança
50
+
51
+ Secrets encontrados **nunca** aparecem em texto puro — são sempre
52
+ redigidos (`redact()`), mostrando só os últimos 4 caracteres. Isso vale
53
+ pra saída de terminal, JSON exportado e qualquer log.
54
+
55
+ ## Mascote
56
+
57
+ O ASCII art da lula-vampira fica em `abyss/ui/banner.py`, na constante
58
+ `MASCOT_ART` — troque o placeholder pelo desenho final quando estiver
59
+ pronto.
60
+
61
+ ## Roadmap
62
+
63
+ - [ ] Publicar no PyPI
64
+ - [ ] Integração com GitHub API — abrir issue automática ao encontrar problema
65
+ - [ ] Suporte a mais ecossistemas (Cargo, Go modules)
66
+ - [ ] Modo `--watch` para rodar em CI/pre-commit hook
67
+ - [x] Nome definitivo: **Abyss**
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,147 @@
1
+ """CLI principal do abyss-cli."""
2
+
3
+ from pathlib import Path
4
+ from typing import Optional
5
+
6
+ import typer
7
+ from rich.console import Console
8
+ from rich.table import Table
9
+
10
+ from abyss import __version__
11
+ from abyss.scanners import dependencies, secrets
12
+
13
+ app = typer.Typer(
14
+ name="abyss",
15
+ help="Escaneia repositórios em busca de secrets vazados e dependências vulneráveis.",
16
+ no_args_is_help=False,
17
+ invoke_without_command=True,
18
+ )
19
+ console = Console()
20
+
21
+
22
+ def _version_callback(value: bool):
23
+ if value:
24
+ console.print(f"abyss-cli v{__version__}")
25
+ raise typer.Exit()
26
+
27
+
28
+ @app.callback(invoke_without_command=True)
29
+ def main(
30
+ ctx: typer.Context,
31
+ version: Optional[bool] = typer.Option(
32
+ None, "--version", callback=_version_callback, is_eager=True,
33
+ help="Mostra a versão e sai.",
34
+ ),
35
+ ):
36
+ """Sem subcomando -> abre o menu interativo."""
37
+ if ctx.invoked_subcommand is None:
38
+ from abyss.ui.menu import run_interactive
39
+
40
+ run_interactive()
41
+ raise typer.Exit()
42
+
43
+
44
+ @app.command()
45
+ def scan(
46
+ path: str = typer.Argument(".", help="Caminho do repositório a escanear."),
47
+ secrets_only: bool = typer.Option(False, "--secrets-only", help="Roda só o scan de secrets."),
48
+ deps_only: bool = typer.Option(False, "--deps-only", help="Roda só o scan de dependências."),
49
+ json_output: Optional[Path] = typer.Option(None, "--json", help="Salva resultado em JSON no caminho informado."),
50
+ ):
51
+ """Escaneia um repositório local em busca de secrets e dependências vulneráveis."""
52
+ from abyss.ui.banner import show_banner
53
+
54
+ show_banner()
55
+
56
+ repo_path = str(Path(path).resolve())
57
+ if not Path(repo_path).exists():
58
+ console.print(f"[red]Erro:[/red] caminho não encontrado: {repo_path}")
59
+ raise typer.Exit(code=1)
60
+
61
+ console.print(f"[bold cyan]abyss[/bold cyan] escaneando [bold]{repo_path}[/bold]\n")
62
+
63
+ secret_findings = []
64
+ dep_findings = []
65
+
66
+ if not deps_only:
67
+ with console.status("[cyan]Procurando secrets vazados...[/cyan]"):
68
+ secret_findings = secrets.scan_repo(repo_path, progress_callback=console.print)
69
+ _print_secret_findings(secret_findings)
70
+
71
+ if not secrets_only:
72
+ with console.status("[cyan]Auditando dependências...[/cyan]"):
73
+ dep_findings = dependencies.scan_repo(repo_path)
74
+ _print_dependency_findings(dep_findings)
75
+
76
+ total = len(secret_findings) + len(dep_findings)
77
+ if total == 0:
78
+ console.print("\n[bold green]✓ Nenhum problema encontrado.[/bold green]")
79
+ else:
80
+ console.print(
81
+ f"\n[bold yellow]⚠ {total} problema(s) encontrado(s)[/bold yellow] "
82
+ f"({len(secret_findings)} secret(s), {len(dep_findings)} dependência(s) vulnerável(is))"
83
+ )
84
+
85
+ if json_output:
86
+ _write_json(json_output, secret_findings, dep_findings, repo_path)
87
+ console.print(f"[dim]Relatório salvo em {json_output}[/dim]")
88
+
89
+ if total > 0:
90
+ raise typer.Exit(code=1)
91
+
92
+
93
+ def _print_secret_findings(findings):
94
+ if not findings:
95
+ console.print("[green]✓[/green] Nenhum secret encontrado.\n")
96
+ return
97
+
98
+ table = Table(title="Secrets encontrados")
99
+ table.add_column("Arquivo", style="cyan")
100
+ table.add_column("Linha")
101
+ table.add_column("Regra", style="yellow")
102
+ table.add_column("Secret (redigido)", style="red")
103
+
104
+ for f in findings:
105
+ table.add_row(f.file, str(f.line_number), f.rule_id, f.redacted_secret)
106
+
107
+ console.print(table)
108
+ console.print()
109
+
110
+
111
+ def _print_dependency_findings(findings):
112
+ if not findings:
113
+ console.print("[green]✓[/green] Nenhuma dependência vulnerável encontrada.\n")
114
+ return
115
+
116
+ table = Table(title="Dependências vulneráveis")
117
+ table.add_column("Pacote", style="cyan")
118
+ table.add_column("Versão instalada")
119
+ table.add_column("Vulnerabilidade", style="yellow")
120
+ table.add_column("Severidade")
121
+ table.add_column("Correção", style="green")
122
+ table.add_column("Ecossistema")
123
+
124
+ for f in findings:
125
+ table.add_row(
126
+ f.package, f.installed_version, f.vulnerability_id,
127
+ f.severity, f.fix_version or "-", f.ecosystem,
128
+ )
129
+
130
+ console.print(table)
131
+ console.print()
132
+
133
+
134
+ def _write_json(output_path: Path, secret_findings, dep_findings, repo_path: str):
135
+ import json as json_lib
136
+ from dataclasses import asdict
137
+
138
+ report = {
139
+ "repo": repo_path,
140
+ "secrets": [asdict(f) for f in secret_findings],
141
+ "dependencies": [asdict(f) for f in dep_findings],
142
+ }
143
+ output_path.write_text(json_lib.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
144
+
145
+
146
+ if __name__ == "__main__":
147
+ app()
File without changes
@@ -0,0 +1,82 @@
1
+ """Baixa e gerencia o binário do gitleaks para a plataforma atual.
2
+
3
+ O gitleaks é distribuído como binário standalone por release no GitHub,
4
+ então cada SO/arquitetura tem um asset diferente. Baixamos uma vez e
5
+ guardamos em abyss_home() pra não repetir o download a cada scan.
6
+ """
7
+
8
+ import stat
9
+ import tarfile
10
+ import zipfile
11
+ from pathlib import Path
12
+
13
+ import requests
14
+
15
+ from abyss.utils.platform import binary_name, get_arch, get_os, abyss_home
16
+
17
+ GITLEAKS_VERSION = "8.18.4"
18
+ GITLEAKS_REPO = "gitleaks/gitleaks"
19
+
20
+
21
+ def _asset_name() -> str:
22
+ """Monta o nome do asset de release do gitleaks pra plataforma atual."""
23
+ os_name = get_os()
24
+ arch = get_arch()
25
+
26
+ os_map = {"windows": "windows", "darwin": "darwin", "linux": "linux"}
27
+ arch_map = {"x64": "x64", "arm64": "arm64", "x32": "x32"}
28
+
29
+ ext = "zip" if os_name == "windows" else "tar.gz"
30
+ return f"gitleaks_{GITLEAKS_VERSION}_{os_map[os_name]}_{arch_map.get(arch, 'x64')}.{ext}"
31
+
32
+
33
+ def gitleaks_path() -> Path:
34
+ return abyss_home() / binary_name("gitleaks")
35
+
36
+
37
+ def is_installed() -> bool:
38
+ return gitleaks_path().exists()
39
+
40
+
41
+ def install(progress_callback=None) -> Path:
42
+ """Baixa e extrai o binário do gitleaks se ainda não estiver instalado."""
43
+ target = gitleaks_path()
44
+ if target.exists():
45
+ return target
46
+
47
+ asset = _asset_name()
48
+ url = f"https://github.com/{GITLEAKS_REPO}/releases/download/v{GITLEAKS_VERSION}/{asset}"
49
+
50
+ download_dir = abyss_home() / "downloads"
51
+ download_dir.mkdir(exist_ok=True)
52
+ archive_path = download_dir / asset
53
+
54
+ if progress_callback:
55
+ progress_callback(f"Baixando gitleaks v{GITLEAKS_VERSION} para {get_os()}/{get_arch()}...")
56
+
57
+ response = requests.get(url, stream=True, timeout=60)
58
+ response.raise_for_status()
59
+
60
+ with open(archive_path, "wb") as f:
61
+ for chunk in response.iter_content(chunk_size=8192):
62
+ f.write(chunk)
63
+
64
+ if progress_callback:
65
+ progress_callback("Extraindo binário...")
66
+
67
+ if asset.endswith(".zip"):
68
+ with zipfile.ZipFile(archive_path, "r") as zf:
69
+ zf.extractall(download_dir)
70
+ else:
71
+ with tarfile.open(archive_path, "r:gz") as tf:
72
+ tf.extractall(download_dir)
73
+
74
+ extracted_binary = download_dir / binary_name("gitleaks")
75
+ extracted_binary.replace(target)
76
+
77
+ if get_os() != "windows":
78
+ target.chmod(target.stat().st_mode | stat.S_IEXEC | stat.S_IXGRP | stat.S_IXOTH)
79
+
80
+ archive_path.unlink(missing_ok=True)
81
+
82
+ return target
File without changes
@@ -0,0 +1,127 @@
1
+ """Scanner de dependências vulneráveis: Python (pip-audit) e Node (npm audit).
2
+
3
+ Detecta automaticamente qual ecossistema está presente no repositório
4
+ (requirements.txt/pyproject.toml para Python, package.json para Node)
5
+ e roda a ferramenta apropriada.
6
+ """
7
+
8
+ import json
9
+ import shutil
10
+ import subprocess
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import List
14
+
15
+
16
+ @dataclass
17
+ class DependencyFinding:
18
+ package: str
19
+ installed_version: str
20
+ vulnerability_id: str
21
+ severity: str
22
+ fix_version: str = ""
23
+ ecosystem: str = "python"
24
+
25
+
26
+ def _tool_available(name: str) -> bool:
27
+ return shutil.which(name) is not None
28
+
29
+
30
+ def scan_python(repo_path: str) -> List[DependencyFinding]:
31
+ """Roda pip-audit se houver requirements.txt ou pyproject.toml."""
32
+ repo = Path(repo_path)
33
+ requirements_file = repo / "requirements.txt"
34
+ pyproject_file = repo / "pyproject.toml"
35
+
36
+ if not requirements_file.exists() and not pyproject_file.exists():
37
+ return []
38
+
39
+ if not _tool_available("pip-audit"):
40
+ return []
41
+
42
+ # requirements.txt precisa da flag -r; pyproject.toml é lido via
43
+ # project_path posicional (pip-audit entende PEP 621 automaticamente).
44
+ if requirements_file.exists():
45
+ command = ["pip-audit", "-r", str(requirements_file), "--format", "json"]
46
+ else:
47
+ command = ["pip-audit", str(repo), "--format", "json"]
48
+
49
+ result = subprocess.run(
50
+ command,
51
+ capture_output=True,
52
+ text=True,
53
+ timeout=180,
54
+ )
55
+
56
+ findings = []
57
+ try:
58
+ data = json.loads(result.stdout) if result.stdout.strip() else {"dependencies": []}
59
+ except json.JSONDecodeError:
60
+ return []
61
+
62
+ for dep in data.get("dependencies", []):
63
+ for vuln in dep.get("vulns", []):
64
+ findings.append(
65
+ DependencyFinding(
66
+ package=dep.get("name", "unknown"),
67
+ installed_version=dep.get("version", ""),
68
+ vulnerability_id=vuln.get("id", ""),
69
+ severity=vuln.get("severity", "unknown") or "unknown",
70
+ fix_version=", ".join(vuln.get("fix_versions", [])),
71
+ ecosystem="python",
72
+ )
73
+ )
74
+ return findings
75
+
76
+
77
+ def scan_node(repo_path: str) -> List[DependencyFinding]:
78
+ """Roda npm audit se houver package.json."""
79
+ repo = Path(repo_path)
80
+ if not (repo / "package.json").exists():
81
+ return []
82
+
83
+ if not _tool_available("npm"):
84
+ return []
85
+
86
+ result = subprocess.run(
87
+ ["npm", "audit", "--json"],
88
+ capture_output=True,
89
+ text=True,
90
+ timeout=180,
91
+ cwd=repo_path,
92
+ )
93
+
94
+ findings = []
95
+ try:
96
+ data = json.loads(result.stdout) if result.stdout.strip() else {}
97
+ except json.JSONDecodeError:
98
+ return []
99
+
100
+ vulnerabilities = data.get("vulnerabilities", {})
101
+ for pkg_name, vuln_info in vulnerabilities.items():
102
+ via = vuln_info.get("via", [])
103
+ vuln_id = ""
104
+ for v in via:
105
+ if isinstance(v, dict):
106
+ vuln_id = str(v.get("source", ""))
107
+ break
108
+
109
+ findings.append(
110
+ DependencyFinding(
111
+ package=pkg_name,
112
+ installed_version=vuln_info.get("range", ""),
113
+ vulnerability_id=vuln_id or "n/a",
114
+ severity=vuln_info.get("severity", "unknown"),
115
+ fix_version=str(vuln_info.get("fixAvailable", "")),
116
+ ecosystem="node",
117
+ )
118
+ )
119
+ return findings
120
+
121
+
122
+ def scan_repo(repo_path: str) -> List[DependencyFinding]:
123
+ """Roda todos os scanners de dependência aplicáveis ao repositório."""
124
+ findings = []
125
+ findings.extend(scan_python(repo_path))
126
+ findings.extend(scan_node(repo_path))
127
+ return findings
@@ -0,0 +1,79 @@
1
+ """Scanner de secrets vazados, usando gitleaks como engine de detecção."""
2
+
3
+ import json
4
+ import subprocess
5
+ import tempfile
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import List
9
+
10
+ from abyss.core.installer import gitleaks_path, install, is_installed
11
+ from abyss.utils.redact import redact
12
+
13
+
14
+ @dataclass
15
+ class SecretFinding:
16
+ rule_id: str
17
+ file: str
18
+ line_number: int
19
+ redacted_secret: str
20
+ commit: str = ""
21
+ severity: str = "high"
22
+
23
+
24
+ def ensure_gitleaks(progress_callback=None) -> Path:
25
+ if not is_installed():
26
+ return install(progress_callback=progress_callback)
27
+ return gitleaks_path()
28
+
29
+
30
+ def scan_repo(repo_path: str, progress_callback=None) -> List[SecretFinding]:
31
+ """Roda gitleaks contra um diretório local e retorna findings já
32
+ redigidos (o secret cru nunca fica em memória além dessa função)."""
33
+
34
+ binary = ensure_gitleaks(progress_callback=progress_callback)
35
+ is_git_repo = (Path(repo_path) / ".git").exists()
36
+
37
+ with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as tmp:
38
+ report_path = tmp.name
39
+
40
+ try:
41
+ command = [
42
+ str(binary),
43
+ "detect",
44
+ "--source", repo_path,
45
+ "--report-format", "json",
46
+ "--report-path", report_path,
47
+ "--exit-code", "0",
48
+ "--no-banner",
49
+ ]
50
+ if not is_git_repo:
51
+ command.append("--no-git")
52
+
53
+ result = subprocess.run(
54
+ command,
55
+ capture_output=True,
56
+ text=True,
57
+ timeout=300,
58
+ )
59
+
60
+ report_file = Path(report_path)
61
+ content = report_file.read_text(encoding="utf-8").strip()
62
+ raw_findings = json.loads(content) if content else []
63
+
64
+ findings = []
65
+ for item in raw_findings:
66
+ raw_secret = item.get("Secret", "")
67
+ findings.append(
68
+ SecretFinding(
69
+ rule_id=item.get("RuleID", "unknown"),
70
+ file=item.get("File", ""),
71
+ line_number=item.get("StartLine", 0),
72
+ redacted_secret=redact(raw_secret),
73
+ commit=item.get("Commit", "")[:8],
74
+ )
75
+ )
76
+ return findings
77
+
78
+ finally:
79
+ Path(report_path).unlink(missing_ok=True)
File without changes
@@ -0,0 +1,123 @@
1
+ """Banner ASCII e identidade visual do abyss-cli.
2
+ Layout inspirado em splash screens clássicas de ferramenta de terminal:
3
+ título no canto esquerdo, arte original ao lado, seguido da tagline,
4
+ linha de status e prompt de "iniciando" antes de cair no menu.
5
+ """
6
+ from rich.console import Console
7
+ from rich.text import Text
8
+
9
+ console = Console()
10
+
11
+ MASCOT_ART = r"""
12
+
13
+
14
+
15
+ --
16
+ =+--=+# =++-
17
+ -*- --+ +* =-+--
18
+ - =+- --= -==--=+= =+*+==* -++
19
+ =+- ++=+- --=--=+---*= --+*=--== =++
20
+ -== =+++ ---=--===----------------=-=++- ++* =--==
21
+ --=- -=*-------= -=+=-----------------++==---=-=+= -----=
22
+ =--=----------------=---=+=------------= -=++=----------------= -=
23
+ =-----------=----=--=---==-----------=--=+==-------------------++
24
+ ----------------=----==---------=--===----------==---==
25
+ -------------- ---===----------==------------=+ --
26
+ -- ----------------------------==---=--=-==+
27
+ -=-----==-=-- --- ----------==-----=*-
28
+ --=-- - ----- ---------------------=+*
29
+ =**+=--= ----------------- =*#%
30
+ @█**+-==------------------------==--+%@█ -
31
+ +*+++--------------------------+++**
32
+ -=------------=--------------=+*# -
33
+ ==------=------=-===--==----=+*
34
+ - --------------=-=----=--=====-===**----
35
+ --------------------==-===-=======-------------------
36
+ -==-----------------=---------=+--=-==-----------=-
37
+ ---=====-+%**-==---=--=---=#%#+====--=====-
38
+ =--=%%=-=---+%*#*#*+
39
+ -++++++++***+= -
40
+ -====-- -
41
+ """
42
+
43
+ TITLE_BLOCK = r"""
44
+ ▄▄▄ ▄▄▄▄ ▓██ ██▓ ██████ ██████
45
+ ▒████▄ ▓█████▄▒██ ██▒▒██ ▒ ▒██ ▒
46
+ ▒██ ▀█▄ ▒██▒ ▄██▒██ ██░░ ▓██▄ ░ ▓██▄
47
+ ░██▄▄▄▄██ ▒██░█▀ ░ ▐██▓░ ▒ ██▒ ▒ ██▒
48
+ ▓█ ▓██▒░▓█ ▀█▓░ ██▒▓░▒██████▒▒▒██████▒▒
49
+ ▒▒ ▓▒█░░▒▓███▀▒ ██▒▒▒ ▒ ▒▓▒ ▒ ░▒ ▒▓▒ ▒ ░
50
+ ▒ ▒▒ ░▒░▒ ░▓██ ░▒░ ░ ░▒ ░ ░░ ░▒ ░ ░
51
+ ░ ▒ ░ ░▒ ▒ ░░ ░ ░ ░ ░ ░ ░
52
+ ░ ░ ░ ░ ░ ░ ░
53
+ ░░ ░
54
+ """
55
+
56
+ TITLE = "ABYSS"
57
+ SUBTITLE = "Next-Gen Deep Sea Exploration Platform"
58
+ STRAPLINE = "ABYSS Security — Just one bite"
59
+ STATUS_LINE = "[ STATUS: ACTIVE ]"
60
+ START_LINE = "Descendo ao abismo..."
61
+
62
+
63
+ def _combine_side_by_side(
64
+ left: str,
65
+ right: str,
66
+ gap: int = 3,
67
+ left_style: str = "bold red",
68
+ right_style: str = "bold magenta",
69
+ valign: str = "center",
70
+ ) -> Text:
71
+ """Junta dois blocos ASCII lado a lado, alinhados à esquerda (sem centralizar)."""
72
+ left_lines = left.strip("\n").split("\n")
73
+ right_lines = right.strip("\n").split("\n")
74
+
75
+ left_width = max(len(l) for l in left_lines)
76
+ height = max(len(left_lines), len(right_lines))
77
+
78
+ def pad_block(lines, target_height):
79
+ missing = target_height - len(lines)
80
+ if missing <= 0:
81
+ return lines
82
+ if valign == "center":
83
+ top = missing // 2
84
+ bottom = missing - top
85
+ elif valign == "bottom":
86
+ top, bottom = missing, 0
87
+ else: # "top"
88
+ top, bottom = 0, missing
89
+ return [""] * top + lines + [""] * bottom
90
+
91
+ left_lines = pad_block(left_lines, height)
92
+ right_lines = pad_block(right_lines, height)
93
+
94
+ text = Text()
95
+ for l, r in zip(left_lines, right_lines):
96
+ text.append(l.ljust(left_width), style=left_style)
97
+ text.append(" " * gap)
98
+ text.append(r, style=right_style)
99
+ text.append("\n")
100
+ return text
101
+
102
+
103
+ def show_banner():
104
+ combined = _combine_side_by_side(
105
+ TITLE_BLOCK,
106
+ MASCOT_ART,
107
+ gap=3,
108
+ left_style="bold red",
109
+ right_style="bold magenta",
110
+ valign="center",
111
+ )
112
+ console.print(combined, soft_wrap=True)
113
+ console.print(Text(f" {SUBTITLE}", style="bold yellow"))
114
+ console.print(Text(f" {STRAPLINE}", style="bold green"))
115
+ console.print(Text(f" {STATUS_LINE}", style="bold red"))
116
+ console.print(Text(f" {START_LINE}", style="bold magenta"))
117
+ console.print()
118
+
119
+
120
+ def section(title: str):
121
+ console.print()
122
+ console.rule(f"[bold cyan] {title} [/bold cyan]", style="cyan")
123
+ console.print()
@@ -0,0 +1,125 @@
1
+ """Menu interativo estilo ferramenta OSINT — navegação por setas, sem
2
+ precisar decorar flags de comando."""
3
+
4
+ import questionary
5
+ from questionary import Style
6
+ from rich.console import Console
7
+
8
+ from abyss.ui.banner import section, show_banner
9
+
10
+ console = Console()
11
+
12
+ # Paleta terminal verde/preto, no mesmo espírito do Cerberus/ÆGIS
13
+ HACKER_STYLE = Style([
14
+ ("qmark", "fg:#00ff41 bold"),
15
+ ("question", "fg:#e0e0e0 bold"),
16
+ ("answer", "fg:#00ff41 bold"),
17
+ ("pointer", "fg:#00ff41 bold"),
18
+ ("highlighted", "fg:#00ff41 bold"),
19
+ ("selected", "fg:#00ff41"),
20
+ ("separator", "fg:#666666"),
21
+ ("instruction", "fg:#888888"),
22
+ ("text", "fg:#e0e0e0"),
23
+ ])
24
+
25
+
26
+ def prompt_repo_path(default: str = "") -> str:
27
+ raw = questionary.path(
28
+ "Caminho do repositório alvo (Enter = diretório atual):",
29
+ default=default,
30
+ style=HACKER_STYLE,
31
+ ).ask()
32
+ return raw.strip() if raw and raw.strip() else "."
33
+
34
+
35
+ def main_menu() -> str:
36
+ show_banner()
37
+ choice = questionary.select(
38
+ "Selecione uma operação:",
39
+ choices=[
40
+ questionary.Choice("[1] Scan completo (secrets + dependências)", value="full"),
41
+ questionary.Choice("[2] Scan de secrets vazados", value="secrets"),
42
+ questionary.Choice("[3] Scan de dependências vulneráveis", value="deps"),
43
+ questionary.Choice("[4] Instalar/atualizar gitleaks", value="install_gitleaks"),
44
+ questionary.Choice("[0] Sair", value="exit"),
45
+ ],
46
+ style=HACKER_STYLE,
47
+ qmark=">",
48
+ instruction=" ",
49
+ ).ask()
50
+ return choice or "exit"
51
+
52
+
53
+ def prompt_export_json() -> bool:
54
+ return questionary.confirm(
55
+ "Exportar relatório em JSON?",
56
+ default=False,
57
+ style=HACKER_STYLE,
58
+ ).ask()
59
+
60
+
61
+ def run_interactive():
62
+ from pathlib import Path
63
+
64
+ from abyss.cli import _print_dependency_findings, _print_secret_findings, _write_json
65
+ from abyss.scanners import dependencies, secrets
66
+
67
+ while True:
68
+ choice = main_menu()
69
+
70
+ if choice == "exit":
71
+ console.print("\n[dim green]encerrando sessão...[/dim green]\n")
72
+ break
73
+
74
+ if choice == "install_gitleaks":
75
+ from abyss.core.installer import install, is_installed
76
+
77
+ section("INSTALAÇÃO — GITLEAKS")
78
+ if is_installed():
79
+ console.print("[green]✓[/green] gitleaks já está instalado.")
80
+ else:
81
+ install(progress_callback=lambda msg: console.print(f"[dim]{msg}[/dim]"))
82
+ console.print("[green]✓[/green] gitleaks instalado com sucesso.")
83
+ questionary.press_any_key_to_continue(style=HACKER_STYLE).ask()
84
+ continue
85
+
86
+ repo_path = prompt_repo_path()
87
+ if not repo_path:
88
+ continue
89
+
90
+ resolved = str(Path(repo_path).resolve())
91
+ if not Path(resolved).exists():
92
+ console.print(f"[red]✗ caminho não encontrado:[/red] {resolved}")
93
+ questionary.press_any_key_to_continue(style=HACKER_STYLE).ask()
94
+ continue
95
+
96
+ secret_findings, dep_findings = [], []
97
+
98
+ if choice in ("full", "secrets"):
99
+ section("SCANNING — SECRETS")
100
+ with console.status("[green]procurando secrets vazados...[/green]"):
101
+ secret_findings = secrets.scan_repo(resolved, progress_callback=console.print)
102
+ _print_secret_findings(secret_findings)
103
+
104
+ if choice in ("full", "deps"):
105
+ section("SCANNING — DEPENDENCIES")
106
+ with console.status("[green]auditando dependências...[/green]"):
107
+ dep_findings = dependencies.scan_repo(resolved)
108
+ _print_dependency_findings(dep_findings)
109
+
110
+ total = len(secret_findings) + len(dep_findings)
111
+ section("RESULTADO")
112
+ if total == 0:
113
+ console.print("[bold green]✓ nenhum problema encontrado.[/bold green]")
114
+ else:
115
+ console.print(f"[bold yellow]⚠ {total} problema(s) encontrado(s).[/bold yellow]")
116
+
117
+ if prompt_export_json():
118
+ output_path = questionary.path(
119
+ "Salvar relatório em:", default="abyss-report.json", style=HACKER_STYLE
120
+ ).ask()
121
+ if output_path:
122
+ _write_json(Path(output_path), secret_findings, dep_findings, resolved)
123
+ console.print(f"[dim]relatório salvo em {output_path}[/dim]")
124
+
125
+ questionary.press_any_key_to_continue(style=HACKER_STYLE).ask()
File without changes
@@ -0,0 +1,52 @@
1
+ """Detecção de plataforma e arquitetura para baixar os binários corretos."""
2
+
3
+ import platform
4
+ import sys
5
+
6
+
7
+ def get_os() -> str:
8
+ """Retorna 'windows', 'darwin' ou 'linux'."""
9
+ system = platform.system().lower()
10
+ if system == "darwin":
11
+ return "darwin"
12
+ if system == "windows":
13
+ return "windows"
14
+ return "linux"
15
+
16
+
17
+ def get_arch() -> str:
18
+ """Normaliza a arquitetura pro formato usado nos releases do gitleaks."""
19
+ machine = platform.machine().lower()
20
+ if machine in ("x86_64", "amd64"):
21
+ return "x64"
22
+ if machine in ("arm64", "aarch64"):
23
+ return "arm64"
24
+ if machine in ("i386", "i686", "x86"):
25
+ return "x32"
26
+ return machine
27
+
28
+
29
+ def binary_name(base_name: str) -> str:
30
+ """Adiciona .exe no Windows quando necessário."""
31
+ if get_os() == "windows":
32
+ return f"{base_name}.exe"
33
+ return base_name
34
+
35
+
36
+ def abyss_home() -> "Path":
37
+ """Diretório onde guardamos binários auxiliares baixados (gitleaks etc)."""
38
+ from pathlib import Path
39
+
40
+ if get_os() == "windows":
41
+ base = Path.home() / "AppData" / "Local" / "abyss-cli"
42
+ elif get_os() == "darwin":
43
+ base = Path.home() / "Library" / "Application Support" / "abyss-cli"
44
+ else:
45
+ base = Path.home() / ".local" / "share" / "abyss-cli"
46
+
47
+ base.mkdir(parents=True, exist_ok=True)
48
+ return base
49
+
50
+
51
+ def is_supported() -> bool:
52
+ return sys.version_info >= (3, 9)
@@ -0,0 +1,25 @@
1
+ """Garante que secrets encontrados nunca aparecem em texto puro em logs,
2
+ issues ou saída de terminal — só os últimos caracteres ficam visíveis."""
3
+
4
+
5
+ def redact(secret: str, visible_chars: int = 4) -> str:
6
+ """Mascara um secret, mantendo só os últimos N caracteres visíveis.
7
+
8
+ Exemplo: redact("ghp_aBcD1234efGH5678") -> "••••••••••••••••5678"
9
+ """
10
+ if not secret:
11
+ return ""
12
+
13
+ if len(secret) <= visible_chars:
14
+ return "•" * len(secret)
15
+
16
+ masked_len = len(secret) - visible_chars
17
+ return ("•" * masked_len) + secret[-visible_chars:]
18
+
19
+
20
+ def redact_line(line: str, secret: str, visible_chars: int = 4) -> str:
21
+ """Substitui a ocorrência do secret dentro de uma linha de código/log
22
+ pela versão mascarada, preservando o resto do contexto da linha."""
23
+ if secret not in line:
24
+ return line
25
+ return line.replace(secret, redact(secret, visible_chars))
@@ -0,0 +1,13 @@
1
+ """Arquivo de teste — contém secrets FAKE só pra validar detecção do scanner.
2
+ NÃO são credenciais reais. AWS_EXAMPLE_KEY é literalmente o exemplo oficial
3
+ que a própria AWS usa na documentação deles pra ilustrar o formato de chave.
4
+ """
5
+
6
+ # Exemplo oficial de chave AWS usado na documentação da AWS (não é real)
7
+ AWS_ACCESS_KEY_ID = "AKIAIOSFODNN7EXAMPLE"
8
+ AWS_SECRET_ACCESS_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
9
+
10
+ # Token fake no formato de GitHub personal access token (valor inventado pra teste)
11
+ GITHUB_TOKEN = "ghp_1234567890abcdefghijklmnopqrstuvwxyz12"
12
+
13
+ DATABASE_URL = "postgresql://admin:supersecretpassword123@localhost:5432/mydb"
@@ -0,0 +1 @@
1
+ requests==2.6.0
@@ -0,0 +1,40 @@
1
+ [project]
2
+ name = "abyss-scanner"
3
+ version = "0.1.0"
4
+ description = "Abyss — CLI que mergulha nas profundezas do seu código pra achar secrets vazados e dependências vulneráveis"
5
+ readme = "README.md"
6
+ requires-python = ">=3.9"
7
+ license = { text = "MIT" }
8
+ authors = [
9
+ { name = "Lohane", email = "lohane.mdev@gmail.com" }
10
+ ]
11
+ dependencies = [
12
+ "typer>=0.12.0",
13
+ "rich>=13.7.0",
14
+ "requests>=2.31.0",
15
+ "PyGithub>=2.3.0",
16
+ "packaging>=24.0",
17
+ "questionary>=2.0.0",
18
+ ]
19
+
20
+ [project.scripts]
21
+ abyss = "abyss.cli:app"
22
+
23
+ [build-system]
24
+ requires = ["hatchling"]
25
+ build-backend = "hatchling.build"
26
+
27
+ [tool.hatch.build.targets.wheel]
28
+ packages = ["abyss"]
29
+
30
+ [tool.hatch.build]
31
+ exclude = [
32
+ "venv",
33
+ ".venv",
34
+ "test-venv-fake",
35
+ "**/__pycache__",
36
+ "*.egg-info",
37
+ "dist",
38
+ "build",
39
+ ".pytest_cache",
40
+ ]
File without changes
@@ -0,0 +1,25 @@
1
+ from abyss.utils.redact import redact, redact_line
2
+
3
+
4
+ def test_redact_keeps_last_4_chars():
5
+ assert redact("ghp_aBcD1234efGH5678") == "•" * 16 + "5678"
6
+
7
+
8
+ def test_redact_short_secret_fully_masked():
9
+ assert redact("abc") == "•••"
10
+
11
+
12
+ def test_redact_empty_string():
13
+ assert redact("") == ""
14
+
15
+
16
+ def test_redact_line_replaces_secret_in_context():
17
+ line = 'API_KEY = "ghp_aBcD1234efGH5678"'
18
+ result = redact_line(line, "ghp_aBcD1234efGH5678")
19
+ assert "ghp_aBcD1234efGH5678" not in result
20
+ assert result.endswith('5678"')
21
+
22
+
23
+ def test_redact_line_no_secret_present_unchanged():
24
+ line = "some unrelated line"
25
+ assert redact_line(line, "not-here") == line