chore(id_verif): init repo (docker-support-v2)
This commit is contained in:
commit
9e2d56383b
18
.cursorignore
Normal file
18
.cursorignore
Normal 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
59
.gitea/workflows/ci.yml
Normal 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
29
.gitignore
vendored
Normal 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
12
AGENTS.md
Normal 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
10
Dockerfile
Normal 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
8
docs/SECURITY_AUDIT.md
Normal 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
551
index.html
Normal 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 l’accè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 0–120).` ); }
|
||||
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
1104
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
package.json
Normal file
19
package.json
Normal 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
29
scripts/security/audit.sh
Normal 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
95
server.js
Normal 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}`);
|
||||
});
|
Loading…
x
Reference in New Issue
Block a user