From bce780312cc336e7fc5246d1e5f87b3442a016bb Mon Sep 17 00:00:00 2001 From: "Rondineli G. Saad" Date: Mon, 10 Aug 2026 16:14:30 -0300 Subject: [PATCH 1/8] Modernize Paperboy for Python 3.9 through 3.14 --- Dockerfile | 67 ++++------ README.rst | 19 +-- paperboy-dockerizado.md | 12 +- paperboy/__init__.py | 3 + paperboy/communicator.py | 248 +++++++++++++------------------------ paperboy/send_to_scielo.py | 68 +++++----- paperboy/send_to_server.py | 8 +- paperboy/utils.py | 23 ++-- pyproject.toml | 55 ++++++++ requirements.txt | 1 - setup.cfg | 6 - setup.py | 41 ------ tests/test_reports.py | 27 ++++ tests/test_utils.py | 30 +++++ 14 files changed, 283 insertions(+), 325 deletions(-) create mode 100644 pyproject.toml delete mode 100644 requirements.txt delete mode 100644 setup.cfg delete mode 100644 setup.py create mode 100644 tests/test_reports.py create mode 100644 tests/test_utils.py diff --git a/Dockerfile b/Dockerfile index 6fa680a..dc948f0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,48 +1,23 @@ -FROM centos:centos6 - -MAINTAINER Rondineli Saad - -ENV PYTHONUNBUFFERED 1 -ENV PAPERBOY_SETTINGS_FILE config.ini - -# Install yum dependencies -RUN yum -y update && \ - yum groupinstall -y development && \ - yum install -y \ - bzip2-devel \ - git \ - hostname \ - openssl \ - openssl-devel \ - sqlite-devel \ - sudo \ - tar \ - wget \ - glibc-devel.i686 \ - zlib-dev - -COPY . /app +ARG PYTHON_VERSION=3.14.6 +FROM python:${PYTHON_VERSION}-slim + +LABEL org.opencontainers.image.authors="SciELO Dev Team " + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PAPERBOY_SETTINGS_FILE=/app/config.ini + WORKDIR /app -# Install python2.7 -RUN cd /tmp && \ - wget https://www.python.org/ftp/python/2.7.8/Python-2.7.8.tgz && \ - tar xvfz Python-2.7.8.tgz && \ - cd Python-2.7.8 && \ - ./configure --prefix=/usr/local && \ - make && \ - make altinstall - -# Install setuptools + pip -RUN cd /tmp && \ - wget --no-check-certificate https://pypi.python.org/packages/source/s/setuptools/setuptools-1.4.2.tar.gz && \ - tar -xvf setuptools-1.4.2.tar.gz && \ - cd setuptools-1.4.2 && \ - python2.7 setup.py install && \ - curl https://bootstrap.pypa.io/get-pip.py | python2.7 - && \ - pip install virtualenv - -RUN pip install --upgrade pip && \ - pip --no-cache-dir install scielo-paperboy - -CMD ["python2.7"] +RUN addgroup --system paperboy && \ + adduser --system --ingroup paperboy --home /home/paperboy paperboy && \ + install -d -m 0700 -o paperboy -g paperboy /home/paperboy/.ssh + +COPY pyproject.toml README.rst LICENSE ./ +COPY paperboy ./paperboy + +RUN python -m pip install --no-cache-dir . + +USER paperboy + +CMD ["paperboy", "--help"] diff --git a/README.rst b/README.rst index 8a535ad..f71769a 100644 --- a/README.rst +++ b/README.rst @@ -12,24 +12,21 @@ PaperBoy Dockerizado? Como instalar ============= +Compatível com Python 3.9 a 3.14. A imagem Docker usa Python 3.14.6. + Linux ----- -pip install scielo-paperboy +python3 -m pip install scielo-paperboy Windows ------- -1. Instalar as seguintes dependência: - - paramiko 1.16.0 ou superior - - pycrypto 2.6.1 ou superior +1. Instalar Python 3.9 ou superior. +2. Instalar Paperboy (as dependências, incluindo Paramiko 5, serão instaladas automaticamente): -2. Instalar Paperboy - - pip install scielo-paperboy + py -m pip install scielo-paperboy Como utilizar ============= @@ -62,6 +59,10 @@ Windows set PAPERBOY_SETTINGS_FILE=config.ini +Para conexões SFTP, a chave do servidor deve estar previamente registrada no +arquivo ``~/.ssh/known_hosts`` do usuário que executa o Paperboy. Chaves de host +desconhecidas são rejeitadas para evitar ataques de interceptação. + Utilitários disponíveis * paperboy_delivery_to_server diff --git a/paperboy-dockerizado.md b/paperboy-dockerizado.md index cad5eb0..92d8440 100644 --- a/paperboy-dockerizado.md +++ b/paperboy-dockerizado.md @@ -42,11 +42,13 @@ Starting Paperboy instance -------------------------- ``` docker run -it --restart unless-stopped \ - --env PAPERBOY_SETTINGS_FILE=/app/config.ini - -v $(PWD)/config.ini:/app/config.ini - -v $(PWD)/scielo:/var/www/scielo - scieloorg/paperboy - paperboy_delivery_to_server -m + --env PAPERBOY_SETTINGS_FILE=/app/config.ini \ + -v "$(pwd)/config.ini:/app/config.ini:ro" \ + -v "$HOME/.ssh/known_hosts:/home/paperboy/.ssh/known_hosts:ro" \ + -v "$(pwd)/scielo:/var/www/scielo" \ + scieloorg/paperboy paperboy -m ``` Observation: The files only will be sent if the config.ini is setting properly. +For SFTP, mount a populated ``known_hosts`` file as shown above. Paperboy rejects +unknown server keys instead of trusting them automatically. diff --git a/paperboy/__init__.py b/paperboy/__init__.py index e69de29..1505265 100644 --- a/paperboy/__init__.py +++ b/paperboy/__init__.py @@ -0,0 +1,3 @@ +"""Paperboy data transfer utilities.""" + +__version__ = "0.13.0" diff --git a/paperboy/communicator.py b/paperboy/communicator.py index 46f3531..80c274a 100644 --- a/paperboy/communicator.py +++ b/paperboy/communicator.py @@ -1,18 +1,21 @@ -# coding: utf-8 +"""File transfer clients used by Paperboy.""" + +from __future__ import annotations + +import ftplib import logging +from pathlib import Path +from typing import Optional + import paramiko -from paramiko.client import SSHClient -from paramiko import ssh_exception -from ftplib import FTP as FTPLIB -import ftplib logger = logging.getLogger(__name__) -class Communicator(object): - - def __init__(self, host, port, user, password): +class Communicator: + """Base configuration shared by the FTP and SFTP clients.""" + def __init__(self, host: str, port: int, user: str, password: str) -> None: self.host = host self.port = port self.user = user @@ -21,183 +24,110 @@ def __init__(self, host, port, user, password): class FTP(Communicator): - ftp_client = None + """Lazily connected FTP client.""" @property - def client(self): - self.ftp_client = FTPLIB(self.host) - try: - self.ftp_client.login(user=self.user, passwd=self.password) - except ftplib.error_perm: - logger.error(u'Fail while connecting through FTP. Check your creadentials.') - else: - return self.ftp_client - - def exists_dir(self, path): - logger.info(u'Checking if directory already exists (%s)', path) + def client(self) -> ftplib.FTP: + if self._active_client is not None: + return self._active_client + client = ftplib.FTP() + try: + client.connect(self.host, self.port) + client.login(user=self.user, passwd=self.password) + except ftplib.all_errors: + client.close() + logger.exception("Failed to connect through FTP") + raise + + self._active_client = client + return client + + def exists_dir(self, path: str) -> bool: + logger.info("Checking whether directory exists (%s)", path) try: - self.client.nlst(str(path)) - logger.debug(u'Directory already exists (%s)', path) - return True + self.client.nlst(path) except ftplib.error_perm: - logger.debug(u'Directory do not exists (%s)', path) - - return False - - def mkdir(self, path): - - logger.info(u'Creating directory (%s)', path) + return False + return True + def mkdir(self, path: str) -> None: + logger.info("Creating directory (%s)", path) try: self.client.mkd(path) - logger.debug(u'Directory has being created (%s)', path) - except ftplib.error_perm as e: + except ftplib.error_perm: if not self.exists_dir(path): - logger.error( - u'Fail while creating directory (%s): %s', - path, - e.message - ) - - def chdir(self, path): - - logger.info(u'Changing to directory (%s)', path) - - try: - self.client.chdir(path) - except IOError as e: - logger.error( - u'Fail while accessing directory (%s): %s', - path, - e.strerror - ) - raise(e) - - def put(self, from_fl, to_fl, binary=True): - - logger.info( - u'Copying file from (%s) to (%s)', - from_fl, - to_fl - ) - - read_type = u'rb' - - if not binary: - read_type = u'r' - - try: - command = u'STOR %s' % to_fl - if binary: - self.client.storbinary( - command.encode('utf-8'), open(from_fl, read_type) - ) - else: - self.client.storlines( - command.encode('utf-8'), open(from_fl, read_type) - ) - - except IOError: - logger.error(u'File not found (%s)', from_fl) - - logger.debug(u'File has being copied (%s)', to_fl) + logger.exception("Failed to create directory (%s)", path) + raise + + def chdir(self, path: str) -> None: + logger.info("Changing to directory (%s)", path) + self.client.cwd(path) + + def put(self, from_file: str, to_file: str, binary: bool = True) -> None: + logger.info("Copying file from (%s) to (%s)", from_file, to_file) + source = Path(from_file) + if binary: + with source.open("rb") as stream: + self.client.storbinary(f"STOR {to_file}", stream) + else: + with source.open("rb") as stream: + self.client.storlines(f"STOR {to_file}", stream) class SFTP(Communicator): - ssh_client = None + """Lazily connected SFTP client with host-key verification enabled.""" - @property - def client(self): + def __init__(self, host: str, port: int, user: str, password: str) -> None: + super().__init__(host, port, user, password) + self.ssh_client: Optional[paramiko.SSHClient] = None - if self.ssh_client and self.ssh_client.get_transport().is_active(): - return self._active_client + @property + def client(self) -> paramiko.SFTPClient: + if self.ssh_client is not None: + transport = self.ssh_client.get_transport() + if transport is not None and transport.is_active() and self._active_client is not None: + return self._active_client self._active_client = self._client() - return self._active_client - def _client(self): - - logger.info( - u'Conecting through SSH to the server (%s:%s)', - self.host, - self.port - ) - + def _client(self) -> paramiko.SFTPClient: + logger.info("Connecting through SSH to server (%s:%s)", self.host, self.port) + client = paramiko.SSHClient() + client.load_system_host_keys() try: - self.ssh_client = SSHClient() - self.ssh_client.set_missing_host_key_policy( - paramiko.AutoAddPolicy() - ) - self.ssh_client.connect( + client.connect( self.host, + port=self.port, username=self.user, password=self.password, - compress=True + compress=True, ) - except ssh_exception.AuthenticationException: - logger.error( - u'Fail while connecting through SSH. Check your creadentials.') - return None - except ssh_exception.NoValidConnectionsError: - logger.error(u'Fail while connecting through SSH. Check your credentials or the server availability.') - return None - else: - return self.ssh_client.open_sftp() + except (paramiko.AuthenticationException, paramiko.SSHException, OSError): + client.close() + logger.exception("Failed to connect through SSH") + raise - def mkdir(self, path): - - logger.info(u'Creating directory (%s)', path) + self.ssh_client = client + return client.open_sftp() + def mkdir(self, path: str) -> None: + logger.info("Creating directory (%s)", path) try: self.client.mkdir(path) - logger.debug(u'Directory has being created (%s)', path) - except IOError as e: + except OSError: try: self.client.stat(path) - logger.warning(u'Directory already exists (%s)', path) - except IOError as e: - logger.error( - u'Fail while creating directory (%s): %s', - path, - e.strerror - ) - raise(e) - - def chdir(self, path): - - logger.info(u'Changing to directory (%s)', path) - - try: - self.client.chdir(path) - except IOError as e: - logger.error( - u'Fail while accessing directory (%s): %s', - path, - e.strerror - ) - raise(e) - - def put(self, from_fl, to_fl): - - logger.info( - u'Copying file from (%s) to (%s)', - from_fl, - to_fl - ) - - try: - self.client.put(from_fl, to_fl) - logger.debug(u'File has being copied (%s)', to_fl) - except OSError as e: - logger.error( - u'Fail while copying file (%s), file not found', - to_fl - ) - except IOError as e: - logger.error( - u'Fail while copying file (%s): %s', - to_fl, - e.strerror - ) + except OSError: + logger.exception("Failed to create directory (%s)", path) + raise + logger.debug("Directory already exists (%s)", path) + + def chdir(self, path: str) -> None: + logger.info("Changing to directory (%s)", path) + self.client.chdir(path) + + def put(self, from_file: str, to_file: str) -> None: + logger.info("Copying file from (%s) to (%s)", from_file, to_file) + self.client.put(from_file, to_file) diff --git a/paperboy/send_to_scielo.py b/paperboy/send_to_scielo.py index 6098fb0..18bd79c 100644 --- a/paperboy/send_to_scielo.py +++ b/paperboy/send_to_scielo.py @@ -1,9 +1,9 @@ -# coding: utf-8 import argparse import logging import logging.config import os import subprocess +from pathlib import Path from paperboy.utils import settings from paperboy.communicator import SFTP, FTP @@ -82,20 +82,26 @@ def make_section_catalog_report(source_dir, cisis_dir): logger.info(u'Making report static_section_catalog.txt') - command = u"""mkdir -p %s/bases/reports; %s/mx %s/bases/issue/issue btell=0 "pft=if p(v49) then (v35[1],v65[1]*0.4,s(f(val(s(v36[1]*4.3))+10000,2,0))*1.4,'|',v49^l,'|',v49^c,'|',v49^t,/) fi" lw=0 -all now > %s/bases/reports/static_section_catalog.txt""" % ( - source_dir, - cisis_dir, - source_dir, - source_dir, - ) - - logger.debug(u'Running: %s', command) - + source_path = Path(source_dir) + reports_dir = source_path / 'bases' / 'reports' + reports_dir.mkdir(parents=True, exist_ok=True) + mx_command = str(Path(cisis_dir) / 'mx') if cisis_dir else 'mx' + command = [ + mx_command, + str(source_path / 'bases' / 'issue' / 'issue'), + 'btell=0', + "pft=if p(v49) then (v35[1],v65[1]*0.4,s(f(val(s(v36[1]*4.3))+10000,2,0))*1.4,'|',v49^l,'|',v49^c,'|',v49^t,/) fi", + 'lw=0', + '-all', + 'now', + ] + logger.debug('Running: %s', command) try: - status = subprocess.Popen(command, shell=True) - status.wait() - except OSError: - logger.error(u'Error while creating report, static_section_catalog.txt was not updated') + with (reports_dir / 'static_section_catalog.txt').open('w', encoding='utf-8') as output: + subprocess.run(command, stdout=output, check=True, text=True) + except (OSError, subprocess.CalledProcessError): + logger.exception('Error while creating report, static_section_catalog.txt was not updated') + raise logger.debug(u'Report static_section_catalog.txt done') @@ -107,34 +113,24 @@ def make_static_file_report(source_dir, report): logger.info(u'Making report static_%s_files.txt', report_name) - command = u'mkdir -p %s/bases/%s; mkdir -p %s/bases/reports; cd %s/bases/%s; find . -name "*.%s*" > %s/bases/reports/static_%s_files.txt' %( - source_dir, - report, - source_dir, - source_dir, - report, - extension_name, - source_dir, - report_name + source_path = Path(source_dir) + content_dir = source_path / 'bases' / report + reports_dir = source_path / 'bases' / 'reports' + content_dir.mkdir(parents=True, exist_ok=True) + reports_dir.mkdir(parents=True, exist_ok=True) + matches = sorted( + path.relative_to(content_dir).as_posix() + for path in content_dir.rglob(f'*.{extension_name}*') + if path.is_file() ) - - logger.debug(u'Running: %s', command) - try: - status = subprocess.Popen(command, shell=True) - status.wait() - except OSError: - logger.error(u'Error while creating report, static_%s_files.txt was not updated', report_name) + output = reports_dir / f'static_{report_name}_files.txt' + output.write_text(''.join(f'./{path}\n' for path in matches), encoding='utf-8') logger.debug(u'Report static_%s_files.txt done', report_name) def remove_last_slash(path): - path = path.replace('\\', '/') - - try: - return path[:-1] if path[-1] == '/' else path - except IndexError: - return path + return path.replace('\\', '/').rstrip('/') class Delivery(object): diff --git a/paperboy/send_to_server.py b/paperboy/send_to_server.py index 8cc578b..cfa1582 100644 --- a/paperboy/send_to_server.py +++ b/paperboy/send_to_server.py @@ -1,4 +1,3 @@ -# coding: utf-8 import argparse import logging import logging.config @@ -115,12 +114,7 @@ def parse_scilista(scilista): def remove_last_slash(path): - path = path.replace('\\', '/') - - try: - return path[:-1] if path[-1] == '/' else path - except IndexError: - return path + return path.replace('\\', '/').rstrip('/') class Delivery(object): diff --git a/paperboy/utils.py b/paperboy/utils.py index 99efd86..8472d60 100644 --- a/paperboy/utils.py +++ b/paperboy/utils.py @@ -1,17 +1,12 @@ -#coding: utf-8 import os -import weakref import logging - -try: - from configparser import ConfigParser -except ImportError: - from ConfigParser import ConfigParser +import weakref +from configparser import ConfigParser logger = logging.getLogger(__name__) -class SingletonMixin(object): +class SingletonMixin: """ Adds a singleton behaviour to an existing class. @@ -44,10 +39,7 @@ class Configuration(SingletonMixin): def __init__(self, fp, parser_dep=ConfigParser): self.conf = parser_dep() - try: - self.conf.read_file(fp) - except AttributeError: - self.conf.readfp(fp) + self.conf.read_file(fp) @classmethod def from_env(cls): @@ -68,12 +60,13 @@ def from_file(cls, filepath): """ try: - fp = open(filepath, 'r') - except IOError: + fp = open(filepath, encoding='utf-8') + except OSError: logger.warning('file defined on PAPERBOY_SETTINGS_FILE environment variable not found (%s), no presets available', filepath) return {} - return cls(fp) + with fp: + return cls(fp) def __getattr__(self, attr): return getattr(self.conf, attr) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2690e81 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,55 @@ +[build-system] +requires = ["setuptools>=80"] +build-backend = "setuptools.build_meta" + +[project] +name = "scielo-paperboy" +version = "0.13.0" +description = "Send SciELO images, PDFs, translations, XML, and databases to processing servers" +readme = "README.rst" +requires-python = ">=3.9" +license = "BSD-3-Clause" +authors = [ + { name = "SciELO Dev Team", email = "scielo-dev@googlegroups.com" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +] +dependencies = [ + "paramiko>=5.0,<6", +] + +[project.urls] +Homepage = "https://github.com/scieloorg/paperboy" +Repository = "https://github.com/scieloorg/paperboy" + +[project.scripts] +paperboy = "paperboy.send_to_server:main" +paperboy_delivery_to_server = "paperboy.send_to_server:main" +paperboy_delivery_to_scielo = "paperboy.send_to_scielo:main" + +[project.optional-dependencies] +test = ["pytest>=8.4,<9"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "--strict-config --strict-markers" + +[tool.ruff] +target-version = "py39" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM", "S"] + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["S101"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 8168ee5..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -paramiko==1.16.0 diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 06c3481..0000000 --- a/setup.cfg +++ /dev/null @@ -1,6 +0,0 @@ -[nosetests] -match = ^test -nocapture = 1 -cover-package = accesses -with-coverage = 1 -cover-erase = 0 \ No newline at end of file diff --git a/setup.py b/setup.py deleted file mode 100644 index 0bf1cd4..0000000 --- a/setup.py +++ /dev/null @@ -1,41 +0,0 @@ -#coding: utf-8 -#!/usr/bin/env python -from setuptools import setup, find_packages - -install_requires = [ - "paramiko>=1.16.0" -] - -tests_require = [ - "paramiko>=1.16.0" -] - -setup( - name="scielo_paperboy", - version="0.12.7", - description=u"Utilitary to send Images. PDF's, Translations and XML's from the local website to stanging and production servers", - author="SciELO Dev Team", - author_email="scielo-dev@googlegroups.com", - license="BSD License", - url="http://github.com/scieloorg/paperboy", - packages=find_packages(), - include_package_data=True, - classifiers=[ - "Development Status :: 4 - Beta", - "Intended Audience :: Developers", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 2.7", - ], - dependency_links=[ - ], - tests_require=tests_require, - test_suite='tests', - install_requires=install_requires, - entry_points={ - 'console_scripts': [ - 'paperboy=paperboy.send_to_server:main', - 'paperboy_delivery_to_server=paperboy.send_to_server:main', - 'paperboy_delivery_to_scielo=paperboy.send_to_scielo:main' - ] - } -) diff --git a/tests/test_reports.py b/tests/test_reports.py new file mode 100644 index 0000000..0b75db0 --- /dev/null +++ b/tests/test_reports.py @@ -0,0 +1,27 @@ +from pathlib import Path +from unittest.mock import patch + +from paperboy.send_to_scielo import make_section_catalog_report, make_static_file_report + + +def test_make_static_file_report_is_sorted_and_does_not_use_a_shell(tmp_path): + pdf_dir = tmp_path / "bases" / "pdf" / "journal" + pdf_dir.mkdir(parents=True) + (pdf_dir / "z.pdf").touch() + (pdf_dir / "a.pdf").touch() + (pdf_dir / "ignored.xml").touch() + + make_static_file_report(tmp_path, "pdf") + + report = tmp_path / "bases" / "reports" / "static_pdf_files.txt" + assert report.read_text(encoding="utf-8") == "./journal/a.pdf\n./journal/z.pdf\n" + + +def test_make_section_catalog_report_passes_an_argument_list(tmp_path): + with patch("paperboy.send_to_scielo.subprocess.run") as run: + make_section_catalog_report(tmp_path, "/opt/cisis") + + command = run.call_args.args[0] + assert command[0] == "/opt/cisis/mx" + assert command[1] == str(Path(tmp_path) / "bases" / "issue" / "issue") + assert run.call_args.kwargs["check"] is True diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..6c6794e --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,30 @@ +from io import StringIO + +from paperboy.send_to_scielo import remove_last_slash as scielo_remove_last_slash +from paperboy.send_to_server import parse_scilista, remove_last_slash +from paperboy.utils import Configuration + + +def test_remove_last_slash_normalizes_separators(): + assert remove_last_slash(r"C:\scielo\\") == "C:/scielo" + assert scielo_remove_last_slash("/var/www/scielo///") == "/var/www/scielo" + assert remove_last_slash("") == "" + + +def test_parse_scilista_ignores_invalid_rows(tmp_path): + scilista = tmp_path / "scilista.lst" + scilista.write_text("rsp v1n1\nrsp v1n2 del\ninvalid\na b unknown\n", encoding="utf-8") + + assert parse_scilista(scilista) == [ + ("rsp", "v1n1", False), + ("rsp", "v1n2", True), + ("a", "b", False), + ] + + +def test_configuration_reads_sections(): + config = Configuration(StringIO("[app:main]\nserver=example.org\nport=22\n")) + + assert dict(config.items()) == { + "app:main": {"server": "example.org", "port": "22"}, + } From edbb4fb8fbad068ed893d64f3d038ff64f6af885 Mon Sep 17 00:00:00 2001 From: "Rondineli G. Saad" Date: Mon, 10 Aug 2026 17:25:06 -0300 Subject: [PATCH 2/8] Replace insecure FTP with verified FTPS --- README.rst | 1 + config.ini-TEMPLATE | 12 ++++++----- paperboy-dockerizado.md | 13 +++++------ paperboy/communicator.py | 16 ++++++++------ paperboy/send_to_scielo.py | 18 ++++++++-------- paperboy/send_to_server.py | 18 ++++++++-------- tests/test_communicator.py | 44 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 87 insertions(+), 35 deletions(-) create mode 100644 tests/test_communicator.py diff --git a/README.rst b/README.rst index f71769a..61f4e24 100644 --- a/README.rst +++ b/README.rst @@ -45,6 +45,7 @@ config.ini:: scilista=/var/www/scielo/serial/scilista.lst destiny_dir=/var/www/scielo server=localhost + server_type=ftps port=21 user=anonymous password=anonymous diff --git a/config.ini-TEMPLATE b/config.ini-TEMPLATE index 5187708..6a47b93 100644 --- a/config.ini-TEMPLATE +++ b/config.ini-TEMPLATE @@ -12,16 +12,18 @@ source_dir=/var/www/scielo cisis_dir=/var/www/scielo/proc/cisis ## Full path to the scilista.lst file. It is usually available at the directory -## and file serial/scilista.lst +## and file serial/scilista.lst. Journal and issue fields accept only ASCII letters, +## numbers, dots, underscores and hyphens (maximum 128 characters each). #scilista=/var/www/scielo/serial/scilista.lst ## Full path to the destiny folder in the server side. It is usually the path -## to the SciELO Site in the server. When sending data to SciELO is must be -## commented or empty on the FTP will login the user to the correct path. +## to the SciELO Site in the server. It may be commented or empty when the +## remote account starts in the correct path. #destiny_dir= -## FTP or SFTP credentials -## The protocol will be defined by the server_type ['ftp', 'sftp'] +## FTPS or SFTP credentials. Plain FTP is not supported. +## The protocol will be defined by the server_type ['ftps', 'sftp']. +## FTPS uses explicit TLS, validates the server certificate and protects data transfers. server= server_type=sftp port=22 diff --git a/paperboy-dockerizado.md b/paperboy-dockerizado.md index 92d8440..c87011c 100644 --- a/paperboy-dockerizado.md +++ b/paperboy-dockerizado.md @@ -24,19 +24,20 @@ cisis_dir=/var/www/scielo/proc/cisis scilista=/var/www/scielo/serial/scilista.lst ## Full path to the destiny folder in the server side. It is usually the path -## to the SciELO Site in the server. When sending data to SciELO is must be -## commented or empty on the FTP will login the user to the correct path. +## to the SciELO Site in the server. It may be commented or empty when the +## remote account starts in the correct path. destiny_dir=/var/www/scielo -## FTP or SFTP credentials -## The protocol will be defined by the server_type ['ftp', 'sftp'] +## FTPS or SFTP credentials. Plain FTP is not supported. +## The protocol will be defined by the server_type ['ftps', 'sftp'] server= -server_type= +server_type= port= user= password= ``` -Please fill up with right server credentials. If your server doesn't have ftp use sftp (ssh way). +Please fill in the correct server credentials. Use SFTP when available; FTPS uses +explicit TLS with certificate verification and a protected data channel. Starting Paperboy instance -------------------------- diff --git a/paperboy/communicator.py b/paperboy/communicator.py index 80c274a..c475225 100644 --- a/paperboy/communicator.py +++ b/paperboy/communicator.py @@ -4,6 +4,7 @@ import ftplib import logging +import ssl from pathlib import Path from typing import Optional @@ -13,7 +14,7 @@ class Communicator: - """Base configuration shared by the FTP and SFTP clients.""" + """Base configuration shared by the FTPS and SFTP clients.""" def __init__(self, host: str, port: int, user: str, password: str) -> None: self.host = host @@ -23,21 +24,24 @@ def __init__(self, host: str, port: int, user: str, password: str) -> None: self._active_client = None -class FTP(Communicator): - """Lazily connected FTP client.""" +class FTPS(Communicator): + """Lazily connected explicit FTPS client with certificate verification.""" @property - def client(self) -> ftplib.FTP: + def client(self) -> ftplib.FTP_TLS: if self._active_client is not None: return self._active_client - client = ftplib.FTP() + context = ssl.create_default_context() + context.minimum_version = ssl.TLSVersion.TLSv1_2 + client = ftplib.FTP_TLS(context=context) try: client.connect(self.host, self.port) client.login(user=self.user, passwd=self.password) + client.prot_p() except ftplib.all_errors: client.close() - logger.exception("Failed to connect through FTP") + logger.exception("Failed to connect through FTPS") raise self._active_client = client diff --git a/paperboy/send_to_scielo.py b/paperboy/send_to_scielo.py index 18bd79c..860b620 100644 --- a/paperboy/send_to_scielo.py +++ b/paperboy/send_to_scielo.py @@ -6,7 +6,7 @@ from pathlib import Path from paperboy.utils import settings -from paperboy.communicator import SFTP, FTP +from paperboy.communicator import FTPS, SFTP logger = logging.getLogger(__name__) @@ -146,10 +146,10 @@ def __init__(self, source_type, cisis_dir, source_dir, destiny_dir, server, if str(server_type) == 'sftp': self.client = SFTP(server, int(port), user, password) - elif str(server_type) == 'ftp': - self.client = FTP(server, int(port), user, password) + elif str(server_type) == 'ftps': + self.client = FTPS(server, int(port), user, password) else: - raise TypeError(u'server_type must be ftp or sftp') + raise TypeError(u'server_type must be ftps or sftp') def _local_remove(self, path): @@ -376,35 +376,35 @@ def main(): u'--server', u'-f', default=setts.get(u'server', u'localhost'), - help=u'FTP or SFTP Server' + help=u'FTPS or SFTP server' ) parser.add_argument( u'--server_type', u'-e', default=setts.get(u'server_type', u'sftp'), - choices=['ftp', 'sftp'] + choices=['ftps', 'sftp'] ) parser.add_argument( u'--port', u'-x', default=setts.get(u'port', u'22'), - help=u'usually 22 for SFTP connection or 21 for FTP connection' + help=u'usually 22 for SFTP or 21 for explicit FTPS' ) parser.add_argument( u'--user', u'-u', default=setts.get(u'user', u'anonymous'), - help=u'FTP or SFTP username' + help=u'FTPS or SFTP username' ) parser.add_argument( u'--password', u'-p', default=setts.get(u'password', u'anonymous'), - help=u'FTP or SFTP password' + help=u'FTPS or SFTP password' ) parser.add_argument( diff --git a/paperboy/send_to_server.py b/paperboy/send_to_server.py index cfa1582..b1a13ef 100644 --- a/paperboy/send_to_server.py +++ b/paperboy/send_to_server.py @@ -5,7 +5,7 @@ import subprocess from paperboy.utils import settings -from paperboy.communicator import SFTP, FTP +from paperboy.communicator import FTPS, SFTP logger = logging.getLogger(__name__) @@ -132,10 +132,10 @@ def __init__(self, source_type, cisis_dir, scilista, source_dir, destiny_dir, if str(server_type) == 'sftp': self.client = SFTP(server, int(port), user, password) - elif str(server_type) == 'ftp': - self.client = FTP(server, int(port), user, password) + elif str(server_type) == 'ftps': + self.client = FTPS(server, int(port), user, password) else: - raise TypeError(u'server_type must be ftp or sftp') + raise TypeError(u'server_type must be ftps or sftp') def _local_remove(self, path): @@ -436,35 +436,35 @@ def main(): u'--server', u'-f', default=setts.get(u'server', u'localhost'), - help=u'FTP or SFTP' + help=u'FTPS or SFTP server' ) parser.add_argument( u'--server_type', u'-e', default=setts.get(u'server_type', u'sftp'), - choices=['ftp', 'sftp'] + choices=['ftps', 'sftp'] ) parser.add_argument( u'--port', u'-x', default=setts.get(u'port', u'22'), - help=u'usually 22 for SFTP connection or 21 for FTP connection' + help=u'usually 22 for SFTP or 21 for explicit FTPS' ) parser.add_argument( u'--user', u'-u', default=setts.get(u'user', u'anonymous'), - help=u'FTP or SFTP username' + help=u'FTPS or SFTP username' ) parser.add_argument( u'--password', u'-p', default=setts.get(u'password', u'anonymous'), - help=u'FTP or SFTP password' + help=u'FTPS or SFTP password' ) parser.add_argument( diff --git a/tests/test_communicator.py b/tests/test_communicator.py new file mode 100644 index 0000000..019d858 --- /dev/null +++ b/tests/test_communicator.py @@ -0,0 +1,44 @@ +import ssl +from unittest.mock import Mock, patch + +import pytest + +from paperboy.communicator import FTPS +from paperboy.send_to_server import Delivery + + +def test_ftps_requires_tls12_and_protects_data_channel(): + client = Mock() + + with patch("paperboy.communicator.ftplib.FTP_TLS", return_value=client) as factory: + communicator = FTPS("files.example.org", 21, "paperboy", "test-password") + + assert communicator.client is client + + context = factory.call_args.kwargs["context"] + assert context.minimum_version == ssl.TLSVersion.TLSv1_2 + assert context.verify_mode == ssl.CERT_REQUIRED + assert context.check_hostname is True + client.connect.assert_called_once_with("files.example.org", 21) + client.login.assert_called_once_with(user="paperboy", passwd="test-password") + client.prot_p.assert_called_once_with() + + +def test_plain_ftp_is_rejected(tmp_path): + scilista = tmp_path / "scilista.lst" + scilista.write_text("", encoding="utf-8") + + with pytest.raises(TypeError, match="ftps or sftp"): + Delivery( + None, + "", + str(scilista), + str(tmp_path), + "/remote", + False, + "files.example.org", + "ftp", + 21, + "paperboy", + "test-password", + ) From d6b83977adc7cc2463b3b3c9b36021a49a896a5f Mon Sep 17 00:00:00 2001 From: "Rondineli G. Saad" Date: Mon, 10 Aug 2026 17:25:25 -0300 Subject: [PATCH 3/8] Validate scilista entries and contain source paths --- README.rst | 13 ++++++ paperboy/send_to_server.py | 94 +++++++++++++++++++++++++++++--------- tests/test_utils.py | 71 ++++++++++++++++++++++++++-- 3 files changed, 153 insertions(+), 25 deletions(-) diff --git a/README.rst b/README.rst index 61f4e24..3528918 100644 --- a/README.rst +++ b/README.rst @@ -64,6 +64,19 @@ Para conexões SFTP, a chave do servidor deve estar previamente registrada no arquivo ``~/.ssh/known_hosts`` do usuário que executa o Paperboy. Chaves de host desconhecidas são rejeitadas para evitar ataques de interceptação. +Os protocolos aceitos são ``sftp`` e ``ftps``. FTP sem criptografia foi removido. +O modo ``ftps`` usa TLS explícito, exige TLS 1.2 ou superior, valida o certificado +do servidor e protege também o canal de dados. + +Segurança do scilista +--------------------- + +Os campos de periódico e fascículo do ``scilista.lst`` aceitam somente letras +ASCII, números, pontos, sublinhados e hífens, com no máximo 128 caracteres. +Caminhos absolutos, separadores e componentes ``..`` são rejeitados. Os caminhos +locais resolvidos, inclusive links simbólicos, devem permanecer dentro de +``source_dir`` ou ``serial_source_dir``. + Utilitários disponíveis * paperboy_delivery_to_server diff --git a/paperboy/send_to_server.py b/paperboy/send_to_server.py index b1a13ef..6c3be8a 100644 --- a/paperboy/send_to_server.py +++ b/paperboy/send_to_server.py @@ -2,7 +2,9 @@ import logging import logging.config import os +import re import subprocess +from pathlib import Path, PurePosixPath from paperboy.utils import settings from paperboy.communicator import FTPS, SFTP @@ -10,6 +12,7 @@ logger = logging.getLogger(__name__) ALLOWED_ITENS = ['serial', 'pdfs', 'images', 'translations'] +SAFE_SCILISTA_COMPONENT = re.compile(r'^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$') LOGGING = { 'version': 1, @@ -76,8 +79,8 @@ def parse_scilista(scilista): lista = [] try: - f = open(scilista, 'r') - except IOError: + f = open(scilista, encoding='utf-8') + except OSError: logger.error( u'Fail while loading scilista, file not found (%s)', scilista @@ -88,7 +91,10 @@ def parse_scilista(scilista): for line in f: line = line.strip() count += 1 - splited_line = [i.strip() for i in line.split(' ')] + if not line: + continue + + splited_line = line.split() if len(splited_line) > 3 or len(splited_line) < 2: logger.warning( @@ -99,14 +105,27 @@ def parse_scilista(scilista): ) continue - if len(splited_line) == 3: # issue to remove - if splited_line[2].lower() == 'del': - lista.append((splited_line[0], splited_line[1], True)) - else: - lista.append((splited_line[0], splited_line[1], False)) + journal_acronym, issue_label = splited_line[:2] + if not all( + SAFE_SCILISTA_COMPONENT.fullmatch(component) + for component in (journal_acronym, issue_label) + ): + logger.warning( + u'Unsafe value in the file (%s) line (%d)', + scilista, + count, + ) + continue + + if len(splited_line) == 3 and splited_line[2].lower() != 'del': + logger.warning( + u'Wrong action in the file (%s) line (%d)', + scilista, + count, + ) + continue - if len(splited_line) == 2: # issue to remove - lista.append((splited_line[0], splited_line[1], False)) + lista.append((journal_acronym, issue_label, len(splited_line) == 3)) logger.info(u'scilista loaded (%s)', scilista) @@ -117,6 +136,32 @@ def remove_last_slash(path): return path.replace('\\', '/').rstrip('/') +def validate_relative_path(path): + """Return a normalized relative path without traversal components.""" + normalized = str(path).replace('\\', '/') + relative = PurePosixPath(normalized) + if ( + not normalized + or not relative.parts + or relative.is_absolute() + or any(part in ('', '.', '..') for part in relative.parts) + ): + raise ValueError('path must be relative and cannot contain traversal components') + return relative + + +def resolve_within(root, relative_path): + """Resolve a path and require it to remain inside its configured root.""" + root_path = Path(root).resolve() + relative = validate_relative_path(relative_path) + candidate = root_path.joinpath(*relative.parts).resolve() + try: + candidate.relative_to(root_path) + except ValueError as exc: + raise ValueError('resolved path escapes the configured source directory') from exc + return candidate + + class Delivery(object): def __init__(self, source_type, cisis_dir, scilista, source_dir, destiny_dir, @@ -152,8 +197,10 @@ def _local_remove(self, path): ) def transfer_data_general(self, base_path): - - base_path = base_path.replace(u'\\', u'/') + relative = validate_relative_path(base_path) + base_path = relative.as_posix() + source_root = Path(self.source_dir).resolve() + source_base = resolve_within(source_root, relative) # Cria a estrutura de diretorio informada em base_path dentro de destiny_dir path = u'' @@ -162,17 +209,17 @@ def transfer_data_general(self, base_path): self.client.mkdir(self.destiny_dir + path) # Cria recursivamente todo conteudo baixo o source_dir + base_path - tree = os.walk(self.source_dir + u'/' + base_path) + tree = os.walk(source_base) for item in tree: - root = item[0].replace(u'\\', u'/') - current = root.replace(self.source_dir+u'/', '') + root = Path(item[0]).resolve() + current = root.relative_to(source_root).as_posix() dirs = item[1] files = item[2] for fl in files: - from_fl = root + u'/' + fl + from_fl = resolve_within(source_root, PurePosixPath(current) / fl) to_fl = self.destiny_dir + u'/' + current + u'/' + fl - self.client.put(from_fl, to_fl) + self.client.put(str(from_fl), to_fl) for directory in dirs: self.client.mkdir(self.destiny_dir + u'/' + current + u'/' + directory) @@ -188,7 +235,10 @@ def transfer_data_databases(self, base_path): convert the files to windown compatible files. The default is false. """ - base_path = base_path.replace(u'\\', u'/') + relative = validate_relative_path(base_path) + base_path = relative.as_posix() + source_root = Path(self.serial_source_dir).resolve() + source_base = resolve_within(source_root, relative) allowed_extensions = [u'mst', u'xrf'] @@ -199,18 +249,18 @@ def transfer_data_databases(self, base_path): self.client.mkdir(self.destiny_dir + path) # Cria recursivamente todo conteudo baixo o serial_source_dir + base_path - tree = os.walk(self.serial_source_dir + u'/' + base_path) + tree = os.walk(source_base) converted = set() for item in tree: - root = item[0].replace(u'\\', u'/') - current = root.replace(self.serial_source_dir + u'/', u'') + root = Path(item[0]).resolve() + current = root.relative_to(source_root).as_posix() dirs = item[1] files = item[2] for fl in files: if not fl[-3:].lower() in allowed_extensions: continue - from_fl = root + u'/' + fl + from_fl = str(resolve_within(source_root, PurePosixPath(current) / fl)) from_fl_name = from_fl[:-4] converted_fl = from_fl_name + u'_converted' to_fl = self.destiny_dir + u'/' + current + u'/' + fl diff --git a/tests/test_utils.py b/tests/test_utils.py index 6c6794e..b1eed63 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,7 +1,16 @@ from io import StringIO +from unittest.mock import Mock + +import pytest from paperboy.send_to_scielo import remove_last_slash as scielo_remove_last_slash -from paperboy.send_to_server import parse_scilista, remove_last_slash +from paperboy.send_to_server import ( + Delivery, + parse_scilista, + remove_last_slash, + resolve_within, + validate_relative_path, +) from paperboy.utils import Configuration @@ -13,15 +22,71 @@ def test_remove_last_slash_normalizes_separators(): def test_parse_scilista_ignores_invalid_rows(tmp_path): scilista = tmp_path / "scilista.lst" - scilista.write_text("rsp v1n1\nrsp v1n2 del\ninvalid\na b unknown\n", encoding="utf-8") + scilista.write_text( + "rsp v1n1\n" + "rsp v1n2 del\n" + "invalid\n" + "a b unknown\n" + "../etc passwd\n" + "journal issue/../../secret\n", + encoding="utf-8", + ) assert parse_scilista(scilista) == [ ("rsp", "v1n1", False), ("rsp", "v1n2", True), - ("a", "b", False), ] +@pytest.mark.parametrize( + "path", + ["", ".", "..", "../secret", "bases/../../secret", "/etc/passwd", r"..\secret"], +) +def test_validate_relative_path_rejects_unsafe_paths(path): + with pytest.raises(ValueError): + validate_relative_path(path) + + +def test_resolve_within_rejects_symlink_escape(tmp_path): + source = tmp_path / "source" + source.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + (source / "escape").symlink_to(outside, target_is_directory=True) + + with pytest.raises(ValueError, match="escapes"): + resolve_within(source, "escape/private.xml") + + +def test_transfer_rejects_file_symlink_escape(tmp_path): + source = tmp_path / "source" + content = source / "bases" / "pdf" / "journal" / "issue" + content.mkdir(parents=True) + outside = tmp_path / "private.pdf" + outside.write_bytes(b"private") + (content / "article.pdf").symlink_to(outside) + scilista = tmp_path / "scilista.lst" + scilista.write_text("journal issue\n", encoding="utf-8") + + delivery = Delivery( + "pdfs", + "", + str(scilista), + str(source), + "/remote", + False, + "files.example.org", + "sftp", + 22, + "paperboy", + "test-password", + ) + delivery.client = Mock() + + with pytest.raises(ValueError, match="escapes"): + delivery.run_pdfs() + + def test_configuration_reads_sections(): config = Configuration(StringIO("[app:main]\nserver=example.org\nport=22\n")) From 32ecc860c06d7448a3727bc9e63f03b785a74766 Mon Sep 17 00:00:00 2001 From: "Rondineli G. Saad" Date: Mon, 10 Aug 2026 17:45:13 -0300 Subject: [PATCH 4/8] Add corrected cryptography standards skill --- .codex/skills/crypto-standards/SKILL.md | 270 ++++++++++++++++++ .../crypto-standards/agents/openai.yaml | 4 + .../crypto-standards/references/algorithms.md | 151 ++++++++++ 3 files changed, 425 insertions(+) create mode 100644 .codex/skills/crypto-standards/SKILL.md create mode 100644 .codex/skills/crypto-standards/agents/openai.yaml create mode 100644 .codex/skills/crypto-standards/references/algorithms.md diff --git a/.codex/skills/crypto-standards/SKILL.md b/.codex/skills/crypto-standards/SKILL.md new file mode 100644 index 0000000..6723b1a --- /dev/null +++ b/.codex/skills/crypto-standards/SKILL.md @@ -0,0 +1,270 @@ +--- +name: crypto-standards +description: > + Seleciona algoritmos criptográficos seguros e rejeita algoritmos obsoletos + conforme a NSI.04 seção 3.8 do SciELO/FapUNIFESP. Use esta skill SEMPRE que + o usuário precisar implementar ou revisar: hash de senha, criptografia de dados, + assinatura digital, geração de tokens, armazenamento seguro, TLS/SSL, troca de + chaves, geração de chaves, certificados. Também acione quando mencionar: bcrypt, + argon2, AES, RSA, SHA, MD5, "como criptografar", "como fazer hash de senha", + "algoritmo seguro", "qual criptografia usar", "chave simétrica", "chave assimétrica", + "criptografia obsoleta", "migrar de MD5", "trocar SHA1", "implementar JWT". + A skill bloqueia ativamente MD5, SHA1, DES, 3DES, RC4, RC2 e modo ECB. +--- + +# Crypto Standards + +Você é um especialista em criptografia aplicada seguindo a **NSI.04 seção 3.8** do +SciELO/FapUNIFESP. Sua função é garantir que o código use apenas algoritmos aprovados +e rejeitar — com correção imediata — qualquer uso de algoritmos proibidos. + +--- + +## Tabela de decisão rápida + +| Caso de uso | ✅ Aprovado | ❌ Proibido | +|------------|------------|------------| +| Hash de senha | Argon2id, bcrypt, scrypt | MD5, SHA1, SHA256 sem salt, plaintext | +| Hash de integridade (não senha) | SHA-256, SHA-3, BLAKE2 | MD5, SHA1, MD4 | +| Criptografia simétrica | AES-256-GCM, AES-256-CBC+HMAC | DES, 3DES, RC4, RC2, AES-ECB | +| Criptografia assimétrica | RSA-4096, Ed25519, X25519 | RSA < 2048 bits | +| Assinatura digital | RSA-PSS-4096, ECDSA-P256, Ed25519 | MD5withRSA, SHA1withRSA | +| TLS / HTTPS | TLS 1.2 (mínimo), TLS 1.3 | SSL 2/3, TLS 1.0, TLS 1.1 | +| Geração de tokens/IDs aleatórios | `secrets` (Python), `crypto.randomBytes` (Node) | `random`, `Math.random()` | +| JWT assimétrico | PS256, EdDSA (Ed25519), ES256 (ECDSA P-256) | `none`, chave RSA < 2048 bits, algoritmo escolhido pelo header do token | +| JWT simétrico | HS256 com chave CSPRNG ≥ 256 bits e allowlist fixa | Chave fraca, segredo compartilhado entre ambientes, `none` | +| Armazenamento de chaves | Vault, KMS, HSM, variável de ambiente | Hardcoded, arquivo não protegido | + +--- + +## Passo 1 — Identificar o caso de uso + +Pergunte ao usuário (ou deduza do contexto) qual é o objetivo: + +1. **Senha de usuário** → sempre Argon2id ou bcrypt +2. **Dado sensível em repouso** → AES-256-GCM +3. **Dado sensível em trânsito** → TLS 1.3 + certificado válido +4. **Token de sessão / API key** → gerador seguro (`secrets`) + armazenamento com hash +5. **Assinatura de documento / JWT** → PS256 (RSA-PSS), EdDSA (Ed25519) ou ES256 (ECDSA P-256) +6. **Verificar integridade de arquivo** → SHA-256 ou BLAKE2 + +--- + +## Passo 2 — Gerar implementação aprovada + +### Hash de senha + +```python +# Python — Argon2id (recomendado pela NSI.04 3.8) +from argon2 import PasswordHasher + +ph = PasswordHasher( + time_cost=3, # iterações + memory_cost=65536, # 64 MB + parallelism=4, + hash_len=32, + salt_len=16 +) + +# Criar hash +hashed = ph.hash(password) + +# Verificar +try: + ph.verify(hashed, password_input) + if ph.check_needs_rehash(hashed): + hashed = ph.hash(password_input) # atualizar se parâmetros mudaram +except Exception: + raise ValueError("Senha inválida") +``` + +```python +# Python — bcrypt (alternativa aprovada) +import bcrypt + +# Criar hash (salt gerado automaticamente) +hashed = bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt(rounds=12)) + +# Verificar +bcrypt.checkpw(password_input.encode("utf-8"), hashed) +``` + +```javascript +// Node.js — bcrypt +const bcrypt = require("bcrypt"); + +const hashed = await bcrypt.hash(password, 12); +const valid = await bcrypt.compare(passwordInput, hashed); +``` + +### Criptografia simétrica (AES-256-GCM) + +```python +# Python — AES-256-GCM com tag de autenticação +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +import os + +key = os.urandom(32) # 256 bits — armazenar no vault +nonce = os.urandom(12) # único por operação + +aesgcm = AESGCM(key) +ciphertext = aesgcm.encrypt(nonce, plaintext.encode(), None) + +# Armazenar junto: nonce + ciphertext +stored = nonce + ciphertext + +# Descriptografar +nonce_stored = stored[:12] +ct = stored[12:] +plaintext = aesgcm.decrypt(nonce_stored, ct, None).decode() +``` + +```python +# PROIBIDO — AES no modo ECB (sem autenticação, padrões visíveis) +from Crypto.Cipher import AES +cipher = AES.new(key, AES.MODE_ECB) # NUNCA usar ECB +``` + +### Geração de tokens seguros + +```python +# Python — token de sessão / API key +import secrets + +# Token de 32 bytes = 256 bits de entropia (NSI.04: mínimo 128 bits) +token = secrets.token_urlsafe(32) + +# UUID v4 (aceitável para IDs não-secretos) +import uuid +uid = str(uuid.uuid4()) +``` + +```javascript +// Node.js +const crypto = require("crypto"); +const token = crypto.randomBytes(32).toString("hex"); + +// PROIBIDO +const token = Math.random().toString(36); // previsível +``` + +### JWT seguro + +```python +# Python — JWT com PS256 (RSA-PSS + SHA-256) +import jwt +from datetime import datetime, timedelta, timezone + +private_key = load_private_key_from_vault() +now = datetime.now(timezone.utc) + +token = jwt.encode( + { + "sub": str(user_id), + "iat": now, + "exp": now + timedelta(hours=8), + }, + private_key, + algorithm="PS256", +) + +public_key = load_public_key_from_trusted_store() +claims = jwt.decode( + token, + public_key, + algorithms=["PS256"], # allowlist fixa; nunca confiar em header["alg"] + options={"require": ["sub", "iat", "exp"]}, +) + +# PROIBIDO +jwt.encode(payload, "", algorithm="none") # sem assinatura +jwt.encode(payload, weak_human_password, algorithm="HS256") # chave fraca +jwt.decode(token, public_key, algorithms=[untrusted_header["alg"]]) +``` + +- Usar `PS256` para RSA-PSS; `RS256` usa RSASSA-PKCS1-v1_5 e deve ficar restrito + a integrações que exijam compatibilidade. +- Usar `ES256` somente com ECDSA P-256 e `EdDSA` com Ed25519; não tratar esses + algoritmos e tipos de chave como equivalentes. +- Fixar a allowlist de algoritmos no verificador e validar `exp`, `iat`, `iss` e + `aud` quando essas claims fizerem parte do contrato da aplicação. + +### Hash de integridade (não senha) + +```python +# Python — SHA-256 para verificar integridade de arquivo +import hashlib + +def hash_file(path: str) -> str: + h = hashlib.sha256() + with open(path, "rb") as f: + for chunk in iter(lambda: f.read(8192), b""): + h.update(chunk) + return h.hexdigest() + +# PROIBIDO +hashlib.md5(data).hexdigest() # colisões conhecidas +hashlib.sha1(data).hexdigest() # colisões conhecidas +``` + +--- + +## Passo 3 — Detectar e corrigir uso proibido + +Se o código fornecido usar algoritmo proibido, reporte no formato: + +``` +🚫 ALGORITMO PROIBIDO — [nome do algoritmo] + +Arquivo: caminho/arquivo.py Linha: N +Código atual: [trecho problemático] + +Por que é proibido (NSI.04 3.8): +[explicação do risco] + +Substituição aprovada: +[código corrigido completo] + +Migração de dados existentes: +[se houver dados já hasheados/criptografados com o algoritmo proibido, + orientar estratégia de migração] +``` + +--- + +## Migração de algoritmos legados + +### MD5/SHA1 → Argon2id (senhas) + +```python +# Estratégia: re-hash no próximo login bem-sucedido +def verify_and_migrate(user, password_input): + if user.hash_algorithm == "md5": + # verificar com MD5 legado + old_hash = hashlib.md5(password_input.encode()).hexdigest() + if not hmac.compare_digest(old_hash, user.password_hash): + raise ValueError("Senha inválida") + # migrar para Argon2id + user.password_hash = ph.hash(password_input) + user.hash_algorithm = "argon2id" + db.session.commit() + else: + ph.verify(user.password_hash, password_input) +``` + +### DES/AES-ECB → AES-256-GCM (dados em repouso) + +Migração requer: +1. Descriptografar com algoritmo antigo +2. Re-criptografar com AES-256-GCM +3. Executar em lote ou sob demanda no acesso +4. Manter chave antiga disponível apenas durante a migração +5. Destruir chave antiga após confirmação de migração completa + +--- + +## Referências + +- `references/algorithms.md` — tabela completa com tamanhos de chave e modos +- NSI.04 seção 3.8 — Proteção de Dados: Criptografia e Hash +- NIST SP 800-131A — Transitioning the Use of Cryptographic Algorithms diff --git a/.codex/skills/crypto-standards/agents/openai.yaml b/.codex/skills/crypto-standards/agents/openai.yaml new file mode 100644 index 0000000..1870884 --- /dev/null +++ b/.codex/skills/crypto-standards/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Crypto Standards" + short_description: "Choose approved cryptography and reject obsolete algorithms." + default_prompt: "Use the crypto-standards skill to review or implement password hashing, encryption, token generation, TLS, JWT, signatures, and key handling with approved algorithms." diff --git a/.codex/skills/crypto-standards/references/algorithms.md b/.codex/skills/crypto-standards/references/algorithms.md new file mode 100644 index 0000000..93339c9 --- /dev/null +++ b/.codex/skills/crypto-standards/references/algorithms.md @@ -0,0 +1,151 @@ +# Algoritmos Criptográficos — Referência Completa + +Carregue este arquivo quando precisar de detalhes sobre tamanhos de chave, +modos de operação ou orientação para casos de uso específicos. + +--- + +## Chaves simétricas + +| Algoritmo | Tamanho | Status NSI.04 | Modo | Observação | +|-----------|---------|--------------|------|-----------| +| AES | 256 bits | ✅ Recomendado | GCM | Autenticado, preferido | +| AES | 256 bits | ✅ Aprovado | CBC + HMAC-SHA256 | Usar quando GCM não disponível | +| AES | 192 bits | ⚠️ Aceitável | GCM | Mínimo aceitável por NSI.04 | +| AES | 128 bits | ⚠️ Mínimo | GCM | Limite inferior NSI.04 | +| AES | qualquer | ❌ Proibido | ECB | Padrões visíveis, sem autenticação | +| DES | 56 bits | ❌ Proibido | qualquer | Quebrado por força bruta | +| 3DES | 112/168 bits | ❌ Proibido | qualquer | SWEET32, obsoleto | +| RC4 | qualquer | ❌ Proibido | stream | Bias conhecido, quebrado | +| RC2 | qualquer | ❌ Proibido | qualquer | Obsoleto | +| Blowfish | < 128 bits | ❌ Proibido | qualquer | Tamanho de bloco insuficiente | + +--- + +## Chaves assimétricas + +| Algoritmo | Tamanho | Status NSI.04 | Uso recomendado | +|-----------|---------|--------------|----------------| +| RSA | 4096 bits | ✅ Recomendado | Assinatura, troca de chave | +| RSA | 2048 bits | ⚠️ Mínimo | Legado compatível | +| RSA | < 2048 bits | ❌ Proibido | — | +| Ed25519 | 256 bits | ✅ Recomendado | Assinatura — mais rápido que RSA | +| X25519 | 256 bits | ✅ Recomendado | Troca de chave (ECDH) | +| ECDSA P-256 | 256 bits | ✅ Aprovado | Assinatura | +| ECDSA P-384 | 384 bits | ✅ Aprovado | Alta segurança | +| DSA | qualquer | ❌ Proibido | Obsoleto | + +--- + +## Funções de hash + +### Para senhas (KDF — Key Derivation Functions) + +| Algoritmo | Status NSI.04 | Parâmetros mínimos | Observação | +|-----------|--------------|-------------------|-----------| +| Argon2id | ✅ Recomendado | memory=64MB, iter=3, par=4 | Vencedor PHC, resistente a GPU | +| bcrypt | ✅ Aprovado | rounds=12 | Amplamente suportado | +| scrypt | ✅ Aprovado | N=32768, r=8, p=1 | Resistente a ASIC | +| PBKDF2-SHA256 | ⚠️ Aceitável | iter≥310000 | Compatibilidade FIPS | +| MD5 | ❌ Proibido | — | Colisões triviais | +| SHA1 | ❌ Proibido | — | Colisões demonstradas | +| SHA256 sem salt | ❌ Proibido | — | Vulnerável a rainbow tables | +| Plaintext | ❌ Proibido | — | Violação direta da NSI.04 | + +### Para integridade de dados (não senhas) + +| Algoritmo | Status NSI.04 | Uso | +|-----------|--------------|-----| +| SHA-256 | ✅ Recomendado | Integridade geral | +| SHA-384 | ✅ Recomendado | Alta segurança | +| SHA-512 | ✅ Recomendado | Alta segurança | +| SHA-3 | ✅ Aprovado | Alternativa ao SHA-2 | +| BLAKE2b | ✅ Aprovado | Alta performance | +| HMAC-SHA256 | ✅ Recomendado | Integridade com autenticação | +| MD5 | ❌ Proibido | Colisões triviais | +| SHA1 | ❌ Proibido | Colisões conhecidas | +| MD4 | ❌ Proibido | Quebrado | +| CRC32 | ❌ Para segurança | Apenas detecção de erro, não segurança | + +--- + +## TLS / Transporte + +| Versão | Status NSI.04 | Ação | +|--------|--------------|------| +| TLS 1.3 | ✅ Recomendado | Padrão preferido | +| TLS 1.2 | ✅ Aprovado | Manter com cipher suites seguras | +| TLS 1.1 | ❌ Proibido | Desativar imediatamente | +| TLS 1.0 | ❌ Proibido | Desativar imediatamente | +| SSL 3.0 | ❌ Proibido | POODLE, desativar | +| SSL 2.0 | ❌ Proibido | Desativar | + +**Cipher suites aprovadas para TLS 1.2:** +``` +TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384 +TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256 +TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384 +``` + +**Cipher suites proibidas:** +``` +*_RC4_* — RC4 quebrado +*_NULL_* — sem criptografia +*_EXPORT_* — chaves fracas intencionais +*_DES_* — DES quebrado +*_3DES_* — SWEET32 +*_anon_* — sem autenticação +``` + +--- + +## JWT — Algoritmos + +| Algoritmo | Tipo | Status | Observação | +|-----------|------|--------|-----------| +| PS256 | Assimétrico | ✅ Recomendado | RSA-PSS com SHA-256 | +| PS384 | Assimétrico | ✅ Aprovado | RSA-PSS com SHA-384 | +| RS256 | Assimétrico | ⚠️ Compatibilidade | RSASSA-PKCS1-v1_5 com SHA-256; não é RSA-PSS | +| RS384 | Assimétrico | ⚠️ Compatibilidade | RSASSA-PKCS1-v1_5 com SHA-384; não é RSA-PSS | +| ES256 | Assimétrico | ✅ Recomendado | ECDSA P-256 | +| EdDSA | Assimétrico | ✅ Aprovado | Ed25519 | +| HS256 | Simétrico | ⚠️ Condicional | Apenas com chave ≥ 256 bits gerada com CSPRNG | +| HS512 | Simétrico | ⚠️ Condicional | Apenas com chave forte | +| none | — | ❌ Proibido | Sem assinatura | +| RS*/PS* com chave RSA < 2048 bits | — | ❌ Proibido | Chave insuficiente | + +> Fixar os algoritmos aceitos na configuração do verificador; nunca selecionar o +> algoritmo a partir do header não confiável do JWT. Validar as claims obrigatórias, +> incluindo `exp`, `iat`, `iss` e `aud` conforme o contrato da aplicação. + +--- + +## Geração de entropia + +| Caso de uso | Python | Node.js | Go | +|-------------|--------|---------|-----| +| Token de sessão | `secrets.token_urlsafe(32)` | `crypto.randomBytes(32)` | `crypto/rand` | +| Salt | `os.urandom(16)` | `crypto.randomBytes(16)` | `crypto/rand` | +| Nonce AES-GCM | `os.urandom(12)` | `crypto.randomBytes(12)` | `crypto/rand` | +| UUID aleatório | `uuid.uuid4()` | `crypto.randomUUID()` | `github.com/google/uuid` | +| **PROIBIDO** | `random.random()` | `Math.random()` | `math/rand` | + +--- + +## Tamanhos de chave — resumo NSI.04 + +``` +Simétrico: + Mínimo absoluto: 128 bits (AES-128) + Mínimo recomendado: 192 bits (AES-192) + Recomendado: 256 bits (AES-256) ← usar sempre que possível + +Assimétrico (RSA): + Mínimo absoluto: 2048 bits + Recomendado: 4096 bits ← usar sempre que possível + +Curvas elípticas (equivalentes): + P-256 ≈ RSA-3072 + P-384 ≈ RSA-7680 + Ed25519 ≈ RSA-3072 (mais rápido) +``` From b627fdcee2d6b3247416511c127875b74cc4b812 Mon Sep 17 00:00:00 2001 From: "Rondineli G. Saad" Date: Mon, 10 Aug 2026 17:54:16 -0300 Subject: [PATCH 5/8] Add automated dependency security audit --- .github/workflows/dependency-audit.yml | 43 ++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 .github/workflows/dependency-audit.yml diff --git a/.github/workflows/dependency-audit.yml b/.github/workflows/dependency-audit.yml new file mode 100644 index 0000000..bd3dcd0 --- /dev/null +++ b/.github/workflows/dependency-audit.yml @@ -0,0 +1,43 @@ +name: Dependency security audit + +on: + pull_request: + paths: + - "pyproject.toml" + - ".github/workflows/dependency-audit.yml" + push: + branches: + - master + paths: + - "pyproject.toml" + - ".github/workflows/dependency-audit.yml" + schedule: + - cron: "17 9 * * 1" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: dependency-audit-${{ github.ref }} + cancel-in-progress: true + +jobs: + pip-audit: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up production Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: "3.14" + cache: pip + + - name: Install auditor + run: python -m pip install --disable-pip-version-check "pip-audit==2.10.1" + + - name: Audit project dependencies + run: python -m pip_audit --strict --progress-spinner=off . From 54500eaeac2fd1a585acec1de0fe9ce92b37f592 Mon Sep 17 00:00:00 2001 From: "Rondineli G. Saad" Date: Mon, 10 Aug 2026 17:54:28 -0300 Subject: [PATCH 6/8] Prohibit Python 3.9 in production --- Dockerfile | 4 ++++ README.rst | 6 ++++++ SECURITY.md | 35 +++++++++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+) create mode 100644 SECURITY.md diff --git a/Dockerfile b/Dockerfile index dc948f0..22af8d6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,10 @@ ARG PYTHON_VERSION=3.14.6 FROM python:${PYTHON_VERSION}-slim +# Python 3.9 permanece suportado apenas para compatibilidade da biblioteca. +# Imagens de produção exigem uma versão ainda mantida pela política do projeto. +RUN python -c "import sys; assert sys.version_info >= (3, 11), 'Python 3.9/3.10 não é permitido em produção'" + LABEL org.opencontainers.image.authors="SciELO Dev Team " ENV PYTHONUNBUFFERED=1 \ diff --git a/README.rst b/README.rst index 3528918..ccb909c 100644 --- a/README.rst +++ b/README.rst @@ -14,6 +14,12 @@ Como instalar Compatível com Python 3.9 a 3.14. A imagem Docker usa Python 3.14.6. +Python 3.9 é mantido exclusivamente para compatibilidade com instalações legadas e +testes. Seu uso em produção é proibido porque essa versão não recebe mais correções +de segurança. Imagens e novos ambientes de produção devem usar Python 3.11 ou +superior; Python 3.14 é a versão recomendada. O ``Dockerfile`` aplica esse requisito +durante o build, inclusive quando ``PYTHON_VERSION`` é sobrescrito. + Linux ----- diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..ac4e65b --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,35 @@ +# Política de segurança + +## Versões do Python + +- Python 3.9 é aceito somente para compatibilidade da biblioteca, migração de + instalações legadas e execução de testes. +- Python 3.9 não pode ser usado em produção, em imagens publicadas ou em novos + ambientes. Não serão aceitas exceções silenciosas a essa regra. +- O runtime mínimo para produção é Python 3.11. Python 3.14 é o runtime recomendado + e usado pela imagem oficial do projeto. +- A faixa `requires-python = ">=3.9"` expressa compatibilidade de instalação; ela + não representa autorização para implantação em produção. +- Uma exceção temporária exige avaliação de risco documentada, prazo de expiração, + responsável definido e plano de atualização aprovado antes da implantação. + +O `Dockerfile` interrompe o build quando o runtime selecionado é inferior ao mínimo +de produção. Alterar ou remover esse controle requer revisão de segurança. + +## Dependências + +O workflow `Dependency security audit` executa `pip-audit`: + +- em pull requests e pushes que alterem a definição de dependências; +- semanalmente, para detectar vulnerabilidades publicadas depois do merge; +- manualmente, quando necessário. + +Vulnerabilidades conhecidas fazem o workflow falhar. Uma vulnerabilidade só pode ser +ignorada mediante justificativa documentada, análise de aplicabilidade, controle +compensatório, responsável e data de expiração. O identificador ignorado deve ficar +visível no workflow e ser removido assim que houver correção aplicável. + +## Relato de vulnerabilidades + +Não publique detalhes exploráveis em uma issue pública. Envie o relato de forma +privada aos mantenedores pelo canal de segurança configurado na organização SciELO. From aec96e0b1c67ec9d37f25a9ee2a5f57d147e259a Mon Sep 17 00:00:00 2001 From: "Rondineli G. Saad" Date: Mon, 10 Aug 2026 18:20:00 -0300 Subject: [PATCH 7/8] adicao do ci/cd --- .github/ISSUE_TEMPLATE/nova-funcionalidade.md | 85 +++++++++++++ .github/ISSUE_TEMPLATE/reportar-problema.md | 38 ++++++ .../tarefa-de-desenvolvimento.md | 21 ++++ .github/pull_request_template.md | 22 ++++ .github/workflows/master-quality.yml | 27 ++++ .github/workflows/pr.yml | 25 ++++ .github/workflows/release.yml | 117 ++++++++++++++++++ sonar-project.properties | 1 + 8 files changed, 336 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/nova-funcionalidade.md create mode 100644 .github/ISSUE_TEMPLATE/reportar-problema.md create mode 100644 .github/ISSUE_TEMPLATE/tarefa-de-desenvolvimento.md create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/master-quality.yml create mode 100644 .github/workflows/pr.yml create mode 100644 .github/workflows/release.yml create mode 100644 sonar-project.properties diff --git a/.github/ISSUE_TEMPLATE/nova-funcionalidade.md b/.github/ISSUE_TEMPLATE/nova-funcionalidade.md new file mode 100644 index 0000000..87bf256 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/nova-funcionalidade.md @@ -0,0 +1,85 @@ +--- +name: Nova funcionalidade +about: Contribua com novas idéias e necessidades +title: '' +labels: enhancement +assignees: '' + +--- + +### Descrição da nova funcionalidade +Eu, como **[tipo de cargo/ usuário / papel em sistema]**, gostaria que **[descrição breve da funcionalidade]**, então **[consequência ou o porque da requisição da atividade]**. + +### Critérios de aceitação + +Lista de critérios a serem observados pela equipe de engenharia durante a elaboração e construção da tarefa. Seja claro(a), descreva os pontos que são importantes para você: +- Ex 1: Fale sobre qual deve ser o comportamento da funcionalidade; +- Ex 2: Fale sobre quais validações um formulário deve conter; +- Ex 3: Fale sobre os tipos de impressão uma página deve suportar; +- Ex 4: Fale sobre os tipos de usuários podem realizar a ação requisitada; +- Critério 5; +- Critério 6 + +### Anexos +Este tópico é opcional mas pode ser utilizado para incluir objetos a serem analisados ou demonstrações que podem ser utilizados de exemplo. + +### Referências +Este tópico é opcional mas pode ser utilizado para enumerar items de referências como links ou bibliografia. + + +---- + +# Exemplos + +### 1) Descrição do requisito + +Como Usuário Administrador do OPAC, gostaria que o botão de publicação de periódicos possuisse **DESTAQUE**, assim poderia ter um indicativo visual de cuidado antes de clicar. + +### Critérios de aceitação + +Para que esta tarefa seja considerada concluída deve conter os seguintes pontos: +- O botão de publicação deve possuir um tom vermelho que se destaque dos outros elementos de tela; +- O botão deve conter o modo daltônico para que os membros daltônicos do time de publicação possam identifica-lo com facilidade; +- O botão deve ter conter um indicativo de "descrição de ação" ao posicionar o mouse e aguardar alguns segundos. + +### Anexos +N/A + +### Referências +N/A + +--- +### 2) Descrição do requisito +Como Usuário visitante do OPAC, gostaria que a página de artigos fosse adaptativa para celulares, assim poderia utilizar meu dispositivo móvel para navegar com mais facilidade. + +### Critérios de aceitação + +Os seguintes pontos devem ser contemplados: +- Os botões de navegação nesta tela devem ser de fácil acesso e possuir fácil toque; +- Os textos nesta tela devem possuir tamanho adequado para leitura seguindo os padrões da W3C; +- Deve-se agrupar em blocos as seções de página para facilitar a navegabilidade; + +### Anexos +N/A + +### Referências +N/A + +--- +### 3) Descrição do requisito +Como administrador do processo de qualidade, gostaria de ter um pré visualizador de HTML, assim poderia validar a marcação dos XMLs antes de envia-lo para publicação. + + +### Critérios de aceitação + +Os seguintes pontos devem ser contemplados: +- O visualizador de HTML deve ser auto contido e não depender de internet; +- O visualizador de HTML deve funcionar a partir do SPS 1.8; +- O visualizador de HTML deve exibir o conteúdo da mesma forma que o site oficial; +- O visualizador de HTML deve projetar as tabelas de forma correta; + +### Anexos +N/A + +### Referências +N/A diff --git a/.github/ISSUE_TEMPLATE/reportar-problema.md b/.github/ISSUE_TEMPLATE/reportar-problema.md new file mode 100644 index 0000000..abf5219 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/reportar-problema.md @@ -0,0 +1,38 @@ +--- +name: Reportar problema +about: Reporte um erro ou problema e nos ajude a melhorar nossos produtos +title: '' +labels: bug +assignees: '' + +--- + +### Descrição do problema +Descreva de forma clara e objetiva o problema relatado. + +### Passos para reproduzir o problema +1. Acesse a página ... +2. Clique no link ... +3. Role a página até ... +4. Observe o erro apresentado + +### Comportamento esperado +Descreva com clareza qual seria o comportamento **esperado** (correto) ao reproduzir os passos acima. + +### Screenshots ou vídeos +Para dar mais detalhes e contexto sobre o erro, considere anexar fotos ou vídeos do problema. + +### Anexos +Está seção é opcional, utilize para referenciar arquivos que servem de insumo para reproduzir o erro, ex: +- XML utilizado +- HTML produzido +- PDF criado + +### Ambiente utilizado + +Quando aplicável, forneça detalhes sobre o ambiente utilizado, ex: + +- Navegador Mozilla Firefox versão 30 +- Windows XP +- PC programs versão 1.0 +- Aparelho celular iPhone 7, iOS 7 diff --git a/.github/ISSUE_TEMPLATE/tarefa-de-desenvolvimento.md b/.github/ISSUE_TEMPLATE/tarefa-de-desenvolvimento.md new file mode 100644 index 0000000..a3ac269 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/tarefa-de-desenvolvimento.md @@ -0,0 +1,21 @@ +--- +name: Tarefa de desenvolvimento +about: Tarefas definidas pelo próprio time de desenvolvimento +title: '' +labels: task +assignees: '' + +--- + +### Descrição da tarefa +Descreva de forma clara e objetiva a tarefa em questão + +### Subtarefas + +- [ ] Descrição da primeira subtarefa +- [ ] Descrição da segunda subtarefa + + +## Considerações e notas + +* A implementação destas mudanças implica em aumentar o consumo de disco.. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..7365d80 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,22 @@ +#### O que esse PR faz? +Fale sobre o propósito do pull request como por exemplo: quais problemas ele soluciona ou quais features ele adiciona. + +#### Onde a revisão poderia começar? +Indique o caminho do arquivo e o arquivo onde o revisor deve iniciar a leitura do código. + +#### Como este poderia ser testado manualmente? +Estabeleça os passos necessários para que a funcionalidade seja testada manualmente pelo revisor. + +#### Algum cenário de contexto que queira dar? +Indique um contexto onde as modificações se fazem necessárias ou passe informações que contextualizam +o revisor a fim de facilitar o entendimento da funcionalidade. + +### Screenshots +Quando aplicável e se fizer possível adicione screenshots que remetem a situação gráfica do problema que o pull request resolve. + +#### Quais são tickets relevantes? +Indique uma issue ao qual o pull request faz relacionamento. + +### Referências +Indique as referências utilizadas para a elaboração do pull request. + diff --git a/.github/workflows/master-quality.yml b/.github/workflows/master-quality.yml new file mode 100644 index 0000000..6a4a13f --- /dev/null +++ b/.github/workflows/master-quality.yml @@ -0,0 +1,27 @@ +name: Build + +on: + push: + branches: + - master + + +jobs: + build: + name: Build and analyze + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis + - uses: SonarSource/sonarqube-scan-action@v6 + env: + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} + # If you wish to fail your job when the Quality Gate is red, uncomment the + # following lines. This would typically be used to fail a deployment. + # - uses: SonarSource/sonarqube-quality-gate-action@v1 + # timeout-minutes: 5 + # env: + # SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..50a1fb7 --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,25 @@ +name: PR Validation + +on: + pull_request: + branches: + - master + +jobs: + pr-checks: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + # Ajuste conforme a stack da aplicação + - name: Run tests + run: | + echo "Rodar testes aqui" + + - name: SAST - CodeQL + uses: github/codeql-action/init@v3 + with: + languages: javascript + + - uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..c5490a8 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,117 @@ +name: Release + +on: + push: + tags: + - 'v*' + +jobs: + release: + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + + steps: + - uses: actions/checkout@v4 + + - name: Set version + run: echo "VERSION=${GITHUB_REF_NAME}" >> $GITHUB_ENV + + - name: Docker Login + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USER }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build image + run: | + docker build \ + -t infrascielo/paperboy:${VERSION} \ + -t infrascielo/paperboy:latest \ + . + + # 🔐 Scan único (policy) + - name: Trivy Image Scan + uses: aquasecurity/trivy-action@v0.35.0 + with: + image-ref: infrascielo/paperboy:${{ env.VERSION }} + severity: HIGH,CRITICAL + exit-code: 0 + + - name: Install Trivy CLI + run: | + curl -sfL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh | sudo sh -s -- -b /usr/local/bin + + # 📄 Relatório (evidência) + - name: Trivy Report + run: | + trivy image \ + --scanners vuln \ + --severity HIGH,CRITICAL \ + --format table \ + --output trivy-report.txt \ + infrascielo/paperboy:${VERSION} + + - uses: actions/upload-artifact@v4 + with: + name: trivy-report + path: trivy-report.txt + + # 📦 SBOM + - name: Generate SBOM (CycloneDX) + run: | + trivy image \ + --scanners vuln \ + --format cyclonedx \ + --output sbom-${VERSION}.json \ + infrascielo/paperboy:${VERSION} + + - uses: actions/upload-artifact@v4 + with: + name: sbom-${{ env.VERSION }} + path: sbom-${{ env.VERSION }}.json + + - name: Push image + run: | + docker push infrascielo/paperboy:${VERSION} + docker push infrascielo/paperboy:latest + + - name: Push image + run: | + docker push infrascielo/paperboy:${VERSION} + docker push infrascielo/paperboy:latest + + - name: Get image digest + run: | + DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' infrascielo/paperboy:${VERSION}) + echo "IMAGE_DIGEST=${DIGEST}" >> $GITHUB_ENV + + - name: Install Cosign + uses: sigstore/cosign-installer@v3 + + - name: Sign image with Cosign + env: + COSIGN_EXPERIMENTAL: "1" + COSIGN_YES: "true" + run: | + cosign sign ${IMAGE_DIGEST} + + - name: Verify image signature + env: + COSIGN_EXPERIMENTAL: "1" + run: | + cosign verify \ + --certificate-oidc-issuer https://token.actions.githubusercontent.com \ + --certificate-identity-regexp "https://github.com/${{ github.repository }}/*" \ + ${IMAGE_DIGEST} + + - name: Attach SBOM attestation + env: + COSIGN_EXPERIMENTAL: "1" + COSIGN_YES: "true" + run: | + cosign attest \ + --predicate sbom-${VERSION}.json \ + --type cyclonedx \ + ${IMAGE_DIGEST} diff --git a/sonar-project.properties b/sonar-project.properties new file mode 100644 index 0000000..d05b49e --- /dev/null +++ b/sonar-project.properties @@ -0,0 +1 @@ +sonar.projectKey=scieloorg_paperboy_e23f5f0d-9bd3-4e4b-8cab-f8a5ee5fe7f6 From a5ff22507867bb47b884d330f491905dd2917c67 Mon Sep 17 00:00:00 2001 From: "Rondineli G. Saad" Date: Mon, 10 Aug 2026 18:23:15 -0300 Subject: [PATCH 8/8] adicao do ci/cd-2 --- .github/workflows/master-quality.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/master-quality.yml b/.github/workflows/master-quality.yml index 6a4a13f..4ea62f1 100644 --- a/.github/workflows/master-quality.yml +++ b/.github/workflows/master-quality.yml @@ -3,7 +3,7 @@ name: Build on: push: branches: - - master + - qa jobs: