diff --git a/.github/workflows/security-audit.yaml b/.github/workflows/security-audit.yaml new file mode 100644 index 00000000..fc7f478a --- /dev/null +++ b/.github/workflows/security-audit.yaml @@ -0,0 +1,49 @@ +name: "Security Audit of JS and Python Libraries" + +on: + push: + branches: + - develop + schedule: + - cron: '0 0 * * 1' # Hebdomadaire le lundi à minuit + +permissions: + contents: read + +jobs: + audit: + runs-on: ubuntu-latest + steps: + - name: "Checkout repository" + uses: actions/checkout@v4 + + - name: "Setup Node.js" + uses: actions/setup-node@v6 + with: + node-version: '24' + + - name: "Setup Python" + uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: "Install dependencies" + run: | + npm install --no-save --no-package-lock --ignore-scripts --no-audit --no-fund retire + python -m pip install --upgrade pip + python -m pip install -r install/dev-requirements.txt + + - id: generate-report + name: "Run security scan" + run: | + export REPORT_DATE="$(date -u +%Y-%m-%d)" + export REPORT_BASENAME="security-report-${REPORT_DATE}.txt" + export REPORT_FILE="${GITHUB_WORKSPACE}/${REPORT_BASENAME}" + npm run scan + test -f "$REPORT_FILE" + + - name: "Upload report as artifact" + uses: actions/upload-artifact@v4 + with: + name: mviewerstudio-security-audit-report + path: ${{ steps.generate-report.outputs.report-file }} diff --git a/docker-compose.yml b/docker-compose.yml index 3857cfc2..dd41e589 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,6 +5,7 @@ services: build: context: . dockerfile: docker/Dockerfile + pull_policy: build image: mviewer/mviewerstudio:latest # ports: # - "8000:8000" @@ -44,4 +45,4 @@ services: - "./apps:/usr/share/nginx/html/apps:ro" depends_on: - mviewerstudio - - mviewer + - mviewer \ No newline at end of file diff --git a/install/dev-requirements.txt b/install/dev-requirements.txt index 923fef4a..7d315b73 100644 --- a/install/dev-requirements.txt +++ b/install/dev-requirements.txt @@ -4,4 +4,5 @@ mypy types-Werkzeug # mypy types-Flask sphinx<7.0.0 -PyStemmer \ No newline at end of file +PyStemmer +pip-audit \ No newline at end of file diff --git a/package.json b/package.json index fdf73f93..0aa12366 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ }, "scripts": { "pretty": "prettier --write \"./src/static/mviewerstudio.i18n.json\" \"./src/static/js/**/*.{js,json}\" \"./src/static/lib/mv.js\"", - "prettier-check": "prettier --check \"./src/static/mviewerstudio.i18n.json\" \"./src/static/js/**/*.{js,json}\" \"./src/static/lib/mv.js\"" + "prettier-check": "prettier --check \"./src/static/mviewerstudio.i18n.json\" \"./src/static/js/**/*.{js,json}\" \"./src/static/lib/mv.js\"", + "scan": "node security-scripts/security-audit.js" }, "repository": { "type": "git", diff --git a/security-scripts/security-audit.js b/security-scripts/security-audit.js new file mode 100644 index 00000000..7c544370 --- /dev/null +++ b/security-scripts/security-audit.js @@ -0,0 +1,529 @@ +/** + * @file Runs Retire.js and pip-audit scans, then generates the plain-text + * security report used by the npm `scan` script and the GitHub Actions workflow. + */ +const fs = require("fs"); +const path = require("path"); +const { spawnSync } = require("child_process"); + +const reportDate = process.env.REPORT_DATE || new Date().toISOString().slice(0, 10); +const workspace = process.env.GITHUB_WORKSPACE || process.cwd(); +const webRootRelative = process.env.WEB_ROOT || "src/static"; +const webRoot = path.resolve(workspace, webRootRelative); +const webRootLabel = path.relative(workspace, webRoot).replace(/\\/g, "/") || "."; +const reportBasename = process.env.REPORT_BASENAME || "security-report.txt"; +const reportFile = process.env.REPORT_FILE || path.join(workspace, reportBasename); +const retireJsonFile = + process.env.RETIRE_OUTPUT_FILE || path.join(workspace, "retire-src-static.json"); +const pipAuditJsonFile = + process.env.PIP_AUDIT_OUTPUT_FILE || path.join(workspace, "pip-audit-python.json"); +const pythonRequirementsRelative = ( + process.env.PYTHON_REQUIREMENTS || + "install/requirements.txt,install/dev-requirements.txt,docs/requirements.txt" +) + .split(",") + .map((requirementFile) => requirementFile.trim()) + .filter(Boolean); + +/** + * @typedef {Object} RequirementFile + * @property {string} absolutePath Absolute path to the requirements file. + * @property {string} relativePath Workspace-relative path used in command output. + */ + +/** + * @typedef {Object} PipAuditOutput + * @property {string} source Requirement file audited by pip-audit. + * @property {number | null} status pip-audit process exit status. + * @property {string} output Raw JSON output emitted by pip-audit. + */ + +/** + * @typedef {Object} ParsedPipAudit + * @property {string} source Requirement file audited by pip-audit. + * @property {number | null} status pip-audit process exit status. + * @property {Object} json Parsed pip-audit JSON payload. + */ + +/** + * @typedef {Object} ReportRow + * @property {string} pkg Package, script, or library name. + * @property {string} file Source file or requirements file where it was found. + * @property {string} version Detected dependency version. + * @property {string} vulnKnown Whether a known vulnerability was detected. + * @property {string} risk Human-readable risk level. + * @property {string} cveList CVE or advisory identifiers. + * @property {string} action Recommended remediation action. + */ + +/** + * Ensures the configured web root exists before launching dependency scans. + * + * @throws {Error} When the configured web root is missing or is not a directory. + * @returns {void} + */ +function ensureWebRootExists() { + if (!fs.existsSync(webRoot) || !fs.statSync(webRoot).isDirectory()) { + throw new Error(`Web root not found: ${webRootLabel}`); + } +} + +/** + * Converts a path to a workspace-relative POSIX-like path when possible. + * + * @param {string | null | undefined} file File path to normalize. + * @returns {string} Workspace-relative path, original normalized path, or "unknown". + */ +function toWorkspaceRelative(file) { + if (!file || file === "unknown") { + return "unknown"; + } + + const normalized = file.replace(/\\/g, "/"); + const absolute = path.isAbsolute(file) ? file : path.resolve(workspace, file); + const relative = path.relative(workspace, absolute); + + if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) { + return relative.replace(/\\/g, "/"); + } + + return normalized; +} + +/** + * Reads a single quoted HTML attribute from an already matched tag string. + * + * @param {string} tag Full HTML tag text. + * @param {string} attributeName Attribute name to read. + * @returns {string} Attribute value, or an empty string when absent. + */ +function getHtmlAttribute(tag, attributeName) { + const regexp = new RegExp(`${attributeName}\\s*=\\s*['"]([^'"]+)['"]`, "i"); + const match = regexp.exec(tag); + return match ? match[1] : ""; +} + +/** + * Resolves a local npm binary when installed, falling back to the command name. + * + * @param {string} command Binary command name. + * @returns {string} Local executable path or command name. + */ +function getLocalBin(command) { + const executable = process.platform === "win32" ? `${command}.cmd` : command; + const localBin = path.join(workspace, "node_modules", ".bin", executable); + return fs.existsSync(localBin) ? localBin : executable; +} + +/** + * Resolves existing Python requirements files from the configured candidates. + * + * @returns {RequirementFile[]} Existing requirements files with absolute and relative paths. + */ +function getPythonRequirementFiles() { + return pythonRequirementsRelative + .map((requirementFile) => { + const absolutePath = path.resolve(workspace, requirementFile); + return { + absolutePath, + relativePath: toWorkspaceRelative(absolutePath), + }; + }) + .filter(({ absolutePath }) => fs.existsSync(absolutePath)); +} + +/** + * Runs Retire.js against the configured web root and stores its JSON output. + * + * @throws {Error} When Retire.js cannot be executed. + * @returns {void} + */ +function runRetireScan() { + const retire = getLocalBin("retire"); + const result = spawnSync(retire, ["--path", webRootLabel, "--outputformat", "json"], { + cwd: workspace, + encoding: "utf8", + }); + + if (result.error) { + throw new Error(`Unable to execute Retire.js: ${result.error.message}`); + } + + fs.writeFileSync(retireJsonFile, result.stdout || "", "utf8"); + + if (result.stderr) { + process.stderr.write(result.stderr); + } +} + +/** + * Runs pip-audit once per requirements file and stores all raw JSON outputs. + * + * @param {RequirementFile[]} requirementFiles Requirements files to scan. + * @throws {Error} When pip-audit cannot be executed or exits without JSON output. + * @returns {void} + */ +function runPipAuditScans(requirementFiles) { + const pipAudit = process.platform === "win32" ? "pip-audit.exe" : "pip-audit"; + /** @type {PipAuditOutput[]} */ + const outputs = requirementFiles.map(({ relativePath }) => { + const result = spawnSync(pipAudit, ["-r", relativePath, "--format", "json"], { + cwd: workspace, + encoding: "utf8", + }); + + if (result.error) { + throw new Error(`Unable to execute pip-audit: ${result.error.message}`); + } + + if (result.stderr) { + process.stderr.write(result.stderr); + } + + if (result.status !== 0 && !(result.stdout || "").trim()) { + throw new Error( + `pip-audit failed for ${relativePath}: ${result.stderr || "no output"}` + ); + } + + return { + source: relativePath, + status: result.status, + output: result.stdout || "", + }; + }); + + fs.writeFileSync(pipAuditJsonFile, JSON.stringify(outputs, null, 2), "utf8"); +} + +/** + * Loads the Retire.js JSON output and falls back to an empty report on parse errors. + * + * @returns {{data?: Object[], results?: Object[]}} Parsed Retire.js payload. + */ +function loadRetireJson() { + try { + return JSON.parse(fs.readFileSync(retireJsonFile, "utf8") || "{}"); + } catch (error) { + console.warn(`Impossible de parser ${path.basename(retireJsonFile)}:`, error.message); + return { data: [] }; + } +} + +/** + * Loads the stored pip-audit outputs and parses each embedded JSON payload. + * + * @returns {ParsedPipAudit[]} Parsed pip-audit results grouped by requirements file. + */ +function loadPipAuditJson() { + try { + return JSON.parse(fs.readFileSync(pipAuditJsonFile, "utf8") || "[]").map( + ({ source, status, output }) => { + let json = {}; + try { + json = JSON.parse(output || "{}"); + } catch (error) { + console.warn( + `Impossible de parser le résultat pip-audit ${source}:`, + error.message + ); + } + return { source, status, json }; + } + ); + } catch (error) { + console.warn( + `Impossible de parser ${path.basename(pipAuditJsonFile)}:`, + error.message + ); + return []; + } +} + +/** + * Converts Retire.js findings into normalized report rows. + * + * @param {{data?: Object[], results?: Object[]}} json Parsed Retire.js payload. + * @returns {ReportRow[]} Deduplicated JavaScript vulnerability report rows. + */ +function collectRetireRows(json) { + const issues = Array.isArray(json.data) + ? json.data + : Array.isArray(json.results) + ? json.results + : []; + const rows = []; + const seen = new Set(); + + issues.forEach((item) => { + const file = toWorkspaceRelative(item.file || item.fileName || "unknown"); + const results = + Array.isArray(item.results) && item.results.length > 0 ? item.results : [item]; + + results.forEach((result) => { + const pkg = + result.package || result.component || result.componentName || path.basename(file); + const version = result.version || result.componentVersion || "inconnue"; + const vulns = Array.isArray(result.vulnerabilities) + ? result.vulnerabilities + : Array.isArray(result.vulns) + ? result.vulns + : []; + const vulnKnown = vulns.length > 0 ? "Oui" : "Non"; + const severity = vulns.map((vuln) => (vuln.severity || "").toLowerCase()); + const risk = + vulnKnown === "Oui" + ? severity.includes("high") + ? "Critique" + : severity.includes("medium") + ? "Élevé" + : "Moyen" + : "Faible"; + const action = + vulnKnown === "Oui" + ? "Mettre à jour ou remplacer la dépendance vulnérable" + : "Aucune action urgente"; + const cves = Array.from( + new Set( + vulns.flatMap((vuln) => { + const identifiers = vuln.identifiers || {}; + return Array.isArray(identifiers.CVE) ? identifiers.CVE : []; + }) + ) + ); + const cveList = cves.length > 0 ? cves.join(", ") : "Aucune"; + const key = `${pkg}|${file}|${version}|${risk}|${cveList}`; + + if (!seen.has(key)) { + seen.add(key); + rows.push({ pkg, file, version, vulnKnown, risk, cveList, action }); + } + }); + }); + + return rows; +} + +/** + * Converts pip-audit findings into normalized report rows. + * + * @param {ParsedPipAudit[]} audits Parsed pip-audit results. + * @returns {ReportRow[]} Deduplicated Python vulnerability report rows. + */ +function collectPipAuditRows(audits) { + const rows = []; + const seen = new Set(); + + audits.forEach(({ source, json }) => { + const dependencies = Array.isArray(json.dependencies) ? json.dependencies : []; + dependencies.forEach((dependency) => { + const vulns = Array.isArray(dependency.vulns) ? dependency.vulns : []; + vulns.forEach((vuln) => { + const aliases = Array.isArray(vuln.aliases) ? vuln.aliases : []; + const cves = aliases.filter((alias) => /^CVE-/i.test(alias)); + const advisoryIds = [ + ...cves, + ...aliases.filter((alias) => !/^CVE-/i.test(alias)), + ]; + + if (vuln.id && !advisoryIds.includes(vuln.id)) { + advisoryIds.push(vuln.id); + } + + const cveList = advisoryIds.length > 0 ? advisoryIds.join(", ") : "Aucune"; + const fixVersions = Array.isArray(vuln.fix_versions) ? vuln.fix_versions : []; + const action = + fixVersions.length > 0 + ? `Mettre à jour vers ${fixVersions.join(", ")}` + : "Mettre à jour ou remplacer la dépendance vulnérable"; + const key = `${dependency.name}|${dependency.version}|${source}|${cveList}`; + + if (!seen.has(key)) { + seen.add(key); + rows.push({ + pkg: dependency.name || "unknown", + file: source, + version: dependency.version || "inconnue", + vulnKnown: "Oui", + risk: "À qualifier", + cveList, + action, + }); + } + }); + }); + }); + + return rows; +} + +/** + * Walks HTML files under the web root and lists script and stylesheet references. + * + * @param {string} [dir=webRoot] Directory to inspect recursively. + * @returns {string[]} Markdown list items describing detected HTML resources. + */ +function collectHtmlResourceLines(dir = webRoot) { + const resourceLines = []; + + /** + * Recursively scans HTML files while skipping dependency and VCS directories. + * + * @param {string} currentDir Directory currently being inspected. + * @returns {void} + */ + const walk = (currentDir) => { + fs.readdirSync(currentDir, { withFileTypes: true }).forEach((entry) => { + const full = path.join(currentDir, entry.name); + if (entry.isDirectory()) { + if (entry.name === ".git" || entry.name === "node_modules") { + return; + } + walk(full); + return; + } + + if (!entry.isFile() || !full.endsWith(".html")) { + return; + } + + const content = fs.readFileSync(full, "utf8"); + const relativePath = path.relative(workspace, full).replace(/\\/g, "/"); + const regexp = / + + - + - + + href="lib/bootstrap-tagsinput/bootstrap-tagsinput.css"/> - + @@ -214,6 +215,7 @@