---
suivi: 1480
date: 2026-09-08
sujet: Neutraliser le script destructeur SIWI + Deny database/ + refus source vide
chantier: reprise_siwi / securite_ops
type: fix
statut: poussé
hash: 01772098
fichiers:
  - database/data/siwi_import_sample/run_tests_1324.php (supprimé)
  - database/.htaccess
  - database/data/.htaccess
  - app/Console/Commands/SiwiImportCommand.php
  - tests/Unit/SiwiImportCommandGuardTest.php
  - tests/Unit/SiwiImport1480SourceTest.php
  - docs/suivi/SUIVI_1480_fix_script_destructeur_siwi.md
---

# SUIVI #1480 — fermer la porte du script destructeur SIWI

## PROMPT ENVOYÉ

Fermer définitivement la porte : neutraliser `run_tests_1324.php`, auditer
tous les scripts PHP autonomes, Deny Apache sur `database/` (modèle `tools/`),
avis `prohibitDestructiveCommands`, refus `siwi:import` si source vide.
Pas de restauration staging, pas de `siwi:import` lancé ici, pas de `.vue`,
pas de migration.

## BASE GIT

```
git log --oneline -1 origin/main
a7f3f65c chore(suivi): hash fiche #1479
```

(Attendu prompt : `a7f3f65c` — conforme.)

## FICHES LUES

- **#1479** — cause établie : DELETE L51–55 avant Artisan::call ; garde CLI
  #1370 empêche le rechargement en FPM. LEÇON : protéger l’appelant, pas
  seulement `siwi:import`. Voie (i) suppression recommandée E3.
- **#1370** — Deny `tools/` + `tools/diag/` ; garde CLI `siwi:import` ;
  seuil `--force-replace` 50. À préserver. Modèle Apache recopié.
- **#1324 / #1328** — upsert, path défaut `/home/hectare-envol/siwi-import`.
  Inchangés.

## VOIE RETENUE : (i) SUPPRESSION

Justification :

- Harnais de preuve du #1324. L’import est couvert par `siwi:import` + gardes
  #1370 + refus source #1480.
- Aucune route, aucun contrôleur, aucun test applicatif ne l’inclut (reconfirmé
  grep `app/`, `database/`, `tools/`, `routes/`, `config/`, `bootstrap/`).
- « On ne protège pas ce qui n’existe pas. »
- Le `chmod 000` d’urgence n’est pas suivi par git : un pull avec conservation
  (voie ii) **restaurerait** le fichier exécutable. La suppression fait
  disparaître la question.

Le DELETE n’est plus atteignable : le fichier n’est plus dans le dépôt.

---

## A — Script fautif

`database/data/siwi_import_sample/run_tests_1324.php` **supprimé**.

CSV synthétiques (`marches.csv`, etc.) **conservés** : ce ne sont pas une
sauvegarde prod ; ils restent un jeu de recette locale, pas exécutables FPM
par eux-mêmes.

---

## B — AUDIT scripts autonomes

Définition : `.php` hors cycle Laravel (`public/index.php`, `artisan`) qui
`require` `vendor/autoload.php` et/ou `bootstrap/app.php`.

Légende atteignable web : DocumentRoot de `envol.hectare.fr` = `public/`
(404 mesuré par Robin sur `COVERAGE.md`). Atteignable = **inconnu** pour une
seconde arborescence mal configurée (chemin fantôme #1479). Deny Apache =
défense en profondeur.

| Chemin | Rôle | Écriture / suppression DB | Garde SAPI | Garde env | Web | Verdict |
|---|---|---|---|---|---|---|
| `database/data/siwi_import_sample/run_tests_1324.php` | Harnais #1324 : DELETE 4 tables `siwi_*` puis `siwi:import` | **OUI — DELETE sans WHERE** | non (avant ce lot) | non | inconnu (2e vhost) | **SUPPRIMÉ** |
| `database/data/siwi_import_sample/build_sample.php` | Régénère CSV + EXPECTED.json ; bootstrap en fin pour `readCsv` / totaux | Non (disque seulement ; bootstrap Laravel) | non | non | inconnu | **garder** ; protégé par Deny `database/` ; pas destructeur DB — non modifié (clause B) |
| `database/data/siwi_import_sample/build_corrupt.php` | Copie CSV corrompus (pas d’autoload) | Non | n/a | n/a | n/a | hors définition autonome |
| `database/data/journal_mises_a_jour_post_2026_06_12.php` | Catalogue inclus par seeder | Non (données PHP) | n/a | n/a | n/a | hors définition (pas de bootstrap) |
| `tools/patch_diag_php_sapi_guard_1370.php` | Patch one-shot fichiers `tools/diag` | Non (fichiers disque) | oui L9 | non | Deny `tools/` | garder tel quel |
| `tools/fix_diag_php_sapi_guard_strict_1370.php` | Idem, placement `declare` | Non (fichiers disque) | oui L6 | non | Deny `tools/` | garder tel quel |
| `tools/diag/reprise_promesses_en_cours_1225.php` | Reprise promesses `--apply` | OUI si `--apply` (UPDATE, pas truncate) | oui | non | Deny `tools/` | **garder tel quel — non touché** |
| `tools/diag/rattrapage_sharepoint_item_id_1239.php` | Rattrapage item_id `--apply` | OUI si `--apply` | oui | non | Deny `tools/` | **garder tel quel — non touché** |
| `tools/diag/recalcul_reste_penalites_1219.php` | Recalcul `reste_a_regler_ht` | OUI (indicateur dérivé) | oui | non | Deny `tools/` | **garder tel quel — non touché** |
| `tools/diag/diag_reprise_fix_1344.php` | Correctif reprise (save) | OUI | oui | non | Deny `tools/` | **garder tel quel — non touché** |
| `tools/diag/diag_prospection_queries_1208.php` | Jeu de données test (INSERT) | OUI insert | oui | non | Deny `tools/` | **garder tel quel — non touché** |
| `tools/diag/diag_prospection_commune_depliage_1217.php` | Idem INSERT | OUI insert | oui | non | Deny `tools/` | **garder tel quel — non touché** |
| `tools/diag/diag_mes_taches_queries_1214.php` | Idem INSERT | OUI insert | oui | non | Deny `tools/` | **garder tel quel — non touché** |
| `tools/diag/*.php` — **113 autres** (120 − 7 ci-dessus) | Diag / verif / inventaire lecture | Non (grep : aucun `truncate` / `delete()` sans where / `migrate:fresh` / `db:wipe` / `FOREIGN_KEY_CHECKS`) | **oui tous** (preuve : 0 fichier sans `PHP_SAPI !== 'cli'`) | non | Deny `tools/` + `tools/diag/` | **garder tel quel** (#1370) |
| `public/index.php` | Front controller | cycle Laravel | n/a | n/a | oui (voulu) | hors audit autonome |
| `artisan` | CLI Laravel | cycle Laravel | CLI | n/a | non | hors audit autonome |
| `KomiDoc_Invoices_To_SageIntacct_Invoices.php` | Script Sage (gitignore) | hors ERP SIWI | ? | ? | ? | **non commité** (`.gitignore`) |

**Preuve exhaustivité `tools/diag` :** 120 fichiers `.php` ; recherche des
fichiers **sans** `PHP_SAPI !== 'cli'` → liste vide. Aucun `truncate` /
`DELETE` sans where / `DROP` / `FOREIGN_KEY_CHECKS` / `migrate:fresh` /
`db:wipe` dans `tools/diag`.

**Scripts écrivains hors `run_tests_1324` :** listés ci-dessus. Tous ont déjà
le garde SAPI + Deny Apache #1370. **Non modifiés** (clause d’arrêt : arbitrage
si on devait les durcir davantage — hors scope ; aucun n’est un DELETE massif
`siwi_*`).

`docs/`, `storage/` : aucun `require` autoload/bootstrap.

---

## C — Protection Apache `database/`

Modèle lu : `tools/.htaccess` (#1370) — `Require all denied` + fallback
Apache 2.2. Recopié à l’identique (commentaires #1480).

Posé :

- `database/.htaccess` (équivalent `tools/.htaccess`)
- `database/data/.htaccess` (équivalent `tools/diag/.htaccess`)

Pas de `.htaccess` à la racine. `public/.htaccess` non touché.

Contenu exact `database/.htaccess` :

```
# #1480 — refuser toute exécution HTTP des scripts sous database/
# (même modèle que tools/ #1370). CLI inchangé (php database/... hors Apache).
<IfModule mod_authz_core.c>
    Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
    Order deny,allow
    Deny from all
</IfModule>
```

Comparaison avec `tools/.htaccess` : mêmes blocs `IfModule` / `Require all
denied` / `Order deny,allow` / `Deny from all`. Seul le commentaire d’entête
change (#1480 vs #1370).

---

## D — `DB::prohibitDestructiveCommands()` — AVIS, NON IMPLÉMENTÉ

Laravel 13 (`Illuminate\Support\Facades\DB` L138–144) interdit uniquement :

`migrate:fresh`, `migrate:refresh`, `migrate:reset`, `migrate:rollback`,
`db:wipe`.

**Insuffisant pour cet incident :** un `DB::table('siwi_marches')->delete()`
n’est pas une commande Artisan. Le script fautif serait passé à travers.

Commandes légitimes listées dans le prompt (`migrate --force`, backfills
`taches:backfill-completer-saisie`, `depot-factures:backfill-validateur`,
`depot-factures:normalize-validateur-id`, `juridique:backfill-demandeur-suiveur`,
`journal:sync`, `optimize:clear`) : **non concernées** par cette liste.

**Risque :** `migrate:rollback` serait interdit en production. Un rollback
d’urgence OVH deviendrait impossible sans lever le garde. C’est la raison
de **ne pas l’activer dans ce lot** — à trancher plus tard si Robin accepte
d’interdire rollback/fresh/wipe en `APP_ENV=production`.

Garde générique recommandé pour tout futur script autonome (à poser **avant**
tout require) :

```
if (PHP_SAPI !== 'cli') { exit(1); }
```

Puis, après bootstrap, si le script écrit : `if (app()->isProduction()) { fwrite(STDERR, ...); exit(1); }`
sauf intention documentée. Modèle : `PurgeFacturesTest` L16.

Non ajouté dans `AppServiceProvider` (vérifié : chaîne absente).

---

## E — Refus `siwi:import` source vide

Ajout **strictement additif** dans `SiwiImportCommand::handle()`, **après**
le garde CLI #1370, **avant** `stagingCounts()`, **avant** `--force-replace`,
**avant** `$service->run()`.

Path défaut inchangé : `/home/hectare-envol/siwi-import`.
Seuil 50 inchangé. Upsert `SiwiImportService` inchangé (le service avait déjà
`Fichier manquant` L96–99 — seconde ligne de défense).

Message (dossier vide) :

```
Refus : dossier source vide ou sans CSV SIWI : {path}
Fichiers attendus : marches.csv, operations.csv, mapping_postes.csv, documents.csv
Fichiers trouvés : (aucun)
Fichiers manquants : marches.csv, operations.csv, mapping_postes.csv, documents.csv
```

Variantes : `dossier source introuvable` (absent) ; `CSV SIWI incomplets`
(certains CSV présents). Log : `siwi.import_refuse_source_incomplete`.

---

## TESTS

- `SiwiImportCommandGuardTest` : 7 passed (seuil 50, path défaut, inspect
  absent/vide/incomplet/complet, message).
- `SiwiImport1480SourceTest` : 10 passed (fichier absent, pas de référence
  PHP, pas de DELETE siwi, htaccess aligné, ordre CLI→inspect→counts→run,
  upsert + modèles Eloquent `SiwiMarche` etc., Artisan refuse dossier vide,
  backfills + `journal:sync` + `PurgeFacturesTest` inchangés).

Échecs préexistants agent : non rencontrés sur cette suite (pas de
RefreshDatabase / pas de sqlite).

## LEÇON

Le garde sur la commande appelée ne protège pas un DELETE situé **avant**
l’appel. Un harnais de test commité sous `database/` est une surface FPM dès
qu’une arborescence a un DocumentRoot hors `public/`. Deny Apache sur
`tools/` ne s’étend pas à `database/`. Permissions fichier (`chmod 000`) ≠
correction versionnée.

## DÉPLOIEMENT-TEST

```
cd /home/hectare-envol/envol.hectare.fr
git pull origin main
php artisan optimize:clear
php artisan view:clear
find app/ config/ database/ -name "*.php" -exec touch {} +
```

**Permissions :** voie (i) retenue — le fichier **disparaît** au pull. Le
`chmod 000` d’urgence n’a plus d’objet (plus de fichier). Si un `git pull`
restaure un conflit / une copie locale de `run_tests_1324.php`, la
**supprimer**.

Vérifications post-déploiement (lecture) :

```
test ! -f database/data/siwi_import_sample/run_tests_1324.php && echo ABSENT_OK
cat database/.htaccess
cat database/data/.htaccess
php artisan siwi:import --path=/tmp/siwi-vide-1480
# attendu : Refus dossier source introuvable ou vide + liste des 4 CSV
# NE PAS lancer --apply sur l'échantillon du dépôt.
```
