From 16affedd2b0264fab74c072ccea21552e32fad81 Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Sat, 3 Oct 2026 09:42:03 -0300 Subject: [PATCH] =?UTF-8?q?Seguran=C3=A7a:=20hist=C3=B3rico=20privado,=20f?= =?UTF-8?q?iltro=20de=20senhas=20e=20Trivy=20na=20esteira?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Só itens fixados vão para o disco, com permissão 600/700 - Ignora conteúdo marcado por gerenciadores de senha - Limites de tamanho e validação do histórico salvo - Comando 'copiado esquecer' - Trivy (repo e pacote), ações fixadas por hash, permissões mínimas - Release publica SHA256SUMS e o instalador confere o checksum Co-Authored-By: Claude Opus 5.5 --- .github/workflows/pacote.yml | 98 +++++++++++++++++++++++++++++++----- README.md | 23 ++++++++- copiado.py | 75 +++++++++++++++++++++++---- instalar.sh | 11 +++- 4 files changed, 183 insertions(+), 24 deletions(-) diff --git a/.github/workflows/pacote.yml b/.github/workflows/pacote.yml index 1f58d2a..dfc2a1a 100644 --- a/.github/workflows/pacote.yml +++ b/.github/workflows/pacote.yml @@ -5,21 +5,70 @@ on: branches: [main] tags: ["v*"] pull_request: + schedule: + - cron: "0 9 * * 1" # toda segunda: reavalia com a base de vulnerabilidades nova +# por padrão nenhum job pode escrever nada; cada um pede só o que precisa permissions: - contents: write + contents: read jobs: - pacote: + verificar: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 - - - name: Verificar código + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Checar código run: | python3 -m py_compile copiado.py shellcheck instalar.sh desinstalar.sh empacotamento/gerar-deb.sh + trivy: + runs-on: ubuntu-24.04 + permissions: + contents: read + security-events: write # envia o relatório para a aba Security do GitHub + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Trivy (relatório para a aba Security) + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + scan-type: fs + scan-ref: . + scanners: vuln,secret,misconfig + format: sarif + output: trivy.sarif + exit-code: "0" + + - uses: github/codeql-action/upload-sarif@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + if: always() + with: + sarif_file: trivy.sarif + category: trivy + + - name: Trivy (barra a esteira em HIGH/CRITICAL) + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 + with: + scan-type: fs + scan-ref: . + scanners: vuln,secret,misconfig + severity: HIGH,CRITICAL + format: table + exit-code: "1" + skip-setup-trivy: true + + pacote: + needs: [verificar, trivy] + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Definir versão run: | if [[ "$GITHUB_REF" == refs/tags/v* ]]; then @@ -29,7 +78,9 @@ jobs: fi - name: Gerar .deb - run: empacotamento/gerar-deb.sh "$VERSAO" + run: | + empacotamento/gerar-deb.sh "$VERSAO" + cd dist && sha256sum copiado_all.deb > SHA256SUMS - name: Testar instalação do pacote run: | @@ -37,16 +88,39 @@ jobs: sudo apt-get install -y ./dist/copiado_all.deb test -x /usr/bin/copiado test -f /etc/xdg/autostart/copiado.desktop + mkdir -p conteudo && dpkg-deb -x dist/copiado_all.deb conteudo - - uses: actions/upload-artifact@v4 + - name: Trivy no conteúdo do pacote + uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: - name: copiado-${{ env.VERSAO }} - path: dist/copiado_all.deb + scan-type: fs + scan-ref: conteudo + scanners: vuln,secret + severity: HIGH,CRITICAL + exit-code: "1" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: copiado + path: | + dist/copiado_all.deb + dist/SHA256SUMS + + release: + if: startsWith(github.ref, 'refs/tags/v') + needs: pacote + runs-on: ubuntu-24.04 + permissions: + contents: write # só este job pode publicar + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: copiado + path: dist - name: Publicar release - if: startsWith(github.ref, 'refs/tags/v') env: GH_TOKEN: ${{ github.token }} + TAG: ${{ github.ref_name }} run: | - gh release create "$GITHUB_REF_NAME" dist/copiado_all.deb \ - --title "Copiado $VERSAO" --generate-notes + gh release create "$TAG" dist/copiado_all.deb dist/SHA256SUMS \ + --repo "$GITHUB_REPOSITORY" --title "Copiado ${TAG#v}" --generate-notes diff --git a/README.md b/README.md index 17e397f..92d9bfd 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,28 @@ Para **atualizar**, rode o mesmo comando de novo. | Apagar tudo (menos os fixados) | **Limpar tudo** | | Fechar sem colar | **Esc** ou clicar fora | -Guarda os 25 últimos itens (texto e imagens) e continua lá depois de reiniciar o PC. +Guarda os 25 últimos itens (texto e imagens). Como no Windows, ao reiniciar o PC +só os itens **fixados** continuam; o resto existe apenas na memória. + +## Privacidade e segurança + +A área de transferência pode ter senhas, tokens e dados pessoais, então o Copiado: + +- **Ignora senhas de gerenciadores de senha** (KeePassXC, Bitwarden, 1Password etc.), + que marcam o que copiam pedindo para não entrar em histórico. +- **Só grava no disco os itens fixados**, em `~/.local/share/copiado/`, com + permissão só para você (pasta `700`, arquivos `600`). +- **Não guarda textos acima de 1 MB nem imagens acima de 20 MB.** +- **Valida o arquivo de histórico** ao abrir: entradas estranhas ou imagens fora da + pasta do Copiado são descartadas. +- **Não usa rede** e não executa nada do que foi copiado. + +Para apagar tudo, inclusive os fixados: `copiado esquecer` + +O instalador confere o checksum SHA-256 do pacote antes de instalar, e a esteira +de CI/CD roda o [Trivy](https://trivy.dev) (vulnerabilidades, segredos e configurações) +em todo push, em toda release e toda segunda-feira. Os resultados ficam na aba +**Security** do repositório. ## Desinstalar diff --git a/copiado.py b/copiado.py index 82a9587..2380b2b 100755 --- a/copiado.py +++ b/copiado.py @@ -4,6 +4,7 @@ Uso: copiado.py inicia em segundo plano (vigia o Ctrl+C) copiado.py show abre a janelinha do histórico + copiado.py esquecer apaga todo o histórico, inclusive os fixados """ import hashlib import json @@ -22,6 +23,16 @@ MAX_ITENS = 25 LARGURA, ALTURA = 380, 480 PASTA = os.path.join(GLib.get_user_data_dir(), "copiado") ARQ_HIST = os.path.join(PASTA, "historico.json") +MAX_TEXTO = 1024 * 1024 # textos maiores que 1 MB não são guardados +MAX_IMAGEM = 20 * 1024 * 1024 # imagens maiores que 20 MB (PNG) também não +# Gerenciadores de senha (KeePassXC, Bitwarden, 1Password...) marcam o que +# copiam com estes formatos para pedir que não entre em histórico. +FORMATOS_SECRETOS = { + "x-kde-passwordManagerHint", + "application/x-nspasteboard-concealed-type", + "x-nspasteboard-concealed-type", + "ExcludeClipboardContentFromMonitorProcessing", +} CSS = b""" #fundo { @@ -63,26 +74,60 @@ scrollbar slider { background-color: rgba(255,255,255,0.25); border-radius: 4px; """ +def item_valido(item): + """Aceita só itens bem formados; imagens só dentro da nossa pasta.""" + if not isinstance(item, dict) or not isinstance(item.get("dado"), str): + return False + if item.get("tipo") == "texto": + return len(item["dado"]) <= MAX_TEXTO + if item.get("tipo") == "imagem": + caminho = os.path.realpath(item["dado"]) + return (os.path.dirname(caminho) == os.path.realpath(PASTA) + and caminho.endswith(".png") and os.path.isfile(caminho)) + return False + + class Historico: def __init__(self): - os.makedirs(PASTA, exist_ok=True) + # só o próprio usuário pode ler o histórico (pasta 700, arquivos 600) + os.umask(0o077) + os.makedirs(PASTA, mode=0o700, exist_ok=True) + os.chmod(PASTA, 0o700) + for nome in os.listdir(PASTA): + caminho = os.path.join(PASTA, nome) + if os.path.isfile(caminho) and not os.path.islink(caminho): + os.chmod(caminho, 0o600) self.itens = [] # {"tipo": "texto"|"imagem", "dado": str, "fixo": bool} try: with open(ARQ_HIST) as f: - self.itens = json.load(f) + carregados = json.load(f) + if isinstance(carregados, list): + # como no Windows: ao ligar o PC, só os fixados continuam + self.itens = [{"tipo": i["tipo"], "dado": i["dado"], "fixo": True} + for i in carregados if item_valido(i) and i.get("fixo") is True] except (OSError, ValueError): pass + self.salvar() def salvar(self): - with open(ARQ_HIST + ".tmp", "w") as f: - json.dump(self.itens, f, ensure_ascii=False) - os.replace(ARQ_HIST + ".tmp", ARQ_HIST) + # só os fixados vão para o disco; o resto fica apenas na memória + fixos = [i for i in self.itens if i["fixo"]] + tmp = ARQ_HIST + ".tmp" + fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, "w") as f: + json.dump(fixos, f, ensure_ascii=False) + os.replace(tmp, ARQ_HIST) usadas = {i["dado"] for i in self.itens if i["tipo"] == "imagem"} for nome in os.listdir(PASTA): caminho = os.path.join(PASTA, nome) if nome.endswith(".png") and caminho not in usadas: os.remove(caminho) + def esquecer_tudo(self): + """Apaga o histórico inteiro, inclusive os fixados.""" + self.itens = [] + self.salvar() + def adicionar(self, tipo, dado): antigo = next((i for i in self.itens if i["tipo"] == tipo and i["dado"] == dado), None) fixo = antigo["fixo"] if antigo else False @@ -125,28 +170,40 @@ class App(Gtk.Application): self.criar_janela() def do_command_line(self, linha): - if "show" in linha.get_arguments()[1:]: + args = linha.get_arguments()[1:] + if "show" in args: self.mostrar() + elif "esquecer" in args: + self.hist.esquecer_tudo() + if self.janela.get_visible(): + self.preencher() return 0 # ---------- captura ---------- def ao_copiar(self, clip, _evento): + ok, alvos = clip.wait_for_targets() + if ok and alvos and FORMATOS_SECRETOS & {a.name() for a in alvos}: + return # senha vinda de um gerenciador de senhas: não guarda if clip.wait_is_image_available(): img = clip.wait_for_image() if img is None: return dados = img.save_to_bufferv("png", [], [])[1] - nome = hashlib.sha1(dados).hexdigest()[:16] + ".png" + if len(dados) > MAX_IMAGEM: + return + nome = hashlib.sha256(dados).hexdigest()[:32] + ".png" caminho = os.path.join(PASTA, nome) if caminho == self.ignorar: return if not os.path.exists(caminho): - with open(caminho, "wb") as f: + fd = os.open(caminho, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) + with os.fdopen(fd, "wb") as f: f.write(dados) self.hist.adicionar("imagem", caminho) else: texto = clip.wait_for_text() - if not texto or not texto.strip() or texto == self.ignorar: + if (not texto or not texto.strip() or len(texto) > MAX_TEXTO + or texto == self.ignorar): return self.hist.adicionar("texto", texto) if self.janela.get_visible(): diff --git a/instalar.sh b/instalar.sh index 0fa329b..74a38a4 100755 --- a/instalar.sh +++ b/instalar.sh @@ -1,13 +1,20 @@ #!/usr/bin/env bash # Instala (ou atualiza) o Copiado a partir do pacote publicado no GitHub set -e -URL="https://github.com/lucasrguerra/copiado/releases/latest/download/copiado_all.deb" +URL="https://github.com/lucasrguerra/copiado/releases/latest/download" BASE=org.cinnamon.desktop.keybindings TMP=$(mktemp -d) trap 'rm -rf "$TMP"' EXIT echo "==> Baixando a versão mais recente..." -curl -fsSL -o "$TMP/copiado_all.deb" "$URL" +curl -fsSL --proto '=https' --tlsv1.2 -o "$TMP/copiado_all.deb" "$URL/copiado_all.deb" +curl -fsSL --proto '=https' --tlsv1.2 -o "$TMP/SHA256SUMS" "$URL/SHA256SUMS" + +echo "==> Conferindo a integridade do pacote..." +(cd "$TMP" && sha256sum --check --strict --quiet SHA256SUMS) || { + echo "ERRO: o pacote baixado não confere com o checksum publicado. Instalação cancelada." + exit 1 +} chmod 644 "$TMP/copiado_all.deb" echo "==> Instalando o pacote..."