Segurança: histórico privado, filtro de senhas e Trivy na esteira
- 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 <noreply@anthropic.com>
This commit is contained in:
1 parent
6e050e98d3
commit
16affedd2b
4 files changed
+183
-24
No files matched your search
@@ -5,21 +5,70 @@ on:
|
|||||||
branches: [main]
|
branches: [main]
|
||||||
tags: ["v*"]
|
tags: ["v*"]
|
||||||
pull_request:
|
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:
|
permissions:
|
||||||
contents: write
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
pacote:
|
verificar:
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
|
with:
|
||||||
- name: Verificar código
|
persist-credentials: false
|
||||||
|
- name: Checar código
|
||||||
run: |
|
run: |
|
||||||
python3 -m py_compile copiado.py
|
python3 -m py_compile copiado.py
|
||||||
shellcheck instalar.sh desinstalar.sh empacotamento/gerar-deb.sh
|
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
|
- name: Definir versão
|
||||||
run: |
|
run: |
|
||||||
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||||
@@ -29,7 +78,9 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Gerar .deb
|
- 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
|
- name: Testar instalação do pacote
|
||||||
run: |
|
run: |
|
||||||
@@ -37,16 +88,39 @@ jobs:
|
|||||||
sudo apt-get install -y ./dist/copiado_all.deb
|
sudo apt-get install -y ./dist/copiado_all.deb
|
||||||
test -x /usr/bin/copiado
|
test -x /usr/bin/copiado
|
||||||
test -f /etc/xdg/autostart/copiado.desktop
|
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:
|
with:
|
||||||
name: copiado-${{ env.VERSAO }}
|
scan-type: fs
|
||||||
path: dist/copiado_all.deb
|
scan-ref: conteudo
|
||||||
|
scanners: vuln,secret
|
||||||
|
severity: HIGH,CRITICAL
|
||||||
|
exit-code: "1"
|
||||||
|
|
||||||
- name: Publicar release
|
- 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')
|
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
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ github.token }}
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
TAG: ${{ github.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
gh release create "$GITHUB_REF_NAME" dist/copiado_all.deb \
|
gh release create "$TAG" dist/copiado_all.deb dist/SHA256SUMS \
|
||||||
--title "Copiado $VERSAO" --generate-notes
|
--repo "$GITHUB_REPOSITORY" --title "Copiado ${TAG#v}" --generate-notes
|
||||||
@@ -25,7 +25,28 @@ Para **atualizar**, rode o mesmo comando de novo.
|
|||||||
| Apagar tudo (menos os fixados) | **Limpar tudo** |
|
| Apagar tudo (menos os fixados) | **Limpar tudo** |
|
||||||
| Fechar sem colar | **Esc** ou clicar fora |
|
| 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
|
## Desinstalar
|
||||||
|
|
||||||
|
|||||||
+66
-9
@@ -4,6 +4,7 @@
|
|||||||
Uso:
|
Uso:
|
||||||
copiado.py inicia em segundo plano (vigia o Ctrl+C)
|
copiado.py inicia em segundo plano (vigia o Ctrl+C)
|
||||||
copiado.py show abre a janelinha do histórico
|
copiado.py show abre a janelinha do histórico
|
||||||
|
copiado.py esquecer apaga todo o histórico, inclusive os fixados
|
||||||
"""
|
"""
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
@@ -22,6 +23,16 @@ MAX_ITENS = 25
|
|||||||
LARGURA, ALTURA = 380, 480
|
LARGURA, ALTURA = 380, 480
|
||||||
PASTA = os.path.join(GLib.get_user_data_dir(), "copiado")
|
PASTA = os.path.join(GLib.get_user_data_dir(), "copiado")
|
||||||
ARQ_HIST = os.path.join(PASTA, "historico.json")
|
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"""
|
CSS = b"""
|
||||||
#fundo {
|
#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:
|
class Historico:
|
||||||
def __init__(self):
|
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}
|
self.itens = [] # {"tipo": "texto"|"imagem", "dado": str, "fixo": bool}
|
||||||
try:
|
try:
|
||||||
with open(ARQ_HIST) as f:
|
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):
|
except (OSError, ValueError):
|
||||||
pass
|
pass
|
||||||
|
self.salvar()
|
||||||
|
|
||||||
def salvar(self):
|
def salvar(self):
|
||||||
with open(ARQ_HIST + ".tmp", "w") as f:
|
# só os fixados vão para o disco; o resto fica apenas na memória
|
||||||
json.dump(self.itens, f, ensure_ascii=False)
|
fixos = [i for i in self.itens if i["fixo"]]
|
||||||
os.replace(ARQ_HIST + ".tmp", ARQ_HIST)
|
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"}
|
usadas = {i["dado"] for i in self.itens if i["tipo"] == "imagem"}
|
||||||
for nome in os.listdir(PASTA):
|
for nome in os.listdir(PASTA):
|
||||||
caminho = os.path.join(PASTA, nome)
|
caminho = os.path.join(PASTA, nome)
|
||||||
if nome.endswith(".png") and caminho not in usadas:
|
if nome.endswith(".png") and caminho not in usadas:
|
||||||
os.remove(caminho)
|
os.remove(caminho)
|
||||||
|
|
||||||
|
def esquecer_tudo(self):
|
||||||
|
"""Apaga o histórico inteiro, inclusive os fixados."""
|
||||||
|
self.itens = []
|
||||||
|
self.salvar()
|
||||||
|
|
||||||
def adicionar(self, tipo, dado):
|
def adicionar(self, tipo, dado):
|
||||||
antigo = next((i for i in self.itens if i["tipo"] == tipo and i["dado"] == dado), None)
|
antigo = next((i for i in self.itens if i["tipo"] == tipo and i["dado"] == dado), None)
|
||||||
fixo = antigo["fixo"] if antigo else False
|
fixo = antigo["fixo"] if antigo else False
|
||||||
@@ -125,28 +170,40 @@ class App(Gtk.Application):
|
|||||||
self.criar_janela()
|
self.criar_janela()
|
||||||
|
|
||||||
def do_command_line(self, linha):
|
def do_command_line(self, linha):
|
||||||
if "show" in linha.get_arguments()[1:]:
|
args = linha.get_arguments()[1:]
|
||||||
|
if "show" in args:
|
||||||
self.mostrar()
|
self.mostrar()
|
||||||
|
elif "esquecer" in args:
|
||||||
|
self.hist.esquecer_tudo()
|
||||||
|
if self.janela.get_visible():
|
||||||
|
self.preencher()
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
# ---------- captura ----------
|
# ---------- captura ----------
|
||||||
def ao_copiar(self, clip, _evento):
|
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():
|
if clip.wait_is_image_available():
|
||||||
img = clip.wait_for_image()
|
img = clip.wait_for_image()
|
||||||
if img is None:
|
if img is None:
|
||||||
return
|
return
|
||||||
dados = img.save_to_bufferv("png", [], [])[1]
|
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)
|
caminho = os.path.join(PASTA, nome)
|
||||||
if caminho == self.ignorar:
|
if caminho == self.ignorar:
|
||||||
return
|
return
|
||||||
if not os.path.exists(caminho):
|
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)
|
f.write(dados)
|
||||||
self.hist.adicionar("imagem", caminho)
|
self.hist.adicionar("imagem", caminho)
|
||||||
else:
|
else:
|
||||||
texto = clip.wait_for_text()
|
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
|
return
|
||||||
self.hist.adicionar("texto", texto)
|
self.hist.adicionar("texto", texto)
|
||||||
if self.janela.get_visible():
|
if self.janela.get_visible():
|
||||||
|
|||||||
+9
-2
@@ -1,13 +1,20 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Instala (ou atualiza) o Copiado a partir do pacote publicado no GitHub
|
# Instala (ou atualiza) o Copiado a partir do pacote publicado no GitHub
|
||||||
set -e
|
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
|
BASE=org.cinnamon.desktop.keybindings
|
||||||
TMP=$(mktemp -d)
|
TMP=$(mktemp -d)
|
||||||
trap 'rm -rf "$TMP"' EXIT
|
trap 'rm -rf "$TMP"' EXIT
|
||||||
|
|
||||||
echo "==> Baixando a versão mais recente..."
|
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"
|
chmod 644 "$TMP/copiado_all.deb"
|
||||||
|
|
||||||
echo "==> Instalando o pacote..."
|
echo "==> Instalando o pacote..."
|
||||||
|
|||||||
Reference in new issue
Block a user