diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml new file mode 100644 index 00000000..d6206389 --- /dev/null +++ b/.github/workflows/audit.yml @@ -0,0 +1,37 @@ +# Dependency security audit against the RustSec advisory database. +# +# Runs when the dependency tree changes and on a weekly schedule, so new +# advisories against ALREADY-COMMITTED dependencies still surface (on +# scheduled runs the action opens an issue for each new advisory). +# Vulnerabilities fail the job; unmaintained/unsound warnings are informational +# — the policy is written down in SECURITY.md. +name: Security audit + +on: + push: + paths: + - "**/Cargo.toml" + - "**/Cargo.lock" + - ".github/workflows/audit.yml" + pull_request: + paths: + - "**/Cargo.toml" + - "**/Cargo.lock" + - ".github/workflows/audit.yml" + schedule: + # Monday 06:30 UTC, weekly. + - cron: "30 6 * * 1" + workflow_dispatch: + +permissions: + contents: read + issues: write + +jobs: + audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: rustsec/audit-check@v2 + with: + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/Cargo.lock b/Cargo.lock index 17d5dced..36af11f7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -158,9 +158,9 @@ checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" [[package]] name = "bytes" -version = "1.11.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cas_android_ffi" @@ -626,9 +626,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..0e279c18 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,75 @@ +# Security Policy + +ExpliCAS evaluates **untrusted mathematical expressions** in three deployment +shapes — client-side WebAssembly on GitHub Pages, a local development server +that forwards input to the native binary, and an Android FFI layer. That is +the attack surface this policy covers. + +This is a solo-maintained project. Reports are handled on a **best-effort +basis**: no response-time guarantees and no supported-versions matrix. The +supported target is the `main` branch and the live Pages deployment built +from it. + +## Reporting a vulnerability + +Please report security issues **privately** — not as public GitHub issues: + +1. **Preferred**: GitHub's private vulnerability reporting — *Security* tab → + *Report a vulnerability* on this repository. +2. Fallback: email the maintainer at `javiergimenezmoya@gmail.com` with + `[ExpliCAS security]` in the subject. + +Include the input (expression, session JSON, or request) that triggers the +problem and what you observed. A reproducible input is the whole report; the +project's own debugging starts from there. + +## Scope + +**In scope** — things we want reported privately: + +- Code execution, memory unsafety, or sandbox escape triggered by a crafted + expression, session-import JSON, or wire payload — in the native binary, + the WASM build, or the Android FFI. +- Injection through the development server (`web/server.py`, + `web/server_colab.py`): anything that turns an expression or HTTP request + into shell/command execution or file access beyond the intended evaluation. +- Hangs or resource exhaustion that **bypass the documented budget system** + (`docs/BUDGET_POLICY.md`): the engine's contract is that every evaluation + is bounded by rewrite/node/time budgets, so an input that escapes them is + a security-relevant robustness failure, not just slowness. +- Vulnerabilities introduced through the dependency tree that are reachable + from expression evaluation. + +**Out of scope** — better reported as regular issues: + +- Slow evaluations that stay *within* the configured budgets, or inputs the + engine honestly declines. +- Mathematically wrong results (the project treats these as its highest-class + ordinary bugs — see the soundness audits under `docs/` — but they are not + security reports). +- Denial of service against a `web/server.py` instance someone chose to + expose publicly (see the trust model below). + +## Trust model, stated plainly + +- **Expressions are always untrusted input.** The parser and engine are + expected to answer, decline, or fail a budget — never to crash the host or + execute anything. +- **Session import replays through the engine.** Imported JSON transcripts + are not trusted as state: each entry is re-evaluated by the same pipeline + as typed input, so a crafted session file has exactly the power of typing + the same expressions. +- **The WASM deployment is client-side only.** There is no server component + on GitHub Pages; the blast radius of a malicious expression there is the + visitor's own tab. +- **`web/server.py` is a development server, not a product.** Note that it + binds **all network interfaces**, not just localhost — machines on your + LAN can reach it while you develop (this is deliberate: the Colab variant + needs it). Do not deploy it publicly; nothing in it is hardened for that. + +## Dependency auditing + +`cargo audit` runs in CI (`.github/workflows/audit.yml`) on every change to +the dependency tree and on a weekly schedule against the RustSec advisory +database. Advisory-level *warnings* (unmaintained/unsound notices) are +reviewed but do not fail the build; *vulnerabilities* do. diff --git a/web/index.html b/web/index.html index 28d73fa8..1226744e 100644 --- a/web/index.html +++ b/web/index.html @@ -998,20 +998,116 @@ transform: translateY(0); } + /* Mobile-only controls: the hamburger that collects the header + actions and the gear that folds the mode selects away. Hidden on + desktop, where everything fits inline. */ + .menu-toggle, + .options-toggle { + display: none; + background: rgba(255, 255, 255, 0.1); + border: 1px solid var(--border); + color: var(--text-primary); + border-radius: 8px; + cursor: pointer; + font-size: 1.1em; + padding: 8px 14px; + transition: all 0.2s; + } + + .menu-toggle:hover, + .options-toggle:hover { + background: rgba(255, 255, 255, 0.2); + border-color: var(--accent); + } + + /* A dot on the gear when any mode differs from its default: the + selects are hidden on mobile, and a non-default mode silently + changing semantics must stay visible. */ + .options-toggle.has-nondefault::after { + content: ""; + display: inline-block; + width: 8px; + height: 8px; + margin-left: 6px; + border-radius: 50%; + background: var(--accent); + vertical-align: middle; + } + @media (max-width: 720px) { body { padding-bottom: 155px; } + /* With the options unfolded the input bar grows several rows tall; + the page needs matching clearance or the last card hides + permanently behind it. */ + body.options-open { + padding-bottom: 480px; + } + + /* Header: title + hamburger; the action buttons become a + slide-down menu under the sticky header. */ + .menu-toggle { + display: flex; + align-items: center; + } + + .header-left p { + display: none; + } + + .header-buttons { + display: none; + } + + header.menu-open .header-buttons { + display: flex; + flex-direction: column; + align-items: stretch; + position: absolute; + top: 100%; + left: 0; + right: 0; + background: var(--bg-secondary); + border-bottom: 2px solid var(--accent); + padding: 10px 14px 14px; + gap: 8px; + box-shadow: 0 12px 24px rgba(0, 0, 0, 0.45); + } + + header.menu-open .header-buttons .header-btn { + justify-content: flex-start; + padding: 12px 14px; + font-size: 1em; + } + + header.menu-open .lang-switch { + align-self: flex-start; + } + .input-wrapper { flex-wrap: wrap; } + /* Mode selects fold behind the gear; the default bar is just + input + gear + Evaluar. */ + .options-toggle { + display: flex; + align-items: center; + height: 48px; + } + .mode-control { - flex: 1 1 150px; + display: none; + flex: 1 1 45%; height: 48px; } + .input-bar.options-open .mode-control { + display: flex; + } + .input-field { flex: 1 1 100%; order: -1; @@ -1020,6 +1116,24 @@ .submit-btn { flex: 1 1 130px; } + + .container { + padding: 14px 12px; + } + } + + /* Every MathJax container inside a card or panel pans horizontally + on its own, so a long formula never forces the PAGE to scroll + sideways and a phone can flick through it with one finger. */ + .expression-card mjx-container, + .steps-container mjx-container, + .variables-panel mjx-container, + .functions-panel mjx-container { + max-width: 100%; + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; + padding-bottom: 3px; } /* Loading state */ @@ -1432,7 +1546,13 @@

🧮 ExpliCAS

Simplificador matemático interactivo paso a paso

-
+ +
@@ -1522,6 +1642,12 @@

¡Bienvenido a ExpliCAS!

+ @@ -1602,9 +1728,78 @@

const LANG_MODE_STORAGE_KEY = 'expliCAS_step_language'; // ===== Interface i18n (es/en) — one catalog, driven by the ES/EN language toggle ===== + // --- Mobile chrome: header menu + input options fold ------------- + function toggleHeaderMenu(force) { + const header = document.querySelector("header"); + const open = force !== undefined ? force : !header.classList.contains("menu-open"); + header.classList.toggle("menu-open", open); + document.getElementById("menu-toggle").setAttribute("aria-expanded", String(open)); + } + + function toggleInputOptions() { + const bar = document.querySelector(".input-bar"); + const open = !bar.classList.contains("options-open"); + bar.classList.toggle("options-open", open); + document.body.classList.toggle("options-open", open); + document.getElementById("options-toggle").setAttribute("aria-expanded", String(open)); + } + + // The gear hides the mode selects on mobile; a non-default mode must + // stay visible, so the gear carries a dot whenever one is active. + const MODE_SELECT_DEFAULTS = { + "domain-mode-select": "generic", + "branch-mode-select": "strict", + "complex-mode-select": "off", + "numeric-display-select": "exact", + }; + + function refreshOptionsIndicator() { + const nondefault = Object.entries(MODE_SELECT_DEFAULTS).some( + ([id, def]) => { + const el = document.getElementById(id); + return el && el.value !== def; + } + ); + document + .getElementById("options-toggle") + .classList.toggle("has-nondefault", nondefault); + } + + document.addEventListener("DOMContentLoaded", () => { + // Acting on a menu entry closes the menu; so does tapping outside + // or pressing Escape. + document.getElementById("header-buttons").addEventListener("click", (ev) => { + if (ev.target.closest("button")) { + toggleHeaderMenu(false); + } + }); + document.addEventListener("click", (ev) => { + const header = document.querySelector("header"); + if (header.classList.contains("menu-open") && !header.contains(ev.target)) { + toggleHeaderMenu(false); + } + }); + document.addEventListener("keydown", (ev) => { + if (ev.key === "Escape") { + toggleHeaderMenu(false); + } + }); + for (const id of Object.keys(MODE_SELECT_DEFAULTS)) { + const el = document.getElementById(id); + if (el) { + el.addEventListener("change", refreshOptionsIndicator); + } + } + refreshOptionsIndicator(); + }); + const I18N = { "es": { "header.tagline": "Simplificador matemático interactivo paso a paso", + "header.menu.title": "Menú", + "header.menu.aria_label": "Abrir el menú de acciones", + "input.options_toggle.title": "Opciones de evaluación (modo, rama, complejo, números)", + "input.options_toggle.aria_label": "Mostrar u ocultar las opciones de evaluación", "header.theme_toggle.title": "Cambiar tema", "header.theme_toggle.label": "🌙 Oscuro", "header.export.title": "Exportar sesión como JSON", @@ -1686,6 +1881,10 @@

}, "en": { "header.tagline": "Interactive Step-by-Step Math Simplifier", + "header.menu.title": "Menu", + "header.menu.aria_label": "Open the actions menu", + "input.options_toggle.title": "Evaluation options (mode, branch, complex, numbers)", + "input.options_toggle.aria_label": "Show or hide the evaluation options", "header.theme_toggle.title": "Toggle theme", "header.theme_toggle.label": "🌙 Dark", "header.export.title": "Export session as JSON",