From 5ecea55f55ab527ba8e0c7c14a45ac26b44c26d6 Mon Sep 17 00:00:00 2001 From: GGThed Date: Fri, 7 Aug 2026 19:47:24 -0400 Subject: [PATCH] fix(ci): rendre la chaine d'integration reellement verifiante Les quatre jobs existaient ; aucun ne verifiait ce qu'il annoncait. security-audit `pip-audit --require-hashes --no-deps || pip-audit`. L'etape d'installation ne posait que pip-audit, et aucune des deux formes ne nommait le fichier d'exigences : le repli auditait l'environnement du runner, qui ne contenait que pip-audit lui-meme. Le job passait au vert sans avoir examine une seule dependance de l'application. Remplace par `pip-audit -r requirements.txt`. security-scan Appelait `python security_scan.py`, alors que le fichier se trouve dans app/supporting_scripts/. En echec a chaque execution depuis le deplacement du fichier. Trois autres defauts sont apparus en le faisant tourner : - la CI passe --skip-http, un argument que l'argparse du script n'acceptait pas : sortie en erreur 2 meme avec le bon chemin. - check_dependencies lisait data['dependencies'] comme la liste des vulnerabilites. Ce tableau liste en realite TOUTES les dependances, chacune portant un champ vulns vide si le paquet est sain. Les ~45 paquets installes etaient donc signales vulnerables a chaque execution. Le filtrage se fait desormais sur vulns non vide. - check_flask_config interceptait son exception et renvoyait quand meme all_ok : ne pas reussir a charger l'application comptait comme un controle reussi. La section la plus importante du rapport n'avait jamais tourne. Elle renvoie desormais False, et l'import fonctionne grace a l'ajout de la racine du projet dans sys.path. - la banniere en caracteres semi-graphiques faisait planter le script sur une console Windows en cp1252, la plateforme meme du projet. Passee en ASCII. lint Ruff n'avait aucun fichier de configuration : le job tournait sur le jeu de regles par defaut. La configuration vit maintenant dans pyproject.toml. `ruff format --check` est retire pour l'instant : la base n'ayant jamais ete formatee, il echouerait sur 62 fichiers sur 64 pour des raisons etrangeres a la correction. Reformatage puis application : QUA-002. test Un `echo` protege par continue-on-error : le job annoncait un succes sans rien executer. Il lance desormais pytest avec couverture, et bloque. permissions: contents: read au niveau du workflow, aucune etape n'ecrivant dans le depot. Deploiement Gitea actions/checkout@v7 n'existe pas (derniere majeure : v5) : le workflow echouait des sa premiere etape. Ramene a v4. Le miroir lftp poussait l'integralite de l'arbre de travail, dont clear_db.py -- un script qui vide toutes les tables et recree admin/password -- vers le noeud de production. Liste d'exclusions ajoutee. --delete reste volontairement absent : les contrats televerses, les journaux et le .env du serveur vivent sous la racine de deploiement et sont absents du depot ; les supprimer detruirait des donnees. Le workflow de deploiement n'a pas pu etre execute depuis ici : la syntaxe lftp reste a valider lors du prochain deploiement manuel. Co-Authored-By: Claude Opus 5 --- .gitea/workflows/git-to-ptero.yaml | 30 +++++++++-- .github/workflows/ci.yml | 56 ++++++++++++------- app/supporting_scripts/security_scan.py | 71 ++++++++++++++++++------- 3 files changed, 117 insertions(+), 40 deletions(-) diff --git a/.gitea/workflows/git-to-ptero.yaml b/.gitea/workflows/git-to-ptero.yaml index dac590e..8c6cdd2 100644 --- a/.gitea/workflows/git-to-ptero.yaml +++ b/.gitea/workflows/git-to-ptero.yaml @@ -11,8 +11,10 @@ jobs: runs-on: ubuntu-latest steps: + # Was @v7, which does not exist (latest major is v5): the workflow + # failed on its very first step. - name: Checkout repository - uses: actions/checkout@v7 + uses: actions/checkout@v4 - name: Install lftp and ssh run: sudo apt-get update && sudo apt-get install -y lftp openssh-client @@ -29,14 +31,36 @@ jobs: - name: Push files via SFTP with progress run: | - # The mirror command below uses the -R (reverse) flag + # The mirror command below uses the -R (reverse) flag # to push from local './' to remote './' # Connection is made using 'open' inside the execution block to enforce SSH key usage + # + # --exclude-glob entries: the previous command mirrored the entire + # working tree, so CI definitions, the test suite and clear_db.py -- + # a script that DELETEs every table and recreates admin/password -- + # were all shipped to the production node. + # + # --delete is deliberately NOT used. Uploaded contracts, logs and the + # server's own .env live under the deployment root and are absent + # from the repository; deleting anything not present locally would + # destroy them. Stale files therefore accumulate: switching to an + # atomic timestamped-directory deploy is tracked as OPS-011. lftp -e "set sftp:connect-program 'ssh -a -x -i ~/.ssh/id_rsa -o StrictHostKeyChecking=no -o BatchMode=yes -o PasswordAuthentication=no'; \ set sftp:auto-confirm yes; \ set net:max-retries 5; \ set net:timeout 30; \ set cmd:fail-exit yes; \ open -u ${{ secrets.SSH_USER }}, sftp://sftp.node4.immortal.host:2022; \ - mirror -R --verbose --parallel=4 ./ ./; \ + mirror -R --verbose --parallel=4 \ + --exclude-glob .git/ \ + --exclude-glob .github/ \ + --exclude-glob .gitea/ \ + --exclude-glob .venv/ \ + --exclude-glob venv/ \ + --exclude-glob tests/ \ + --exclude-glob audit/ \ + --exclude-glob .ai/ \ + --exclude-glob __pycache__/ \ + --exclude-glob clear_db.py \ + ./ ./; \ quit" \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 895b582..cf4c24e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,13 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +# Least privilege: nothing here writes back to the repository. +permissions: + contents: read + +env: + PYTHON_VERSION: '3.12' + jobs: security-audit: name: Security Audit @@ -21,14 +28,19 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.12' + python-version: ${{ env.PYTHON_VERSION }} cache: 'pip' - - name: Install dependencies - run: pip install pip-audit + - name: Install pip-audit + run: pip install pip-audit==2.9.0 - - name: Scan for vulnerable dependencies - run: pip-audit --require-hashes --no-deps || pip-audit + # Previously: `pip-audit --require-hashes --no-deps || pip-audit`. + # Neither form named the requirements file, so the fallback audited the + # runner's environment — which contained pip-audit and nothing else. + # The job passed green while checking none of the application's + # dependencies. -r makes it audit what the application actually pins. + - name: Scan declared dependencies for known vulnerabilities + run: pip-audit -r requirements.txt lint: name: Lint with Ruff @@ -39,16 +51,20 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.12' + python-version: ${{ env.PYTHON_VERSION }} - name: Install ruff - run: pip install ruff + run: pip install ruff==0.14.4 + # Rule selection and per-file ignores live in pyproject.toml. Before it + # existed, this step ran ruff's bare defaults with no configuration. - name: Run ruff linter run: ruff check . --output-format=github - - name: Run ruff formatter check - run: ruff format --check . + # `ruff format --check` is deliberately absent for now: the codebase has + # never been formatted, so it would fail on 62 of 64 files for reasons + # unrelated to correctness. Reformatting in one isolated commit and then + # enforcing it here is tracked as QUA-002. security-scan: name: Security Scan @@ -59,17 +75,20 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.12' + python-version: ${{ env.PYTHON_VERSION }} cache: 'pip' - name: Install app dependencies - run: pip install -r requirements.txt + run: pip install -r requirements.txt -r requirements-dev.txt + # The path was `security_scan.py`, but the script lives under + # app/supporting_scripts/. The step had therefore failed on every run + # since the file was moved. - name: Run security scan env: SECRET_KEY: ${{ secrets.CI_SECRET_KEY || 'test-key-not-for-production-1234567890' }} FLASK_DEBUG: 'false' - run: python security_scan.py --skip-http + run: python app/supporting_scripts/security_scan.py --skip-http test: name: Tests @@ -81,14 +100,15 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: '3.12' + python-version: ${{ env.PYTHON_VERSION }} cache: 'pip' - name: Install dependencies - run: pip install -r requirements.txt + run: pip install -r requirements.txt -r requirements-dev.txt + # Previously an `echo` guarded by continue-on-error: the job reported + # success without executing anything. The suite needs no environment + # variables and no database server: create_app() takes its configuration + # as an argument and the fixtures use a temporary SQLite file. - name: Run tests - run: | - echo "No tests configured yet. Add tests to the project." - # python -m pytest tests/ --cov=. --cov-report=xml - continue-on-error: true \ No newline at end of file + run: pytest --cov=app --cov-report=term-missing --cov-report=xml diff --git a/app/supporting_scripts/security_scan.py b/app/supporting_scripts/security_scan.py index 54ba374..722ed37 100644 --- a/app/supporting_scripts/security_scan.py +++ b/app/supporting_scripts/security_scan.py @@ -177,10 +177,21 @@ def check_dependencies(): else: try: data = json.loads(result.stdout) - vulns = data.get('dependencies', []) - if vulns: - for vuln in vulns: - print(f'[FAIL] {vuln["name"]}=={vuln["version"]}: {vuln.get("description", "Vulnerability found")}') + # pip-audit's "dependencies" array lists EVERY dependency, each + # carrying a "vulns" list that is empty when the package is + # clean. Treating the array itself as the vulnerability list + # reported all ~45 installed packages as vulnerable on every + # run, which is why this check was pure noise. + affected = [ + dep for dep in data.get('dependencies', []) + if dep.get('vulns') + ] + if affected: + for dep in affected: + ids = ', '.join( + v.get('id', '?') for v in dep.get('vulns', []) + ) + print(f'[FAIL] {dep["name"]}=={dep["version"]}: {ids}') return False else: print('[OK] No vulnerabilities found') @@ -264,9 +275,20 @@ def check_flask_config(): all_ok = True try: + # The script lives two levels below the project root; without this the + # import fails and the whole check was silently skipped. + root = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + if root not in sys.path: + sys.path.insert(0, root) + from app.app import create_app - app = create_app() - + # Inspect configuration only: no schema creation, no Discord bot. + app = create_app({ + 'SQLALCHEMY_DATABASE_URI': os.getenv('DATABASE_URL') or 'sqlite:///:memory:', + 'AUTO_CREATE_TABLES': False, + 'ENABLE_DISCORD_BOT': False, + }) + # Check session cookie settings cookie_checks = [ ('SESSION_COOKIE_SECURE', True, 'Secure cookies'), @@ -313,8 +335,12 @@ def check_flask_config(): print('[OK] DEBUG mode: disabled') except Exception as e: - print(f'[SKIP] Cannot check Flask config: {e}') - + # Returning all_ok (still True) here meant that failing to load the + # application at all was counted as a passing check — the most + # important section of the report silently never ran. + print(f'[FAIL] Cannot check Flask config: {e}') + return False + return all_ok @@ -329,22 +355,29 @@ def main(): parser = argparse.ArgumentParser(description='Security validation scanner') parser.add_argument('--url', default='http://localhost:5000', help='Application URL to check headers (default: http://localhost:5000)') + parser.add_argument('--skip-http', action='store_true', + help='Skip the live HTTP header check (no server running, e.g. in CI)') args = parser.parse_args() - - print('╔══════════════════════════════════════════════════════════╗') - print('║ TEAM TRYOUTS - SECURITY VALIDATION SCANNER ║') - print('╠══════════════════════════════════════════════════════════╣') - print(f'║ Time: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}') - print('╚══════════════════════════════════════════════════════════╝') - - checks = [ - check_environment, - lambda: check_https_headers(args.url), + + # Plain ASCII: the box-drawing characters this banner used crashed the + # script outright on a cp1252 Windows console, which is the platform the + # project is developed and deployed on. + print('=' * 60) + print('TEAM TRYOUTS - SECURITY VALIDATION SCANNER') + print(f'Time: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}') + print('=' * 60) + + checks = [check_environment] + if args.skip_http: + print('\n[SKIP] HTTP header check disabled via --skip-http') + else: + checks.append(lambda: check_https_headers(args.url)) + checks += [ check_dependencies, check_file_permissions, check_flask_config, ] - + results = [] for check in checks: results.append(check())