chore(id_verif): init repo (docker-support-v2)

This commit is contained in:
Debian 2025-09-02 14:30:46 +00:00
commit 9e2d56383b
11 changed files with 1934 additions and 0 deletions

18
.cursorignore Normal file
View File

@ -0,0 +1,18 @@
# Caches lourds
node_modules/
dist/
build/
coverage/
logs/
# Binaires / vendors
vendor/
# OS
.DS_Store
Thumbs.db
# Ne PAS ignorer .cursor/ ni AGENTS.md
!/AGENTS.md

59
.gitea/workflows/ci.yml Normal file
View File

@ -0,0 +1,59 @@
name: CI - id_verif
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
code-quality:
name: Code Quality
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: |
if [ -f package.json ]; then (npm ci || npm install); fi
- name: Lint (if present)
run: |
if [ -f package.json ]; then (npm run lint || true); fi
- name: Build (if present)
run: |
if [ -f package.json ]; then (npm run build || true); fi
security-audit:
name: Security Audit
runs-on: ubuntu-latest
needs: [code-quality]
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Ensure scripts executable
run: |
chmod +x scripts/security/audit.sh || true
- name: Run security audit
run: |
if [ -f scripts/security/audit.sh ]; then
./scripts/security/audit.sh
else
echo "No security audit script (ok)"
fi
docker-build:
name: Docker Build
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build Docker image
run: |
docker build -t id_verif:latest .

29
.gitignore vendored Normal file
View File

@ -0,0 +1,29 @@
# Node / web
node_modules/
dist/
build/
coverage/
logs/
*.log
npm-debug.log*
yarn-error.log*
# OS / misc
.DS_Store
Thumbs.db
# Env
.env*
!.env.example
# Rust (si présent)
.cargo/
target/
# Vendor
vendor/
# Ne pas ignorer AGENTS.md
!AGENTS.md

12
AGENTS.md Normal file
View File

@ -0,0 +1,12 @@
# AGENTS
Ce dépôt utilise des agents (Cursor/4NK) pour appliquer les bonnes pratiques.
## Sécurité (vigilance)
- Exécuter l'audit: `scripts/security/audit.sh` (npm audit, scan de secrets).
- Aucun secret en clair dans le dépôt; secrets via CI/variables d'environnement, rotation exigée.
- Vérifier permissions des fichiers sensibles et non-exposition d'endpoints privés.
- Si une CI est ajoutée, inclure un job `security-audit` et bloquer la release en cas d'échec.

10
Dockerfile Normal file
View File

@ -0,0 +1,10 @@
# Debian-based Node server
FROM node:20-bookworm-slim
WORKDIR /app
COPY package*.json ./
RUN npm ci || npm install
COPY . .
EXPOSE 3000
CMD ["node","server.js"]

8
docs/SECURITY_AUDIT.md Normal file
View File

@ -0,0 +1,8 @@
# Audit de Sécurité - id_verif
- CI: job `security-audit` exécutant `scripts/security/audit.sh`.
- Portée: npm audit (niveau moderate+), scan de secrets.
- Critères bloquants: vulnérabilités élevées/critiques, secrets détectés.
- En cas déchec, la publication est bloquée.

551
index.html Normal file
View File

@ -0,0 +1,551 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Vérification locale de carte d'identité (OCR + MRZ)</title>
<style>
body {
font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Helvetica, Arial, sans-serif;
line-height: 1.45;
margin: 24px
}
h1 {
font-size: 1.35rem;
margin: 0 0 8px
}
h2 {
font-size: 1.05rem;
margin: 16px 0 8px
}
.row {
display: flex;
gap: 16px;
flex-wrap: wrap
}
.col {
flex: 1 1 360px
}
.box {
border: 1px solid #ccc;
border-radius: 8px;
padding: 12px
}
label {
display: block;
margin: 8px 0 4px
}
img {
max-width: 100%;
height: auto;
border: 1px solid #ddd;
border-radius: 6px
}
pre {
white-space: pre-wrap;
background: #fafafa;
border: 1px solid #eee;
padding: 8px;
border-radius: 6px;
max-height: 260px;
overflow: auto
}
table {
border-collapse: collapse;
width: 100%
}
th,
td {
border: 1px solid #ddd;
padding: 6px 8px;
text-align: left
}
.ok {
color: #156f00;
font-weight: 600
}
.ko {
color: #9c1515;
font-weight: 600
}
.warn {
color: #7a5d00;
font-weight: 600
}
.kv {
font-family: ui-monospace, Consolas, Monaco, monospace
}
.muted {
color: #555
}
.progress {
height: 10px;
background: #eee;
border-radius: 999px;
overflow: hidden
}
.bar {
height: 100%;
width: 0%;
background: #999;
transition: width .2s
}
.mono {
font-family: ui-monospace, Consolas, Monaco, monospace
}
code {
font-family: ui-monospace, Consolas, Monaco, monospace
}
.hint {
font-size: .92rem;
margin-top: 8px
}
</style>
<!-- Tesseract.js auto-hébergé pour éviter les erreurs MIME depuis certains CDN -->
<script src="/vendor/tesseract/tesseract.min.js"></script>
</head>
<body>
<h1>Vérification locale de carte d'identité (OCR + MRZ)</h1>
<div class="row">
<div class="col box">
<label for="file">Image de la pièce (photo/scan, MRZ nette)</label>
<input id="file" type="file" accept="image/*">
<div class="progress" style="margin:12px 0">
<div id="bar" class="bar"></div>
</div>
<button id="run">Analyser l'image</button>
<div class="hint muted">
Conseils: recadrer pour que la MRZ (2×44 ou 3×30 caractères) soit horizontale et contrastée. Éviter les reflets.
</div>
<h2>Aperçu</h2>
<img id="preview" alt="">
<h2>Texte OCR (brut)</h2>
<pre id="raw"></pre>
</div>
<div class="col box">
<h2>Résultats</h2>
<table id="results">
<tbody></tbody>
</table>
<h2>Score de vraisemblance</h2>
<div id="scoreBlock">
<div class="kv">Score total: <span id="score"></span> / 100</div>
<pre id="scoreDetails"></pre>
</div>
<h2>Données structurées</h2>
<pre id="json"></pre>
</div>
</div>
<script>
// ---------- paramètres Tesseract.js épinglés pour usage en HTML statique ----------
// Chemins locaux pour worker et core
const WORKER_PATH = '/vendor/tesseract/worker.min.js';
const CORE_BASE = '/vendor/tesseract-core/'; // fichiers à la racine du module
const LANG_PATH = '/vendor/tessdata';
// ---------- éléments UI ----------
const fileInput = document.getElementById( 'file' );
const preview = document.getElementById( 'preview' );
const bar = document.getElementById( 'bar' );
const raw = document.getElementById( 'raw' );
const resultsBody = document.querySelector( '#results tbody' );
const scoreEl = document.getElementById( 'score' );
const scoreDetails = document.getElementById( 'scoreDetails' );
const jsonOut = document.getElementById( 'json' );
fileInput.addEventListener( 'change', e => {
const f = e.target.files[ 0 ];
if ( !f ) return;
const url = URL.createObjectURL( f );
preview.src = url;
raw.textContent = '';
} );
// ---------- OCR correctement initialisé ----------
async function recognizeImage ( file ) {
// création du worker avec chemins explicites pour fonctionner en file://
const worker = await Tesseract.createWorker( {
logger: m => {
if ( m.status === 'recognizing text' && m.progress != null ) {
bar.style.width = Math.round( m.progress * 100 ) + '%';
}
},
workerPath: WORKER_PATH,
corePath: CORE_BASE,
langPath: LANG_PATH,
} );
try {
// langues
await worker.loadLanguage( 'fra+eng' );
await worker.initialize( 'fra+eng' );
// reconnaissance
return await worker.recognize( file );
} finally {
await worker.terminate();
}
}
document.getElementById( 'run' ).addEventListener( 'click', async () => {
const f = fileInput.files[ 0 ];
if ( !f ) { alert( 'Choisir une image.' ); return; }
if ( !f.type.startsWith( 'image/' ) ) { alert( 'Fichier non image.' ); return; }
if ( f.size < 1024 ) { alert( 'Fichier trop petit ou vide.' ); return; }
clearOutputs();
try {
const res = await recognizeImage( f );
const text = res?.data?.text || '';
raw.textContent = text;
const analysis = analyzeText( text );
renderAnalysis( analysis );
} catch ( err ) {
console.error( err );
alert( 'Échec OCR. Vérifier laccès réseau au CDN pour charger le modèle, ou tester sous serveur local.' );
}
} );
// ---------- analyse MRZ (ICAO Doc 9303 TD1/TD3) ----------
const ISO3 = {
FRA: "France", DZA: "Algérie", MAR: "Maroc", TUN: "Tunisie", DEU: "Allemagne", ESP: "Espagne", ITA: "Italie",
PRT: "Portugal", BEL: "Belgique", NLD: "Pays-Bas", LUX: "Luxembourg", CHE: "Suisse", GBR: "Royaume-Uni",
IRL: "Irlande", USA: "États-Unis", CAN: "Canada", MEX: "Mexique", BRA: "Brésil", ARG: "Argentine",
CHN: "Chine", JPN: "Japon", KOR: "Corée du Sud", IND: "Inde", RUS: "Russie", UKR: "Ukraine",
POL: "Pologne", CZE: "Tchéquie", SVK: "Slovaquie", HUN: "Hongrie", AUT: "Autriche", SWE: "Suède",
NOR: "Norvège", DNK: "Danemark", FIN: "Finlande", ISL: "Islande", GRC: "Grèce", ROU: "Roumanie",
BGR: "Bulgarie", HRV: "Croatie", SVN: "Slovénie", EST: "Estonie", LVA: "Lettonie", LTU: "Lituanie",
CYP: "Chypre", MLT: "Malte"
};
function isoName ( code ) { return ISO3[ code ]?.toString() || null; }
function analyzeText ( text ) {
const norm = text
.toUpperCase()
.replace( /[^\x20-\x7E]/g, '' )
.replace( /[ ]+/g, ' ' )
.replace( /\u00AB|\u00BB/g, '<' )
.replace( /[|]/g, '<' );
const lines = norm.split( /\r?\n/ ).map( l => l.trim() ).filter( Boolean );
const joined = lines.join( '\n' );
const td1 = findTD1( lines );
const td3 = findTD3( lines );
let mrz = null;
let format = null;
if ( td1 ) { mrz = td1; format = 'TD1'; }
else if ( td3 ) { mrz = td3; format = 'TD3'; }
let parsed = null, checks = null;
if ( mrz ) {
if ( format === 'TD1' ) { parsed = parseTD1( mrz ); checks = checkTD1( parsed ); }
else { parsed = parseTD3( mrz ); checks = checkTD3( parsed ); }
}
const allowed = /[A-Z0-9<]/;
const mrzStr = mrz ? mrz.join( '\n' ) : joined;
const mrzChars = mrzStr.replace( /\s+/g, '' ).split( '' );
const allowedCount = mrzChars.filter( ch => allowed.test( ch ) ).length;
const allowedRatio = mrzChars.length ? allowedCount / mrzChars.length : 0;
const plausibility = scorePlausibility( {
hasMRZ: !!mrz, format, checks, parsed, allowedRatio
} );
return { norm, lines, mrz, format, parsed, checks, allowedRatio, plausibility };
}
function findTD1 ( lines ) {
for ( let i = 0; i < lines.length - 2; i++ ) {
const a = lines[ i ], b = lines[ i + 1 ], c = lines[ i + 2 ];
if ( isMRZLen( a, 30 ) && isMRZLen( b, 30 ) && isMRZLen( c, 30 ) ) return [ a.slice( 0, 30 ), b.slice( 0, 30 ), c.slice( 0, 30 ) ];
}
return null;
}
function findTD3 ( lines ) {
for ( let i = 0; i < lines.length - 1; i++ ) {
const a = lines[ i ], b = lines[ i + 1 ];
if ( isMRZLen( a, 44 ) && isMRZLen( b, 44 ) ) return [ a.slice( 0, 44 ), b.slice( 0, 44 ) ];
}
return null;
}
function isMRZLen ( line, len ) {
return line && line.length >= len && /^[A-Z0-9<]+$/.test( line.slice( 0, len ) );
}
function parseTD1 ( [ L1, L2, L3 ] ) {
L1 = L1.padEnd( 30, '<' ); L2 = L2.padEnd( 30, '<' ); L3 = L3.padEnd( 30, '<' );
const docCode = L1.slice( 0, 2 );
const issuing = L1.slice( 2, 5 );
const namesRaw = L1.slice( 5, 30 ).replace( /<+/g, ' ' ).trim();
const [ surname, given ] = splitNames( namesRaw );
const docNumber = L2.slice( 0, 9 ).replace( /</g, '' );
const docNumberChk = L2[ 9 ];
const nationality = L2.slice( 10, 13 );
const birth = L2.slice( 13, 19 );
const birthChk = L2[ 19 ];
const sex = L2[ 20 ];
const expiry = L2.slice( 21, 27 );
const expiryChk = L2[ 27 ];
const optA = L2.slice( 28, 30 );
const optB = L3.slice( 0, 29 );
const compositeChk = L3[ 29 ];
const optional = ( optA + optB ).replace( /<+/g, '' ).trim() || null;
return {
format: 'TD1', docCode, issuing, surname, given,
docNumber, docNumberChk, nationality, birth, birthChk,
sex, expiry, expiryChk, optional, compositeChk,
raw: [ L1, L2, L3 ]
};
}
function parseTD3 ( [ L1, L2 ] ) {
L1 = L1.padEnd( 44, '<' ); L2 = L2.padEnd( 44, '<' );
const docCode = L1.slice( 0, 2 );
const issuing = L1.slice( 2, 5 );
const namesRaw = L1.slice( 5, 44 ).replace( /<+/g, ' ' ).trim();
const [ surname, given ] = splitNames( namesRaw );
const docNumber = L2.slice( 0, 9 ).replace( /</g, '' );
const docNumberChk = L2[ 9 ];
const nationality = L2.slice( 10, 13 );
const birth = L2.slice( 13, 19 );
const birthChk = L2[ 19 ];
const sex = L2[ 20 ];
const expiry = L2.slice( 21, 27 );
const expiryChk = L2[ 27 ];
const personal = L2.slice( 28, 42 );
const compositeChk = L2[ 43 ];
return {
format: 'TD3', docCode, issuing, surname, given,
docNumber, docNumberChk, nationality, birth, birthChk,
sex, expiry, expiryChk, optional: personal.replace( /<+/g, '' ).trim() || null,
compositeChk, raw: [ L1, L2 ]
};
}
function splitNames ( full ) {
if ( full.includes( '<<' ) ) {
const [ sn, gn ] = full.split( '<<' );
return [ sn.replace( /<+/g, ' ' ).trim(), gn?.replace( /<+/g, ' ' ).trim() || null ];
}
const words = full.split( ' ' ).filter( Boolean );
if ( words.length > 1 ) return [ words[ 0 ], words.slice( 1 ).join( ' ' ) ];
return [ full, null ];
}
// chiffres de contrôle (pondérations 7-3-1)
const weights = [ 7, 3, 1 ];
function charValue ( ch ) {
if ( ch >= '0' && ch <= '9' ) return ch.charCodeAt( 0 ) - 48;
if ( ch >= 'A' && ch <= 'Z' ) return ch.charCodeAt( 0 ) - 55;
if ( ch === '<' ) return 0;
return 0;
}
function checkDigitFor ( str ) {
let sum = 0;
for ( let i = 0; i < str.length; i++ ) {
const v = charValue( str[ i ] );
const w = weights[ i % 3 ];
sum += v * w;
}
return String( sum % 10 );
}
function checkTD1 ( p ) {
if ( !p ) return null;
const L2 = p.raw[ 1 ].slice( 0, 30 );
const L3 = p.raw[ 2 ].slice( 0, 30 );
const cDoc = checkDigitFor( p.raw[ 1 ].slice( 0, 9 ) );
const cBirth = checkDigitFor( p.birth );
const cExp = checkDigitFor( p.expiry );
const compositeField = L2 + L3.slice( 0, 29 );
const cComp = checkDigitFor( compositeField );
return {
docNumber: { expected: cDoc, actual: p.docNumberChk, valid: cDoc === p.docNumberChk },
birth: { expected: cBirth, actual: p.birthChk, valid: cBirth === p.birthChk },
expiry: { expected: cExp, actual: p.expiryChk, valid: cExp === p.expiryChk },
composite: { expected: cComp, actual: p.compositeChk, valid: cComp === p.compositeChk }
};
}
function checkTD3 ( p ) {
if ( !p ) return null;
const cDoc = checkDigitFor( p.raw[ 1 ].slice( 0, 9 ) );
const cBirth = checkDigitFor( p.birth );
const cExp = checkDigitFor( p.expiry );
const compositeField = p.raw[ 1 ].slice( 0, 10 ) + p.raw[ 1 ].slice( 13, 20 ) + p.birthChk + p.raw[ 1 ].slice( 21, 28 ) + p.expiryChk + p.raw[ 1 ].slice( 28, 42 );
const cComp = checkDigitFor( compositeField );
return {
docNumber: { expected: cDoc, actual: p.docNumberChk, valid: cDoc === p.docNumberChk },
birth: { expected: cBirth, actual: p.birthChk, valid: cBirth === p.birthChk },
expiry: { expected: cExp, actual: p.expiryChk, valid: cExp === p.expiryChk },
composite: { expected: cComp, actual: p.compositeChk, valid: cComp === p.compositeChk }
};
}
function parseYYMMDD ( yyMMdd ) {
if ( !/^[0-9]{6}$/.test( yyMMdd ) ) return null;
const yy = parseInt( yyMMdd.slice( 0, 2 ), 10 );
const mm = parseInt( yyMMdd.slice( 2, 4 ), 10 );
const dd = parseInt( yyMMdd.slice( 4, 6 ), 10 );
if ( mm < 1 || mm > 12 || dd < 1 || dd > 31 ) return null;
const year = yy <= 29 ? 2000 + yy : 1900 + yy;
const date = new Date( Date.UTC( year, mm - 1, dd ) );
if ( date.getUTCMonth() !== mm - 1 || date.getUTCDate() !== dd ) return null;
return { year, month: mm, day: dd, iso: `${ year.toString().padStart( 4, '0' ) }-${ String( mm ).padStart( 2, '0' ) }-${ String( dd ).padStart( 2, '0' ) }` };
}
function ageFrom ( birthISO ) {
const d = new Date( birthISO ); const now = new Date();
let age = now.getUTCFullYear() - d.getUTCFullYear();
const m = now.getUTCMonth() - d.getUTCMonth();
if ( m < 0 || ( m === 0 && now.getUTCDate() < d.getUTCDate() ) ) age--;
return age;
}
function scorePlausibility ( { hasMRZ, format, checks, parsed, allowedRatio } ) {
let total = 0;
const lines = [];
const W = {
mrzPresent: 20, docNumber: 15, birth: 10, expiry: 10, composite: 15,
datesPlausible: 10, nationality: 5, issuing: 5, ocrClean: 10
};
if ( hasMRZ ) { total += W.mrzPresent; lines.push( `[+${ W.mrzPresent }] MRZ détectée (${ format }).` ); }
else { lines.push( `[+0] MRZ non détectée.` ); }
if ( checks ) {
if ( checks.docNumber.valid ) { total += W.docNumber; lines.push( `[+${ W.docNumber }] Numéro: chiffre de contrôle correct (${ checks.docNumber.actual }).` ); }
else { lines.push( `[+0] Numéro: attendu ${ checks.docNumber.expected }, obtenu ${ checks.docNumber.actual }.` ); }
if ( checks.birth.valid ) { total += W.birth; lines.push( `[+${ W.birth }] Date de naissance: chiffre de contrôle correct (${ checks.birth.actual }).` ); }
else { lines.push( `[+0] Naissance: attendu ${ checks.birth.expected }, obtenu ${ checks.birth.actual }.` ); }
if ( checks.expiry.valid ) { total += W.expiry; lines.push( `[+${ W.expiry }] Expiration: chiffre de contrôle correct (${ checks.expiry.actual }).` ); }
else { lines.push( `[+0] Expiration: attendu ${ checks.expiry.expected }, obtenu ${ checks.expiry.actual }.` ); }
if ( checks.composite.valid ) { total += W.composite; lines.push( `[+${ W.composite }] Contrôle composite correct.` ); }
else { lines.push( `[+0] Contrôle composite invalide.` ); }
} else {
lines.push( `[+0] Chiffres de contrôle non évalués.` );
}
let datesOK = false;
if ( parsed ) {
const b = parseYYMMDD( parsed.birth );
const e = parseYYMMDD( parsed.expiry );
if ( b && e ) {
const age = ageFrom( b.iso );
const nowISO = new Date().toISOString().slice( 0, 10 );
const expPast = ( new Date( e.iso ) < new Date( nowISO ) );
const ageOK = age >= 0 && age <= 120;
datesOK = ageOK;
lines.push( `[info] Naissance: ${ b.iso } ⇒ âge ${ age } ans.` );
lines.push( `[info] Expiration: ${ e.iso } ⇒ ${ expPast ? 'document expiré' : 'valide à date' }.` );
}
}
if ( datesOK ) { total += W.datesPlausible; lines.push( `[+${ W.datesPlausible }] Dates plausibles (âge 0120).` ); }
else { lines.push( `[+0] Dates non plausibles ou illisibles.` ); }
if ( parsed ) {
const nat = isoName( parsed.nationality );
const iss = isoName( parsed.issuing );
if ( nat ) { total += W.nationality; lines.push( `[+${ W.nationality }] Nationalité reconnue (${ parsed.nationality } = ${ nat }).` ); }
else { lines.push( `[+0] Code nationalité inconnu (${ parsed?.nationality || '' }).` ); }
if ( iss ) { total += W.issuing; lines.push( `[+${ W.issuing }] Pays émetteur reconnu (${ parsed.issuing } = ${ iss }).` ); }
else { lines.push( `[+0] Code pays émetteur inconnu (${ parsed?.issuing || '' }).` ); }
}
const ocrPts = Math.round( W.ocrClean * Math.min( 1, Math.max( 0, ( allowedRatio - 0.85 ) / 0.15 ) ) );
total += ocrPts;
lines.push( `[+${ ocrPts }] Propreté OCR (ratio caractères MRZ valides = ${ ( allowedRatio * 100 ).toFixed( 1 ) } %).` );
if ( total > 100 ) total = 100;
return { total, breakdown: lines.join( '\n' ) };
}
function renderAnalysis ( a ) {
resultsBody.innerHTML = '';
function row ( k, v, cls = '' ) { const tr = document.createElement( 'tr' ); tr.innerHTML = `<th>${ k }</th><td class="kv ${ cls }">${ v ?? '' }</td>`; resultsBody.appendChild( tr ); }
row( 'Format MRZ détecté', a.format || '—', a.mrz ? 'ok' : 'ko' );
if ( a.parsed ) {
const p = a.parsed, c = a.checks;
row( 'Code document', p.docCode );
row( 'Pays émetteur', `${ p.issuing } ${ isoName( p.issuing ) ? ' ' + isoName( p.issuing ) : '' }` );
row( 'Nom', p.surname || '—' );
row( 'Prénoms', p.given || '—' );
row( 'Numéro document', p.docNumber || '—' );
if ( c ) row( 'Contrôle numéro', c.docNumber.valid ? 'valide' : `invalide (attendu ${ c.docNumber.expected }, obtenu ${ c.docNumber.actual })`, c.docNumber.valid ? 'ok' : 'ko' );
row( 'Nationalité', `${ p.nationality } ${ isoName( p.nationality ) ? ' ' + isoName( p.nationality ) : '' }` );
const b = parseYYMMDD( p.birth );
if ( b ) { row( 'Naissance', `${ b.iso } (chiffre ${ a.checks?.birth?.actual || '' })` ); row( 'Âge estimé', ageFrom( b.iso ) + ' ans' ); }
const e = parseYYMMDD( p.expiry );
if ( e ) { const expPast = new Date( e.iso ) < new Date( new Date().toISOString().slice( 0, 10 ) ); row( 'Expiration', `${ e.iso } (${ expPast ? 'expiré' : 'valide' })` ); }
row( 'Sexe (MRZ)', p.sex || '—' );
if ( a.checks ) {
row( 'Contrôle naissance', a.checks.birth.valid ? 'valide' : 'invalide', a.checks.birth.valid ? 'ok' : 'ko' );
row( 'Contrôle expiration', a.checks.expiry.valid ? 'valide' : 'invalide', a.checks.expiry.valid ? 'ok' : 'ko' );
row( 'Contrôle composite', a.checks.composite.valid ? 'valide' : 'invalide', a.checks.composite.valid ? 'ok' : 'ko' );
}
row( 'Données optionnelles', p.optional || '—' );
} else {
row( 'MRZ', 'non détectée', 'ko' );
}
scoreEl.textContent = a.plausibility.total.toString();
scoreDetails.textContent = a.plausibility.breakdown;
jsonOut.textContent = JSON.stringify( {
format: a.format || null,
parsed: a.parsed || null,
checks: a.checks || null,
allowedRatio: a.allowedRatio,
score: a.plausibility.total
}, null, 2 );
}
function clearOutputs () {
resultsBody.innerHTML = '';
raw.textContent = '';
scoreEl.textContent = '';
scoreDetails.textContent = '';
jsonOut.textContent = '';
bar.style.width = '0%';
}
</script>
</body>
</html>

1104
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

19
package.json Normal file
View File

@ -0,0 +1,19 @@
{
"name": "id_verif",
"version": "1.0.0",
"description": "",
"main": "server.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node server.js"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"compression": "^1.8.1",
"express": "^5.1.0",
"tesseract.js": "^4.1.4",
"tesseract.js-core": "^4.0.4"
}
}

29
scripts/security/audit.sh Normal file
View File

@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
echo "[security-audit] démarrage"
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")"/../.. && pwd)"
cd "$ROOT_DIR"
rc=0
# 1) Audit npm (si package.json présent)
if [ -f package.json ]; then
echo "[security-audit] npm audit --audit-level=moderate"
if ! npm audit --audit-level=moderate; then rc=1; fi || true
else
echo "[security-audit] pas de package.json (ok)"
fi
# 2) Recherche de secrets grossiers
echo "[security-audit] scan secrets"
if grep -RIE "(?i)(api[_-]?key|secret|password|private[_-]?key)" --exclude-dir .git --exclude-dir node_modules --exclude "*.md" . >/dev/null 2>&1; then
echo "[security-audit] secrets potentiels détectés"; rc=1
else
echo "[security-audit] aucun secret évident"
fi
echo "[security-audit] terminé rc=$rc"
exit $rc

95
server.js Normal file
View File

@ -0,0 +1,95 @@
const express = require("express");
const compression = require("compression");
const path = require("path");
const app = express();
const port = process.env.PORT || 3000;
// Sécurité basique
app.disable("x-powered-by");
// Compression gzip/brotli
app.use(compression());
// Isolation requise pour de meilleures perfs WASM/Workers (Tesseract)
app.use((req, res, next) => {
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
res.setHeader("Cross-Origin-Resource-Policy", "same-origin");
next();
});
// Répertoire statique = dossier courant (contient index.html)
const staticRootDirectory = __dirname;
const nodeModulesDirectory = path.join(__dirname, "node_modules");
const vendorDirectory = path.join(__dirname, "vendor");
app.use(
express.static(staticRootDirectory, {
index: "index.html",
maxAge: "7d",
etag: true,
setHeaders(res, filePath) {
const ext = path.extname(filePath);
if (ext === ".html") {
res.setHeader("Cache-Control", "no-cache");
}
if (ext === ".wasm") {
res.setHeader("Content-Type", "application/wasm");
}
if (ext === ".js") {
res.setHeader("Content-Type", "application/javascript; charset=utf-8");
}
},
})
);
// Assets Tesseract.js auto-hébergés pour éviter les erreurs MIME/CDN
app.use(
"/vendor/tesseract",
express.static(path.join(nodeModulesDirectory, "tesseract.js/dist"), {
maxAge: "7d",
setHeaders(res, filePath) {
const ext = path.extname(filePath);
if (ext === ".wasm") res.setHeader("Content-Type", "application/wasm");
if (ext === ".js") res.setHeader("Content-Type", "application/javascript; charset=utf-8");
},
})
);
app.use(
"/vendor/tesseract-core",
// Le package tesseract.js-core >=4 place les fichiers à la racine du module (pas de dossier dist)
express.static(path.join(nodeModulesDirectory, "tesseract.js-core"), {
maxAge: "7d",
setHeaders(res, filePath) {
const ext = path.extname(filePath);
if (ext === ".wasm") res.setHeader("Content-Type", "application/wasm");
if (ext === ".js") res.setHeader("Content-Type", "application/javascript; charset=utf-8");
},
})
);
// Traineddata (langues) auto-hébergés
app.use(
"/vendor/tessdata",
express.static(path.join(vendorDirectory, "tessdata"), {
maxAge: "30d",
setHeaders(res, filePath) {
const ext = path.extname(filePath);
if (ext === ".gz") res.setHeader("Content-Type", "application/gzip");
if (ext === ".traineddata") res.setHeader("Content-Type", "application/octet-stream");
},
})
);
// Fallback SPA/HTML unique
// Utiliser une regex pour compatibilité avec path-to-regexp (Express 5)
app.get(/.*/, (_req, res) => {
res.sendFile(path.join(staticRootDirectory, "index.html"));
});
app.listen(port, () => {
// eslint-disable-next-line no-console
console.log(`Site statique servi sur http://localhost:${port}`);
});