Un build réussi n'est pas la même chose qu'un déploiement réussi.
Votre image Docker se construit proprement. Le conteneur démarre. Trente secondes plus tard, il plante parce que DEBUG = True de Django est activé, qu'il n'y a pas de script start dans package.json, qu'une clé API est codée en dur dans le code source, ou que l'application écoute sur 127.0.0.1 au lieu de 0.0.0.0 et est injoignable depuis l'extérieur du conteneur.
Ce ne sont pas des erreurs de build. Ce sont des erreurs de déploiement -- le genre qui passe la CI, survit à la revue de code, et ne se manifeste qu'à 2 heures du matin quand un client signale que votre application est hors service. Nous avons décidé que sh0 devait les détecter avant même que le conteneur ne démarre.
Le résultat : un moteur d'analyse statique en Rust pur avec 34 règles réparties en 8 catégories, construit le Jour Zéro en Phase 6 du marathon de développement de sh0.
Le changement de paradigme : de « Est-ce que ça compile ? » à « Est-ce que ça va tourner ? »
Quand nous avons terminé le moteur de build (Phase 5), sh0 pouvait détecter la stack d'un projet, générer un Dockerfile et construire une image de conteneur. C'était nécessaire mais insuffisant. Un PaaS qui compile votre code puis le laisse échouer en production n'est pas vraiment une plateforme.
L'intuition était simple : la plupart des échecs de déploiement ne sont pas exotiques. Ce sont les mêmes 30 ou 40 erreurs, répétées sur des millions de projets. Un script de démarrage manquant. Une adresse localhost codée en dur. Un secret exposé. Un flag de débogage laissé activé. Tout cela est détectable depuis le code source seul, sans exécuter l'application, sans LLM, sans aucun service externe.
Nous voulions que sh0 dise : « Votre code a été compilé avec succès, mais voici 3 choses qui vont probablement casser en production. »
Architecture : pointeurs de fonction et un contexte en lecture unique
Le moteur a trois couches : le ScanContext qui lit le projet, les structs Rule qui l'analysent, et le Engine qui orchestre le tout.
ScanContext : lire une fois, scanner plusieurs fois
La décision de performance la plus importante était de lire chaque fichier exactement une fois. Chaque règle doit chercher des motifs dans le contenu des fichiers -- clés API, valeurs de configuration, instructions d'import. Laisser chaque règle lire les fichiers indépendamment signifierait lire le même package.json 34 fois.
rustpub struct ScanContext {
pub files: HashMap<PathBuf, String>,
pub package_json: Option<serde_json::Value>,
pub gitignore_patterns: Vec<String>,
pub file_count: usize,
}
impl ScanContext {
pub fn from_directory(path: &Path) -> Result<Self, BuilderError> {
let mut files = HashMap::new();
for entry in WalkDir::new(path)
.into_iter()
.filter_entry(|e| !is_skippable(e))
{
let entry = entry?;
if !entry.file_type().is_file() {
continue;
}
// Ignorer les fichiers de plus de 1 Mo
if entry.metadata()?.len() > 1_048_576 {
continue;
}
if let Ok(content) = std::fs::read_to_string(entry.path()) {
let rel = entry.path().strip_prefix(path)?;
files.insert(rel.to_path_buf(), content);
}
}
let package_json = files.get(Path::new("package.json"))
.and_then(|s| serde_json::from_str(s).ok());
let gitignore_patterns = files.get(Path::new(".gitignore"))
.map(|s| s.lines().map(String::from).collect())
.unwrap_or_default();
let file_count = files.len();
Ok(Self { files, package_json, gitignore_patterns, file_count })
}
}Le parcours de répertoire ignore .git, node_modules, target, vendor, __pycache__, .venv, dist, .next et .nuxt -- des répertoires qui peuvent contenir des dizaines de milliers de fichiers mais ne contiennent jamais du code source écrit par l'utilisateur. La limite de 1 Mo empêche les fichiers binaires de consommer de la mémoire.
Le contexte fournit également des méthodes utilitaires que les règles utilisent constamment :
rustimpl ScanContext {
pub fn has_file(&self, name: &str) -> bool {
self.files.contains_key(Path::new(name))
}
pub fn read_file(&self, name: &str) -> Option<&str> {
self.files.get(Path::new(name)).map(|s| s.as_str())
}
pub fn grep(&self, pattern: &str) -> Vec<(&Path, usize, &str)> {
self.files.iter()
.flat_map(|(path, content)| {
content.lines().enumerate()
.filter(|(_, line)| line.contains(pattern))
.map(move |(n, line)| (path.as_path(), n + 1, line))
})
.collect()
}
pub fn grep_in(&self, file: &str, pattern: &str) -> Vec<(usize, &str)> {
self.files.get(Path::new(file))
.map(|content| {
content.lines().enumerate()
.filter(|(_, line)| line.contains(pattern))
.map(|(n, line)| (n + 1, line))
.collect()
})
.unwrap_or_default()
}
pub fn is_test_file(&self, path: &Path) -> bool {
let s = path.to_string_lossy();
s.contains("test") || s.contains("spec") || s.contains("__tests__")
}
}La méthode grep() est le cheval de bataille. Elle cherche dans chaque fichier du projet un motif et retourne le chemin du fichier, le numéro de ligne et le contenu de la ligne. Les règles l'utilisent pour produire des résultats avec des localisations précises : « Clé API trouvée dans src/config.js à la ligne 42. »
Règles : pointeurs de fonction, pas de traits
Chaque règle est une struct contenant un pointeur de fonction :
rustpub struct Rule {
pub id: &'static str,
pub name: &'static str,
pub category: Category,
pub severity: Severity,
pub stacks: Option<Vec<Stack>>, // None = s'applique à toutes les stacks
pub check: fn(&ScanContext) -> Vec<HealthIssue>,
}Nous avons choisi les pointeurs de fonction plutôt que les trait objects pour deux raisons. Premièrement, la simplicité : définir une nouvelle règle signifie écrire une fonction, pas implémenter un trait sur une nouvelle struct. Deuxièmement, la performance : les pointeurs de fonction sont une valeur de la taille d'un pointeur avec dispatch statique, tandis que les trait objects nécessitent une indirection via vtable.
Le champ stacks permet le filtrage par stack. Les règles Django ne s'exécutent que sur les projets Django. Les règles Next.js ne s'exécutent que sur les projets Next.js. Les règles génériques comme les vérifications de sécurité s'exécutent sur tout.
Les 34 règles
Règles de sécurité (SEC001-SEC007)
Les règles de sécurité sont les plus universellement applicables. Elles scannent chaque projet quelle que soit la stack.
SEC001 -- Clés API dans le code source :
rustfn check_api_keys(ctx: &ScanContext) -> Vec<HealthIssue> {
let patterns = [
"AKIA", // préfixe de clé d'accès AWS
"sk_live_", // clé secrète Stripe live
"sk_test_", // clé secrète Stripe test
"ghp_", // jeton d'accès personnel GitHub
"gho_", // jeton d'accès OAuth GitHub
"glpat-", // jeton d'accès personnel GitLab
"xoxb-", // jeton bot Slack
"xoxp-", // jeton utilisateur Slack
];
let mut issues = Vec::new();
for pattern in &patterns {
for (path, line, content) in ctx.grep(pattern) {
if ctx.is_test_file(path) || ctx.is_comment_line(content) {
continue;
}
issues.push(issue(
"SEC001",
Severity::Blocking,
format!("Possible clé API ({}) trouvée dans {}:{}", pattern, path.display(), line),
"Déplacez les secrets dans des variables d'environnement et ajoutez le fichier au .gitignore.",
));
}
}
issues
}La règle vérifie les préfixes de jetons bien connus. Les clés d'accès AWS commencent toujours par AKIA. Les clés secrètes Stripe commencent par sk_live_ ou sk_test_. Les jetons GitHub commencent par ghp_. Ce ne sont pas des heuristiques -- ce sont des préfixes structurels définis par chaque service.
Les vérifications is_test_file et is_comment_line empêchent les faux positifs. Un fichier de test qui inclut "AKIA_FAKE_KEY" comme mock n'est pas un problème de sécurité. Un commentaire expliquant « utilisez le format AKIA » n'est pas une clé divulguée.
SEC002 -- Mots de passe dans le code source :
``rust
fn check_passwords(ctx: &ScanContext) -> Vec<HealthIssue> {
let patterns = [
"password = \"",
"password = '",
"PASSWORD = \"",
"passwd = \"",
"secret = \"",
];
// ... scan similaire basé sur grep
}
``
SEC006 -- Mode débogage activé détecte DEBUG = True dans les paramètres Django, debug: true dans les fichiers de configuration, et des motifs similaires à travers les frameworks.
SEC007 -- .env dans le dépôt vérifie si .env est suivi (présent dans l'arborescence mais pas dans .gitignore) :
rustfn check_env_file(ctx: &ScanContext) -> Vec<HealthIssue> {
if ctx.has_file(".env") && !ctx.gitignore_patterns.iter().any(|p| p.trim() == ".env") {
vec![issue(
"SEC007",
Severity::Blocking,
"Fichier .env trouvé dans le projet sans exclusion .gitignore",
"Ajoutez .env au .gitignore. Ne commitez jamais les fichiers d'environnement.",
)]
} else {
vec![]
}
}Règles Node.js (NODE001-NODE005)
NODE001 -- Script de démarrage manquant est la raison la plus courante d'échec des déploiements Node.js :
rustfn check_start_script(ctx: &ScanContext) -> Vec<HealthIssue> {
if let Some(pkg) = &ctx.package_json {
let has_start = pkg.get("scripts")
.and_then(|s| s.get("start"))
.is_some();
if !has_start {
return vec![issue(
"NODE001",
Severity::Blocking,
"Pas de script \"start\" dans package.json",
"Ajoutez un script \"start\" (ex : \"node dist/index.js\") dans package.json.",
)];
}
}
vec![]
}C'est un problème bloquant parce que sans script de démarrage, le conteneur démarrera et se terminera immédiatement. L'utilisateur verra « déploiement échoué » sans message d'erreur utile de son application.
NODE002 -- Port codé en dur détecte app.listen(3000) ou server.listen(8080) sans lire depuis process.env.PORT. Dans un environnement PaaS, la plateforme assigne le port via une variable d'environnement.
NODE003 -- Dépendances de développement en production vérifie la présence d'entrées devDependencies comme nodemon ou ts-node dans le script start -- des outils qui ne devraient jamais tourner en production.
Règles Python (PY001-PY006)
Les règles Python se concentrent sur Django et FastAPI, les deux frameworks Python de production les plus courants.
PY001 -- Django DEBUG = True :
``rust
fn check_django_debug(ctx: &ScanContext) -> Vec<HealthIssue> {
for (path, line, content) in ctx.grep("DEBUG") {
if path.to_string_lossy().contains("settings")
&& content.contains("= True")
&& !ctx.is_comment_line(content)
{
return vec![issue(
"PY001",
Severity::Blocking,
format!("Django DEBUG = True dans {}:{}", path.display(), line),
"Définissez DEBUG = os.environ.get('DEBUG', 'False') == 'True'",
)];
}
}
vec![]
}
``
Django avec DEBUG = True en production expose les traces complètes, les requêtes de base de données et les détails de configuration à tout visiteur qui déclenche une erreur. Ce n'est pas un avertissement -- c'est un problème bloquant.
PY002 -- Django SECRET_KEY codée en dur détecte SECRET_KEY = "..." dans les fichiers de configuration. Une clé secrète codée en dur signifie que chaque déploiement partage le même matériel cryptographique, et quiconque a accès au code source peut falsifier des sessions.
PY005 -- Uvicorn avec --reload détecte uvicorn main:app --reload dans les commandes de démarrage en production. Le flag --reload surveille les changements de fichiers et redémarre le serveur -- utile en développement, un problème de performance et de fiabilité en production.
Règles Go, Java, Build et Configuration
Les catégories restantes suivent le même schéma :
- GO001-GO003 : adresses d'écoute codées en dur,
go.modmanquant, absence de gestion de l'arrêt gracieux - JAVA001-JAVA004 : console H2 exposée, Spring Actuator sans sécurité, profil dev actif, flags mémoire JVM manquants
- BUILD001-BUILD003 : TypeScript configuré mais non compilé, script de build manquant, lockfile absent
- CFG001-CFG004 : commande de démarrage manquante, port codé en dur, écoute sur localhost,
.dockerignoreabsent
Le système de scoring
Une fois que toutes les règles applicables ont été exécutées, le moteur calcule un score de santé :
rustpub fn compute_score(issues: &[HealthIssue]) -> u8 {
let blocking = issues.iter().filter(|i| i.severity == Severity::Blocking).count();
let warnings = issues.iter().filter(|i| i.severity == Severity::Warning).count();
let info = issues.iter().filter(|i| i.severity == Severity::Info).count();
let penalty = (blocking * 20) + (warnings * 5) + (info * 1);
100u8.saturating_sub(penalty as u8)
}La formule : on commence à 100, on soustrait 20 pour chaque problème bloquant, 5 pour chaque avertissement, 1 pour chaque information. Le score est plafonné à 0.
Un projet avec une clé API codée en dur (bloquant, -20) et un .dockerignore manquant (avertissement, -5) obtient 75. Un projet avec trois problèmes de sécurité bloquants obtient 40. Le score donne aux utilisateurs un sens instantané et quantifiable de leur préparation au déploiement.
Le moteur calcule également la complexité du projet en fonction du nombre de fichiers :
rustpub fn compute_complexity(file_count: usize) -> Complexity {
let level = if file_count < 20 {
ComplexityLevel::Simple
} else if file_count < 100 {
ComplexityLevel::Medium
} else {
ComplexityLevel::Complex
};
Complexity { level, file_count }
}La complexité est informative, pas punitive. Elle aide les utilisateurs à comprendre la portée du scan.
Pourquoi du Rust pur, pas de LLM
Nous avons fait le choix délibéré d'implémenter chaque règle comme du pattern matching déterministe, pas de l'analyse basée sur un LLM.
La vitesse. Le scan complet s'exécute en millisecondes. Un appel LLM prend des secondes, au minimum. Quand un développeur pousse du code, il veut savoir immédiatement si quelque chose ne va pas -- pas après un appel API de 10 secondes à un modèle de langage.
Le déterminisme. Le même code produit les mêmes résultats à chaque fois. Il n'y a pas de problèmes hallucinés, pas de détections manquées parce que le modèle avait un mauvais jour, pas d'inconsistances « ça marche avec GPT-4o mais pas avec GPT-4o-mini ». Si SEC001 se déclenche, il y a une chaîne correspondant à AKIA à la ligne 42 de votre fichier source. Point final.
Le fonctionnement hors ligne. sh0 est une plateforme auto-hébergée. Elle peut tourner sur un serveur sans accès Internet, derrière un pare-feu d'entreprise, ou sur un réseau isolé. Une dépendance à un service LLM externe casserait ces déploiements.
Le coût. Chaque appel LLM coûte de l'argent. Les utilisateurs de sh0 peuvent pousser du code des dizaines de fois par jour. Facturer les health checks (ou absorber le coût nous-mêmes) serait insoutenable.
Le compromis est que le pattern matching pur ne peut pas détecter les problèmes sémantiques. Il ne peut pas vous dire que votre logique d'authentification a un bug de type time-of-check-to-time-of-use, ou que vos requêtes SQL sont vulnérables à l'injection. Mais il peut détecter les 34 erreurs de déploiement les plus courantes, et cela couvre la grande majorité des problèmes que les vrais utilisateurs rencontrent en production.
Intégration : non bloquant par défaut
Le moteur de health check s'intègre dans le pipeline de build comme une étape informative :
rustimpl Builder {
pub async fn build(&self, opts: BuildOpts) -> Result<BuildOutput, BuilderError> {
let detected = detect(&opts.source_path);
// Le health check s'exécute après la détection, avant le build
let health = check_health(&opts.source_path, &detected).await?;
// Le build continue quel que soit le score de santé
let dockerfile = generate_dockerfile(&detected);
let image_id = self.docker.build_image(/* ... */).await?;
Ok(BuildOutput {
image_id,
stack: detected,
health_report: Some(health),
// ...
})
}
}Le rapport de santé est attaché à la sortie du build mais ne bloque pas le build. Un projet avec un score de 40 se déploie quand même. Les résultats sont présentés à l'utilisateur dans le tableau de bord, lui permettant de décider quoi corriger et quand.
Il existe également une fonction check() autonome pour exécuter les health checks sans construire :
rustpub async fn check(path: &Path) -> Result<HealthReport, BuilderError> {
let detected = detect(path);
check_health(path, &detected).await
}Cela alimente la commande CLI sh0 check, que les développeurs peuvent exécuter localement avant de pousser.
Vérification : 82 tests
Le moteur de health check a ajouté 59 nouveaux tests en plus des 23 tests existants du moteur de build, portant la crate sh0-builder à 82 tests au total. Chaque règle a au moins un test positif (le motif est présent, la règle se déclenche) et un test négatif (le motif est absent, la règle ne se déclenche pas). Les cas limites comme les motifs dans les fichiers de test, les motifs dans les commentaires et les correspondances partielles sont couverts.
cargo test -p sh0-builder 82 tests réussis
cargo clippy -p sh0-builder 0 warningCe qui est venu ensuite
Avec 34 règles couvrant la sécurité, la configuration, les problèmes spécifiques aux frameworks et l'hygiène de build, le moteur de health check de sh0 offre aux utilisateurs un retour exploitable avant que leur code n'atteigne la production. Ce n'est pas un remplacement pour un audit de sécurité ou une revue de code approfondie. C'est un filet de sécurité rapide et déterministe qui détecte les erreurs évidentes -- celles qui représentent 80 % des tickets de support « pourquoi mon déploiement est-il cassé ? » sur toutes les plateformes PaaS.
Les phases suivantes du développement de sh0 sont passées de l'analyse statique à l'infrastructure d'exécution : le reverse proxy avec SSL automatique (Phase 7), le pipeline de déploiement complet (Phase 8), et le système d'authentification (Phase 9). Ce sont des histoires pour les prochains articles de cette série.
Ceci est la Partie 4 de la série « Comment nous avons construit sh0.dev ».
Navigation de la série : - [1] Jour Zéro : 10 crates Rust en 24 heures - [2] Écrire un client Docker Engine from scratch en Rust - [3] Détection automatique de 19 stacks technologiques depuis le code source - [4] 34 règles pour détecter les erreurs de déploiement avant qu'elles ne surviennent (vous êtes ici)