Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions .github/semgrep/strip-suppressed.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""Remove do SARIF os achados já suprimidos in-source (`nosemgrep`).

Por que isto existe
-------------------
Um comentário `nosemgrep` na linha tira o achado do exit code do semgrep —
o gate bloqueante do PR nunca reprova por causa dele. Mas o semgrep ainda
emite o achado no SARIF, marcado com `"suppressions": [{"kind":
"inSource"}]`. O GitHub code scanning entende esse campo, porém o bot de
review (`github-advanced-security`) posta comentário de PR mesmo assim.
O resultado é uma enxurrada de alerta em linha que já carrega
justificativa escrita ao lado — ruído que treina o revisor a ignorar
alerta de segurança, que é exatamente o oposto do que a camada serve.

Filtramos apenas a cópia que vai para o code scanning. O SARIF íntegro,
com os suprimidos, continua sendo publicado como artefato do job (ver
`.github/workflows/security.yml`), então o histórico de "o que foi
suprimido e onde" não se perde.

Falha aberta de propósito: se o arquivo não existir, não for JSON válido
ou tiver formato inesperado, copiamos a entrada para a saída sem filtrar.
Perder o upload de segurança inteiro por causa de um erro de parsing
seria pior do que publicar achados a mais.

Uso:
python3 strip-suppressed.py entrada.sarif saida.sarif
"""

from __future__ import annotations

import json
import shutil
import sys


def strip_suppressed(src: str, dst: str) -> int:
try:
with open(src, encoding="utf-8") as handle:
document = json.load(handle)
except (OSError, ValueError) as exc:
print(f"{src}: ilegível ({exc}) — copiado sem filtrar", file=sys.stderr)
try:
shutil.copyfile(src, dst)
except OSError:
pass
return 0

runs = document.get("runs")
if not isinstance(runs, list):
print(f"{src}: sem `runs` — copiado sem filtrar", file=sys.stderr)
shutil.copyfile(src, dst)
return 0

removed = 0
for run in runs:
if not isinstance(run, dict):
continue
results = run.get("results")
if not isinstance(results, list):
continue
kept = [r for r in results if not (isinstance(r, dict) and r.get("suppressions"))]
removed += len(results) - len(kept)
run["results"] = kept

with open(dst, "w", encoding="utf-8") as handle:
json.dump(document, handle)

print(f"{src}: {removed} achado(s) suprimido(s) removido(s) do upload")
return removed


def main(argv: list[str]) -> int:
if len(argv) != 3:
print(__doc__, file=sys.stderr)
return 2
strip_suppressed(argv[1], argv[2])
return 0


if __name__ == "__main__":
raise SystemExit(main(sys.argv))
26 changes: 24 additions & 2 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,18 @@ jobs:
ls -l semgrep-generic.sarif
exit 0

# Achado com `nosemgrep` na linha já não entra no exit code do
# semgrep, mas ainda vai para o SARIF marcado como `inSource`. O bot
# de review do GitHub anota mesmo assim, enchendo o PR de alerta em
# linha que carrega a justificativa ao lado. Filtramos só a cópia
# destinada ao code scanning; o SARIF íntegro segue no artefato.
- name: Filtrar achados suprimidos do upload — genérico
if: always()
run: |
IN=semgrep-generic.sarif
OUT=semgrep-generic-clean.sarif
python3 .github/semgrep/strip-suppressed.py "$IN" "$OUT" || cp "$IN" "$OUT"

- name: Upload SARIF — genérico
if: always()
# Code scanning pode não estar habilitado (repo privado sem GitHub
Expand All @@ -73,7 +85,7 @@ jobs:
continue-on-error: true
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
sarif_file: semgrep-generic.sarif
sarif_file: semgrep-generic-clean.sarif
category: semgrep

# ---- Camada 2: regras específicas de Flarum v2 --------------------
Expand Down Expand Up @@ -111,12 +123,20 @@ jobs:
[ "$BLOCK" = "1" ] && exit $EXIT
exit 0

# Mesmo motivo da camada 1 — ver o comentário lá.
- name: Filtrar achados suprimidos do upload — Flarum v2
if: always()
run: |
IN=semgrep-flarum.sarif
OUT=semgrep-flarum-clean.sarif
python3 .github/semgrep/strip-suppressed.py "$IN" "$OUT" || cp "$IN" "$OUT"

- name: Upload SARIF — Flarum v2
if: always()
continue-on-error: true
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
sarif_file: semgrep-flarum.sarif
sarif_file: semgrep-flarum-clean.sarif
category: flarum-v2-security

# Canal de resultados independente do code scanning estar ligado:
Expand All @@ -129,5 +149,7 @@ jobs:
path: |
semgrep-generic.sarif
semgrep-flarum.sarif
semgrep-generic-clean.sarif
semgrep-flarum-clean.sarif
retention-days: 30
if-no-files-found: warn
11 changes: 10 additions & 1 deletion extend.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
use Ramon\Backup\Console\ExportCommand;
use Ramon\Backup\Console\ImportCommand;
use Ramon\Backup\Console\PruneStaleJobsCommand;
use Ramon\Backup\Settings\SettingsPreserver;

return [
(new Extend\Frontend('admin'))
Expand All @@ -46,7 +47,15 @@
// Same trust model as ramon/verified — the matching PRIVATE key is
// pasted into config.php under `backup-private-key`, never stored
// in the database.
->default('ramon-backup.encryption_public_key', ''),
->default('ramon-backup.encryption_public_key', '')
/*
* Padrões extra de chaves de `settings` que um restore não pode
* sobrescrever, um por linha (sufixo `*` casa por prefixo). O
* baseline — SMTP, fila, tokens desta instalação — está em
* SettingsPreserver::BASELINE; esta chave existe para o operador
* acrescentar o que for específico do ambiente dele.
*/
->default(SettingsPreserver::EXTRA_KEY, ''),

(new Extend\Routes('api'))
->get('/backup/backups', 'backup.list', ListBackupsController::class)
Expand Down
2 changes: 1 addition & 1 deletion js/dist/admin.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion js/dist/admin.js.map

Large diffs are not rendered by default.

50 changes: 49 additions & 1 deletion js/src/admin/components/ImportModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ interface ArchiveManifest {
// New archives ship rich descriptors.
extensions?: string[] | ArchiveExtensionEntry[];
has_composer?: boolean;
// Archives from before the project-reconcile work don't carry the
// site's root extend.php, so the toggle for it stays hidden.
has_root_extend?: boolean;
}

interface InspectResult {
Expand All @@ -65,8 +68,10 @@ interface ImportProgress {
phase:
| "inspect"
| "extract"
| "reconcile"
| "restore"
| "rewrite"
| "verify"
| "finalize"
| "done"
| "error";
Expand Down Expand Up @@ -125,6 +130,13 @@ export default class ImportModal extends Modal<ImportModalAttrs> {
// Per-extension toggles, keyed by directory name from the manifest.
protected extensionsByName: Record<string, boolean> = {};

// Opt-in: overwriting the site's root extend.php replaces live config
// of THIS server, so it never happens unless asked for.
protected sectionRootExtend = false;
// Opt-out: losing this server's SMTP / queue / integration settings to
// the source forum's is the defect this whole layer exists to stop.
protected preserveSettings = true;

protected status: ImportProgress | null = null;
protected polling = false;

Expand Down Expand Up @@ -571,12 +583,46 @@ export default class ImportModal extends Modal<ImportModalAttrs> {
)}
</>
)}

{(hasDb || manifest.has_root_extend) && (
<div className="BackupImport-serverState">
<h4>{trans("server_state_title")}</h4>
<p className="helpText">{trans("server_state_help")}</p>

{hasDb &&
this.sectionRow(
"preserve_settings",
this.preserveSettings,
(v) => {
this.preserveSettings = v;
},
)}

{manifest.has_root_extend &&
this.sectionRow("root_extend", this.sectionRootExtend, (v) => {
this.sectionRootExtend = v;
})}

{this.sectionRootExtend && (
<div className="BackupImport-composerNote helpText">
<i className="icon fas fa-triangle-exclamation" />{" "}
{trans("root_extend_warning")}
</div>
)}
</div>
)}
</fieldset>
);
}

sectionRow(
key: "db" | "assets" | "storage" | "extensions",
key:
| "db"
| "assets"
| "storage"
| "extensions"
| "preserve_settings"
| "root_extend",
checked: boolean,
set: (v: boolean) => void,
count?: number,
Expand Down Expand Up @@ -619,6 +665,8 @@ export default class ImportModal extends Modal<ImportModalAttrs> {
assets: this.sectionAssets,
storage: this.sectionStorage,
extensions: extensionsField,
root_extend: this.sectionRootExtend,
preserve_settings: this.preserveSettings,
};
}

Expand Down
19 changes: 19 additions & 0 deletions less/admin.less
Original file line number Diff line number Diff line change
Expand Up @@ -617,6 +617,25 @@
font-size: 12px;
}

// Bloco das opções que decidem o que do SERVIDOR sobrevive ao restore.
// Separado por uma régua de propósito: são escolhas de natureza
// diferente das caixas "o que do backup aplicar" acima.
.BackupImport-serverState {
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid var(--control-bg, rgba(0, 0, 0, 0.08));

h4 {
margin: 0 0 4px;
font-size: 13px;
font-weight: 600;
}

> .helpText {
margin-bottom: 8px;
}
}

.BackupImport-extList {
margin: 4px 0 8px 28px;
padding: 8px 12px;
Expand Down
9 changes: 8 additions & 1 deletion locale/en.yml
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ ramon-backup:
extensions_tag_workbench: "workbench"
extensions_tag_vendor: "vendor"
extensions_tag_unknown: "unknown"
extensions_composer_note: "composer.json and composer.lock are bundled and will overwrite the destination's project root. Run `composer install` after restoring to materialise vendor extensions."
extensions_composer_note: "composer.json is merged into this server's, never overwritten — nothing installed here is removed. composer.lock is NOT applied, because a lock from another server prunes packages that only exist here. Run `composer install` after restoring to materialise vendor extensions."
inspect_title: "Archive details"
meta_when: "Created"
meta_flarum: "Flarum version"
Expand All @@ -164,6 +164,11 @@ ramon-backup:
section_storage: "Storage"
section_extensions: "Extensions"
section_count: "{count} files"
server_state_title: "This server's own configuration"
server_state_help: "Some state describes THIS server rather than the forum in the backup — SMTP credentials, queue driver, integration tokens, and the root extend.php. These options decide what survives the restore."
section_preserve_settings: "Keep this server's mail, queue and integration settings"
section_root_extend: "Replace the root extend.php with the backup's"
root_extend_warning: "The root extend.php is loaded before Flarum's error handler exists — a class it references that isn't installed here takes the forum down with a blank HTTP 500. The current file is copied to a .bak-* first, and the restore checks for unresolvable classes when it finishes."
key_title: "Private key"
key_help: "This archive is encrypted. Paste the matching base64 private key — it stays in this browser session and is sent ONCE to the server for the restore."
key_hint_local: "Leave blank to use the private key configured in this server's config.php."
Expand All @@ -189,8 +194,10 @@ ramon-backup:
logout_button: "Reload and sign in"
phase_inspect: "Inspecting archive…"
phase_extract: "Extracting…"
phase_reconcile: "Reconciling project files…"
phase_restore: "Restoring database…"
phase_rewrite: "Rewriting URLs…"
phase_verify: "Checking the forum still boots…"
phase_finalize: "Finalising…"
phase_done: "Done"
phase_error: "Error"
Expand Down
9 changes: 8 additions & 1 deletion locale/pt-BR.yml
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ ramon-backup:
extensions_tag_workbench: "workbench"
extensions_tag_vendor: "vendor"
extensions_tag_unknown: "unknown"
extensions_composer_note: "composer.json e composer.lock estão no arquivo e vão sobrescrever os do servidor de destino. Rode `composer install` depois de restaurar para materializar as extensões vendor."
extensions_composer_note: "O composer.json é UNIDO ao deste servidor, nunca sobrescrito — nada que está instalado aqui é removido. O composer.lock NÃO é aplicado: um lock de outro servidor poda pacotes que só existem aqui. Rode `composer install` depois de restaurar para materializar as extensões vendor."
inspect_title: "Detalhes do arquivo"
meta_when: "Criado em"
meta_flarum: "Versão do Flarum"
Expand All @@ -164,6 +164,11 @@ ramon-backup:
section_storage: "Storage"
section_extensions: "Extensões"
section_count: "{count} arquivos"
server_state_title: "Configuração deste servidor"
server_state_help: "Parte do estado descreve ESTE servidor, não o fórum do backup — credenciais de SMTP, driver de fila, tokens de integração e o extend.php da raiz. Estas opções decidem o que sobrevive à restauração."
section_preserve_settings: "Manter e-mail, fila e integrações deste servidor"
section_root_extend: "Substituir o extend.php da raiz pelo do backup"
root_extend_warning: "O extend.php da raiz é carregado antes de existir handler de erro no Flarum — uma classe referenciada ali que não esteja instalada aqui derruba o fórum com HTTP 500 em branco. O arquivo atual é copiado para um .bak-* antes, e a restauração verifica classes não resolvidas ao terminar."
key_title: "Chave privada"
key_help: "Este arquivo está criptografado. Cole a chave privada base64 correspondente — ela fica somente nesta sessão do navegador e é enviada UMA VEZ ao servidor para a restauração."
key_hint_local: "Deixe em branco para usar a chave privada configurada no config.php deste servidor."
Expand All @@ -189,8 +194,10 @@ ramon-backup:
logout_button: "Recarregar e fazer login"
phase_inspect: "Inspecionando arquivo…"
phase_extract: "Extraindo…"
phase_reconcile: "Reconciliando arquivos do projeto…"
phase_restore: "Restaurando banco…"
phase_rewrite: "Reescrevendo URLs…"
phase_verify: "Conferindo se o fórum ainda sobe…"
phase_finalize: "Finalizando…"
phase_done: "Pronto"
phase_error: "Erro"
Expand Down
Loading
Loading