diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4a32299..8df8bb0 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -14,6 +14,14 @@ updates: groups: npm-minor-and-patch: update-types: [minor, patch] + # The docs site (slice 44) has its own package.json and lockfile. + - package-ecosystem: npm + directory: /site + schedule: { interval: weekly, day: monday } + commit-message: { prefix: "chore(deps)" } + groups: + site-npm-minor-and-patch: + update-types: [minor, patch] - package-ecosystem: github-actions directory: / schedule: { interval: weekly, day: monday } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b860dd8..11058cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,6 +85,9 @@ jobs: if: matrix.current - run: mix hex.audit - run: mix deps.audit + # Security-focused static analysis: any finding fails (server/.sobelow-conf). + - run: mix sobelow + if: matrix.current - run: mix deps.unlock --check-unused - run: mix test diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..d237623 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,50 @@ +# SPDX-FileCopyrightText: 2026 ScriptKittyOS and the Kotiko contributors +# SPDX-License-Identifier: Apache-2.0 +# +# CodeQL, GitHub's security analyser: JavaScript (the extension, scripts, tests and the +# docs site) and the GitHub Actions workflows themselves (script injection, untrusted +# checkouts, over-broad tokens). Nothing to build: both are analysed from source. +# Results go to the Security tab's code scanning alerts and to the "Code scanning results" +# check on each pull request. The rules are in +# docs/security/dependency-and-static-analysis-policy.md. +name: CodeQL + +on: + pull_request: + push: + branches: [main] + schedule: + - cron: "41 4 * * 1" + +permissions: + contents: read + +concurrency: + group: codeql-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + analyze: + name: analyze (${{ matrix.language }}) + runs-on: ubuntu-24.04 + permissions: + contents: read + security-events: write # upload the results to code scanning + strategy: + fail-fast: false + matrix: + language: [actions, javascript-typescript] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + with: + languages: ${{ matrix.language }} + build-mode: none + # The default queries plus the security-extended ones (more checks, a few more + # findings to triage). + queries: security-extended + - uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2 + with: + category: /language:${{ matrix.language }} diff --git a/.github/workflows/dependency-scan.yml b/.github/workflows/dependency-scan.yml new file mode 100644 index 0000000..bc0c093 --- /dev/null +++ b/.github/workflows/dependency-scan.yml @@ -0,0 +1,69 @@ +# SPDX-FileCopyrightText: 2026 ScriptKittyOS and the Kotiko contributors +# SPDX-License-Identifier: Apache-2.0 +# +# Known vulnerabilities in every locked dependency: OSV-Scanner over the three lockfiles +# (the dev tools' package-lock.json, the docs site's site/package-lock.json and the +# server's mix.lock), against the OSV database (GitHub advisories, the Erlang Ecosystem +# Foundation's and others). Any known vulnerability fails the job, whatever its severity, +# unless osv-scanner.toml accepts it with a reason and an expiry date. The rules are in +# docs/security/dependency-and-static-analysis-policy.md. +# +# Runs on every pull request and push to main, every Monday (advisories appear for +# versions that haven't changed), and from release.yml before a release is built. +# The binary is a pinned release checked against its SHA-256, no third-party action. +name: Dependency scan + +on: + pull_request: + push: + branches: [main] + schedule: + - cron: "23 5 * * 1" + workflow_call: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + # github.workflow is the caller's name when release.yml calls this, so a release's scan + # never shares a group with (or is cancelled by) a push's. + group: ${{ github.workflow }}-dependency-scan-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + osv-scanner: + runs-on: ubuntu-24.04 + permissions: + contents: read + env: + OSV_SCANNER_VERSION: "2.6.0" + OSV_SCANNER_SHA256: "ca69b3d3cd08f889a49dc0a383122f71cc528b83803671df5fd874d97485b108" + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - name: Install OSV-Scanner + run: | + curl -sSfL -o "$RUNNER_TEMP/osv-scanner" \ + "https://github.com/google/osv-scanner/releases/download/v${OSV_SCANNER_VERSION}/osv-scanner_linux_amd64" + echo "${OSV_SCANNER_SHA256} $RUNNER_TEMP/osv-scanner" | sha256sum -c - + chmod +x "$RUNNER_TEMP/osv-scanner" + - name: Scan the lockfiles + run: | + set +e + "$RUNNER_TEMP/osv-scanner" scan source --config osv-scanner.toml \ + --lockfile package-lock.json \ + --lockfile site/package-lock.json \ + --lockfile server/mix.lock \ + --format markdown --output-file "$RUNNER_TEMP/osv.md" + status=$? + set -e + if [ -f "$RUNNER_TEMP/osv.md" ]; then + cat "$RUNNER_TEMP/osv.md" + { echo "## Dependency scan (OSV-Scanner)"; cat "$RUNNER_TEMP/osv.md"; } >> "$GITHUB_STEP_SUMMARY" + fi + if [ "$status" -eq 1 ]; then + echo "::error title=Known vulnerability::A locked dependency has a known vulnerability. Update it, or accept it in osv-scanner.toml with a reason and an expiry date (docs/security/dependency-and-static-analysis-policy.md)." + fi + exit "$status" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b9cd66c..1fcd4b4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,8 +7,9 @@ # with the version bumps and the CHANGELOG entry. It never tags or releases on its own. # 2. A maintainer merges that PR, then pushes a signed tag (scripts/tag-release.sh). # 3. The tag push: verify-tag (signature against .github/allowed_signers on main, commit on -# main, versions match) -> build (tests, lint, both zips built twice and compared, SBOM, -# SHA256SUMS) -> attest (Sigstore build provenance for every file) -> github-release. +# main, versions match) and dependency-scan (no known vulnerability in any lockfile) +# -> build (tests, lint, both zips built twice and compared, SBOM, SHA256SUMS) -> attest +# (Sigstore build provenance for every file) -> github-release. # 4. For a release (not an -rc.N candidate): store-gate (slice 54's security review is # closed) -> publish-chrome and publish-firefox, each waiting for approval in the # `release` environment. Without the store secrets they skip with a notice. @@ -74,9 +75,19 @@ jobs: TAG: ${{ github.ref_name }} run: bash scripts/verify-tag.sh "$TAG" --signers .github/allowed_signers --keys .github/release-keys.asc + # No release is built from a tree with a known vulnerability in any lockfile, unless + # osv-scanner.toml accepts it (docs/security/dependency-and-static-analysis-policy.md). + # The same scan as on every pull request, run again here because advisories appear for + # versions that haven't changed. + dependency-scan: + if: github.event_name == 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/v') + permissions: + contents: read + uses: ./.github/workflows/dependency-scan.yml + build: - needs: verify-tag - if: ${{ !cancelled() && (github.event_name == 'workflow_dispatch' || needs.verify-tag.result == 'success') }} + needs: [verify-tag, dependency-scan] + if: ${{ !cancelled() && needs.dependency-scan.result == 'success' && (github.event_name == 'workflow_dispatch' || needs.verify-tag.result == 'success') }} runs-on: ubuntu-24.04 permissions: contents: read diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e4cf96a..9494bf3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -67,6 +67,10 @@ site in `site/` (its own `package.json`, build and tests), in - Code follows [docs/CODING_STANDARDS.md](docs/CODING_STANDARDS.md); CI checks formatting and lint, so run `mix format`, `mix credo` and `npm run lint` before pushing. +- CI scans the dependencies (OSV-Scanner, `mix deps.audit`) and the code (Sobelow, + CodeQL) for security problems. What fails a pull request, the licenses a new dependency + may have, and how to accept a false positive are in the + [dependency and static analysis policy](docs/security/dependency-and-static-analysis-policy.md). - Maintainers review every pull request; how decisions are made and who maintains what is in [GOVERNANCE.md](GOVERNANCE.md) and [MAINTAINERS.md](MAINTAINERS.md). - Commit messages and pull request titles follow diff --git a/SECURITY.md b/SECURITY.md index e28b389..0d1005b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -45,6 +45,12 @@ credit, we'll name you in the release notes. If the problem is in a dependency, we report it upstream and ship a pinned or patched version in the meantime. +We also look for problems ourselves: every pull request and every release is checked for +known vulnerabilities in our dependencies and for security weaknesses in our code. What +blocks a merge or a release, how fast findings are fixed, and how a false positive is +accepted are in the +[dependency and static analysis policy](docs/security/dependency-and-static-analysis-policy.md). + The security response lead named in [MAINTAINERS.md](MAINTAINERS.md) runs this process; [GOVERNANCE.md](GOVERNANCE.md) describes the role. diff --git a/docs/security/assurance-case.md b/docs/security/assurance-case.md index 107b3b0..1f1e6ed 100644 --- a/docs/security/assurance-case.md +++ b/docs/security/assurance-case.md @@ -62,7 +62,7 @@ its argument and evidence. | A malicious or broken model answer | The word pipeline on the server or in the extension | Schema and rule checks, length caps, script checks, text-only rendering | | A Telegram user who isn't allowed | The bot's chat | Sender allowlist | | Another user on the server machine | Files in the data folder, depending on permissions | `api-token` and backups are 0600; the database follows the umask (residual risk, section 7) | -| A compromised dependency or GitHub Action | The build, CI, and later the release | Lockfiles, actions pinned by commit SHA, Dependabot, `mix hex.audit` and `mix deps.audit` in CI, no runtime npm code in the extension | +| A compromised dependency or GitHub Action | The build, CI, and later the release | Lockfiles, actions pinned by commit SHA, Dependabot, OSV-Scanner over every lockfile in CI and before each release, `mix hex.audit` and `mix deps.audit` in CI, no runtime npm code in the extension | | A malicious pull request | The code base | Review by a maintainer; CI with read-only token; branch protection on going public ([PUBLIC_CHECKLIST.md](../PUBLIC_CHECKLIST.md)) | ## 3. Trust boundaries @@ -126,17 +126,17 @@ a small JSON API. | CWE-79 Cross-site scripting | The extension never parses strings as HTML; ESLint enforces it. The server returns JSON only | In place | [`eslint.config.js`](../../eslint.config.js) | | CWE-89 SQL injection | Queries go through Ecto with bound parameters. The few raw statements use fixed table names or `?` parameters; the one built string (`VACUUM INTO` a backup path the server chose) is quoted | In place | [`words.ex`](../../server/lib/kotiko/words.ex); [`data_dir.ex`](../../server/lib/kotiko/data_dir.ex) `sql_string/1` | | CWE-78 OS command injection | The server runs no external commands (no `System.cmd`, `Port.open` or `:os.cmd` in `server/lib`). The shell scripts read only the owner's `.env` | In place | `server/lib/`; ShellCheck in [`ci.yml`](../../.github/workflows/ci.yml) | -| CWE-22 Path traversal | No file path comes from a request. The data folder comes from the owner's settings; backup names are built from the version and the time | In place | [`config.ex`](../../server/lib/kotiko/config.ex) `data_dir/4`; [`migrations.ex`](../../server/lib/kotiko/migrations.ex) | +| CWE-22 Path traversal | No file path comes from a request. The data folder comes from the owner's settings; backup names are built from the version and the time. Sobelow fails CI on any new file call with a path it can't rule out; each accepted one says why | In place | [`config.ex`](../../server/lib/kotiko/config.ex) `data_dir/4`; [`migrations.ex`](../../server/lib/kotiko/migrations.ex); [`.sobelow-conf`](../../server/.sobelow-conf) | | CWE-306, CWE-862 Missing authentication or authorization | Deny by default; one token for everything (no roles yet, so no authorization levels to get wrong) | In place; per-user authorization planned (slice 48) | `authorize/2`; [`router_auth_test.exs`](../../server/test/kotiko/router_auth_test.exs) | | CWE-352 Cross-site request forgery | The token travels in a header, never a cookie; the server sends no CORS headers, so a page can't attach the header (that needs a preflight the server doesn't answer) | In place | [`router_auth_test.exs`](../../server/test/kotiko/router_auth_test.exs) "a CORS preflight gets no CORS headers" | | CWE-918 Server-side request forgery | The server fetches only URLs the owner set (`LLM_URL`, `TRANSCRIBE_URL`) and fixed hosts (Telegram, Wiktionary with the word percent-encoded in the path). No request can choose a URL | In place | [`config.ex`](../../server/lib/kotiko/config.ex); [`pronounce.ex`](../../server/lib/kotiko/pronounce.ex) | -| CWE-502 Unsafe deserialization | Nothing from the network becomes an Erlang term: requests and API answers are parsed as JSON, Wiktionary pages are read as text. One `:erlang.binary_to_term` call reads the lookup cache the server itself wrote to its own database, with the `:safe` option | In place | [`llm/cache.ex`](../../server/lib/kotiko/llm/cache.ex) `decode/1` | +| CWE-502 Unsafe deserialization | Nothing from the network becomes an Erlang term: requests and API answers are parsed as JSON, Wiktionary pages are read as text. One call reads back the lookup cache the server itself wrote to its own database, with `Plug.Crypto.non_executable_binary_to_term/2` and `:safe`, so a changed row can neither create atoms nor carry a function; Sobelow checks for any other | In place | [`llm/cache.ex`](../../server/lib/kotiko/llm/cache.ex) `decode/1`; `llm_test.exs` ("a stored entry holding a function is a miss") | | CWE-400 Resource exhaustion | Body caps (64 KB, 1 MB batch); at most 500 words per batch and 20,000 per list request; each lookup has a deadline and an attempt cap; refused Host names are logged at most once an hour per name. There is no request rate limit for token holders | In place, partly | [`router.ex`](../../server/lib/kotiko/router.ex); [`router_v1.ex`](../../server/lib/kotiko/router_v1.ex) `@max_batch`, `@max_limit`; [`spec/models.json`](../../spec/models.json); [`host_check.ex`](../../server/lib/kotiko/plug/host_check.ex) | | CWE-20 Improper input validation | Settings validation; the shared word spec and its rules; response validation in the extension; server address validation | In place | Section 6 | | CWE-798 Hard-coded credentials | No credentials in the code. CI's required `secrets` job runs gitleaks over the full history on every pull request and every push to `main`, and fails a pull request that adds a `.env`, key or token file; GitHub secret scanning and push protection are on | In place | [`ci.yml`](../../.github/workflows/ci.yml) job `secrets`; [`.gitleaks.toml`](../../.gitleaks.toml); [`check-forbidden-files.mjs`](../../scripts/check-forbidden-files.mjs) | | CWE-532 Secrets in logs | The redaction filter; words only with `LOG_LOOKUPS=true` at debug | In place | [`log/redact.ex`](../../server/lib/kotiko/log/redact.ex); [`llm_test.exs`](../../server/test/kotiko/llm_test.exs) | | DNS rebinding | Host allowlist before routing | In place | [`host_check.ex`](../../server/lib/kotiko/plug/host_check.ex) | -| Supply chain | Lockfiles (`mix.lock`, `package-lock.json`); every GitHub Action pinned by commit SHA; Dependabot for mix, npm and Actions; `mix hex.audit` and `mix deps.audit` in CI; no runtime npm code in the extension; review on every pull request. Not yet: an npm audit or OSV-Scanner in CI (slice 02 addition), release attestations (slice 30) | In place, partly | [`ci.yml`](../../.github/workflows/ci.yml); [`dependabot.yml`](../../.github/dependabot.yml); [`package.json`](../../package.json) | +| Supply chain | Lockfiles (`mix.lock`, `package-lock.json`); every GitHub Action pinned by commit SHA; Dependabot for mix, both npm lockfiles and Actions; OSV-Scanner over all three lockfiles on every pull request, weekly and before each release build; `mix hex.audit` and `mix deps.audit` in CI; no runtime npm code in the extension; review on every pull request ([policy](dependency-and-static-analysis-policy.md)). Not yet: release attestations (slice 30) | In place, partly | [`dependency-scan.yml`](../../.github/workflows/dependency-scan.yml); [`ci.yml`](../../.github/workflows/ci.yml); [`dependabot.yml`](../../.github/dependabot.yml); [`package.json`](../../package.json) | ## 6. Inputs and how each is validated diff --git a/docs/security/dependency-and-static-analysis-policy.md b/docs/security/dependency-and-static-analysis-policy.md new file mode 100644 index 0000000..a8daea4 --- /dev/null +++ b/docs/security/dependency-and-static-analysis-policy.md @@ -0,0 +1,129 @@ +# Dependency and static analysis policy + +Last reviewed: 2026-10-06. + +How Kotiko finds known vulnerabilities in its dependencies and security weaknesses in its +own code, what blocks a merge or a release, how fast findings are fixed, and how a finding +that can't be exploited is accepted. Every rule below is enforced by the CI named next to +it, unless it says "by review". + +## Scope + +- **Runtime**: what ships to users. The server's Hex dependencies outside `only: :dev`/ + `:test` (in `server/mix.lock`), and code the docs site build puts into its pages. The + extension ships no npm code at all ([CONTRIBUTING.md](../../CONTRIBUTING.md)). +- **Dev-only**: everything else in the three lockfiles: the dev tools in + `package-lock.json` (tests, lint, store upload), the site's build tools in + `site/package-lock.json`, and the server's dev and test dependencies. They never reach a + user, but they run in CI and on contributors' machines, so they are scanned too. +- **Our code**: the extension, scripts, tests and site (JavaScript), the server (Elixir), + shell scripts and the GitHub Actions workflows. + +## Dependencies (software composition analysis) + +What runs: + +| Check | Covers | When | Fails on | +| --- | --- | --- | --- | +| OSV-Scanner ([`dependency-scan.yml`](../../.github/workflows/dependency-scan.yml)) | All three lockfiles, runtime and dev-only | Every pull request, every push to `main`, every Monday, and before every release build | Any known vulnerability or malicious-package report in the [OSV database](https://osv.dev), any severity, not accepted in [`osv-scanner.toml`](../../osv-scanner.toml) | +| `mix deps.audit` ([`ci.yml`](../../.github/workflows/ci.yml), server job) | `server/mix.lock` | Every pull request and push to `main` | Any advisory in mix_audit's database | +| `mix hex.audit` (same job) | `server/mix.lock` | Same | A retired Hex package | +| Dependabot ([`dependabot.yml`](../../.github/dependabot.yml)) | mix, both npm lockfiles, Actions | Weekly | Opens update pull requests; nothing to fail | + +**Blocks a merge**: any finding above. They run on every pull request, and a pull request +merges only when the required checks of the `main` ruleset pass. A new advisory against a +version already on `main` turns every pull request red until it is fixed or accepted, so +nobody can merge around it. Not yet: the `osv-scanner` check is not in the ruleset's +required checks (the server job, with both mix audits, is). + +**Blocks a release**: the release workflow ([`release.yml`](../../.github/workflows/release.yml)) +runs the same OSV-Scanner job before it builds anything; when it fails, nothing is built, +attested, released or sent to a store. There is no override other than an accepted +exception in `osv-scanner.toml` on the tagged commit. + +**Deadlines**, counted from the day the advisory is published (the Monday scan finds those +nobody pushed against): + +| Severity (CVSS, as OSV reports it) | Runtime | Dev-only | +| --- | --- | --- | +| Critical | Fixed release within 7 days | Updated or accepted within 7 days | +| High | Fixed release within 30 days | Updated or accepted within 30 days | +| Medium | Fixed release within 60 days | Updated or accepted within 90 days | +| Low or unknown | In the next planned release | Updated or accepted within 90 days | + +These are the same targets as a reported vulnerability ([SECURITY.md](../../SECURITY.md)). +"Fixed" means an updated or patched version; if upstream has no fix, we pin, patch or +replace the package, or remove the code path that uses it. + +**Licenses** (by review, not CI: OSV-Scanner has no license data for Hex packages). A +runtime dependency must be under Apache-2.0, MIT, BSD-2-Clause, BSD-3-Clause, ISC, 0BSD, +Zlib, Unicode-3.0, CC0-1.0 or public domain, all compatible with Kotiko's Apache-2.0; on +2026-10-06 every one is Apache-2.0 or MIT. Anything else (GPL, LGPL, AGPL, a custom or +missing license) needs a maintainer's decision recorded in +[slices/DECISIONS.md](../../slices/DECISIONS.md) before it merges. Dev-only tools may use +any [OSI-approved](https://opensource.org/licenses) license, since we don't distribute +them. The reviewer checks the license when a pull request adds a dependency or a +Dependabot update changes one; a violation found later is replaced or approved before the +next release. Each release's server SBOM (`kotiko-server-.cdx.json`) lists the +runtime dependencies and their licenses. + +## Our code (static analysis) + +What runs, all on every pull request and push to `main`: + +| Tool | Covers | Fails on | +| --- | --- | --- | +| Sobelow (`mix sobelow`, settings in [`server/.sobelow-conf`](../../server/.sobelow-conf)) | The server, its mix tasks, migrations and scripts: SQL and command injection, path traversal, unsafe `binary_to_term`, atom exhaustion, insecure configuration | Any finding, at any confidence | +| CodeQL ([`codeql.yml`](../../.github/workflows/codeql.yml), also weekly) | JavaScript everywhere; the workflows (script injection, untrusted checkouts, token permissions) | Alerts appear in the Security tab and in the "Code scanning results" check | +| ESLint with eslint-plugin-no-unsanitized, `web-ext lint` | The extension and scripts: HTML injection sinks, unsafe extension patterns | Any error | +| Credo, ShellCheck | Elixir and shell code quality | Any issue | + +**Blocks a merge**: any Sobelow, ESLint, web-ext, Credo or ShellCheck finding (their jobs +are required checks). A CodeQL alert of security severity high or critical, or of severity +error, fails the "Code scanning results" check. Not yet: the `main` ruleset doesn't require +code scanning results, so until it does, CodeQL alerts are caught by review. + +**Deadlines** for findings on `main` (a new CodeQL query, a new Sobelow release): an +exploitable one is a vulnerability and follows the [SECURITY.md](../../SECURITY.md) +targets (critical 7 days, high 30, medium 60, low in the next planned release). Every +CodeQL alert, of any severity, is fixed or dismissed with a reason before the next release. + +## Accepting a finding that can't be exploited + +Only when the finding is a false positive, or the vulnerable code can't be reached in the +way Kotiko uses it (for example a dev-only tool that never handles untrusted input). A +reachable finding in runtime code is never accepted; it is fixed. + +Each acceptance is a pull request a maintainer reviews, and it is recorded where the +check reads it: + +- **Dependency**: an `[[IgnoredVulns]]` entry in [`osv-scanner.toml`](../../osv-scanner.toml) + with the advisory `id`, a `reason` (why it isn't exploitable, with evidence, and what we + are waiting for), and an `ignoreUntil` date at most 30 days ahead for critical or high + and 90 days for the rest. After that date the scan fails again, and renewing it is a new + review. Remove the entry once the dependency is updated. `mix deps.audit` has no skip + list: the same advisory goes in its `--ignore-advisory-ids` in `ci.yml`, under a comment + with the same reason and date. +- **Sobelow**: a `# sobelow_skip ["Check.Name"]` comment directly above the function, under + a comment saying why the finding is false. Never `ignore` a whole check in + `.sobelow-conf` without changing this policy. +- **CodeQL**: dismiss the alert in the Security tab as "False positive" or "Used in tests" + with a comment saying why; GitHub keeps who dismissed it and when. +- **ESLint**: an `eslint-disable-next-line -- ` comment. + +Accepted findings today: none in `osv-scanner.toml`; the Sobelow skips are all +`Traversal.FileModule` on file paths that come from the server's own settings or an +operator's command line, never from a request. + +## Running the checks yourself + +```bash +cd server && mix sobelow && mix deps.audit && mix hex.audit +# OSV-Scanner: download a release from github.com/google/osv-scanner, then +osv-scanner scan source --config osv-scanner.toml \ + -L package-lock.json -L site/package-lock.json -L server/mix.lock +``` + +If `mix sobelow --version` doesn't print the version in `server/mix.lock`, a globally +installed Sobelow archive is shadowing the project's; remove it with +`mix archive.uninstall sobelow`. diff --git a/osv-scanner.toml b/osv-scanner.toml new file mode 100644 index 0000000..22c2121 --- /dev/null +++ b/osv-scanner.toml @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: 2026 ScriptKittyOS and the Kotiko contributors +# SPDX-License-Identifier: Apache-2.0 +# +# OSV-Scanner settings for .github/workflows/dependency-scan.yml (all three lockfiles). +# +# Accepted findings only. Each one needs all three fields and a review before it expires; +# after ignoreUntil the finding fails CI again. The rules for accepting one are in +# docs/security/dependency-and-static-analysis-policy.md. Example: +# +# [[IgnoredVulns]] +# id = "GHSA-xxxx-xxxx-xxxx" +# ignoreUntil = 2026-11-05 # at most 30 days for high or critical, 90 for the rest +# reason = "Dev-only (site build). The vulnerable function is never called: . Fixed upstream in x.y.z, waiting for to update." +# +# None accepted today. diff --git a/server/.sobelow-conf b/server/.sobelow-conf new file mode 100644 index 0000000..9f1d8b2 --- /dev/null +++ b/server/.sobelow-conf @@ -0,0 +1,29 @@ +# SPDX-FileCopyrightText: 2026 ScriptKittyOS and the Kotiko contributors +# SPDX-License-Identifier: Apache-2.0 + +# Sobelow, the security-focused static analyser (`mix sobelow`, run in CI's server job). +# The rules are in docs/security/dependency-and-static-analysis-policy.md. +# +# - exit: :low any finding fails the run, whatever Sobelow's confidence. +# - skip: true honour `# sobelow_skip ["Check"]` comments. Each one sits above the +# function it covers, under a comment saying why the finding is false. +# - router: :none Kotiko is a plain Plug app with no Phoenix router, so the router +# checks (CSRF, CSP, headers) have nothing to read. The other checks +# (SQL, command injection, traversal, binary_to_term, atoms) still run. +# - include_* also scan the mix tasks, the migrations and other scripts. +# - strict: true a file Sobelow can't parse fails the run instead of being skipped. +# - private: true no update check, no network. +[ + exit: :low, + threshold: :low, + skip: true, + router: :none, + include_mix_tasks: true, + include_scripts: true, + strict: true, + private: true, + ignore: [], + ignore_files: [], + format: "txt", + verbose: false +] diff --git a/server/lib/kotiko/config.ex b/server/lib/kotiko/config.ex index a3e1c42..729c392 100644 --- a/server/lib/kotiko/config.ex +++ b/server/lib/kotiko/config.ex @@ -449,6 +449,8 @@ defmodule Kotiko.Config do end @doc false + # Sobelow: the folder is KOTIKO_DATA_DIR or the default, never from a request. + # sobelow_skip ["Traversal.FileModule"] def ensure_dir(dir) do with :ok <- File.mkdir_p(dir), {:ok, %File.Stat{type: :directory, access: :read_write}} <- File.stat(dir) do diff --git a/server/lib/kotiko/data_dir.ex b/server/lib/kotiko/data_dir.ex index a661fb9..c339a04 100644 --- a/server/lib/kotiko/data_dir.ex +++ b/server/lib/kotiko/data_dir.ex @@ -97,6 +97,8 @@ defmodule Kotiko.DataDir do a token the old server made keeps working. Returns `{:ok, copied_from_path}`, `{:ok, nil}` when there was nothing to copy, or `{:error, message}`. """ + # Sobelow: both folders come from the server's own settings at startup, never a request. + # sobelow_skip ["Traversal.FileModule"] def copy_legacy_token(from_dir, to_dir) do from = Token.path(from_dir) to = Token.path(to_dir) @@ -126,6 +128,8 @@ defmodule Kotiko.DataDir do end end + # Sobelow: the data folder comes from the server's settings, never from a request. + # sobelow_skip ["Traversal.FileModule"] defp fresh(target) do case File.mkdir_p(target) do :ok -> {:ok, :fresh} @@ -135,6 +139,8 @@ defmodule Kotiko.DataDir do # ── the copy ──────────────────────────────────────────────────────── + # Sobelow: the data folder comes from the server's settings, never from a request. + # sobelow_skip ["Traversal.FileModule"] defp copy(legacy, target) do database = database(target) @@ -166,6 +172,8 @@ defmodule Kotiko.DataDir do end # With the old database locked, so nothing can write to it until the copy is in place. + # Sobelow: the data folder comes from the server's settings, never from a request. + # sobelow_skip ["Traversal.FileModule"] defp copy_locked(conn, legacy, target) do database = database(target) partial = database <> ".partial" @@ -252,6 +260,8 @@ defmodule Kotiko.DataDir do end end + # Sobelow: the old database's folder comes from the server's settings, not a request. + # sobelow_skip ["Traversal.FileModule"] defp write_note(legacy, database) do dir = Path.dirname(legacy) at = DateTime.utc_now() |> DateTime.truncate(:second) |> DateTime.to_iso8601() @@ -280,6 +290,8 @@ defmodule Kotiko.DataDir do # Best effort: the database is already private, and a shared folder someone chose may # not be theirs to change. + # Sobelow: the data folder comes from the server's settings, never from a request. + # sobelow_skip ["Traversal.FileModule"] defp private_dir(dir) do case File.chmod(dir, 0o700) do :ok -> :ok @@ -287,6 +299,8 @@ defmodule Kotiko.DataDir do end end + # Sobelow: the database path comes from the server's settings, never from a request. + # sobelow_skip ["Traversal.FileModule"] defp remove_partial(database) do Enum.each(@partial_suffixes, &File.rm(database <> &1)) end diff --git a/server/lib/kotiko/llm/cache.ex b/server/lib/kotiko/llm/cache.ex index ff80fc6..28bb35e 100644 --- a/server/lib/kotiko/llm/cache.ex +++ b/server/lib/kotiko/llm/cache.ex @@ -163,11 +163,13 @@ defmodule Kotiko.LLM.Cache do def count, do: Repo.aggregate("lookup_cache", :count) # The checked result is an Elixir map with atom keys; term_to_binary keeps it exactly. + # Reading it back refuses new atoms (`:safe`) and any function, so a row changed on disk + # can't hand the server code to run (Sobelow's Misc.BinToTerm). defp encode(result), do: result |> :erlang.term_to_binary() |> Base.encode64() defp decode(text) do with {:ok, bin} <- Base.decode64(text), - %{words: [_ | _]} = result <- :erlang.binary_to_term(bin, [:safe]) do + %{words: [_ | _]} = result <- Plug.Crypto.non_executable_binary_to_term(bin, [:safe]) do result else _ -> nil diff --git a/server/lib/kotiko/llm/catalog.ex b/server/lib/kotiko/llm/catalog.ex index f5740e6..345d1d2 100644 --- a/server/lib/kotiko/llm/catalog.ex +++ b/server/lib/kotiko/llm/catalog.ex @@ -356,6 +356,8 @@ defmodule Kotiko.LLM.Catalog do defp cache_path, do: Path.join(Application.fetch_env!(:kotiko, :data_dir), @cache_file) + # Sobelow: a fixed file name in the configured data folder, never from a request. + # sobelow_skip ["Traversal.FileModule"] defp load_cache(state) do with true <- Client.openrouter?(), {:ok, raw} <- File.read(cache_path()), @@ -378,6 +380,8 @@ defmodule Kotiko.LLM.Catalog do _ -> state end + # Sobelow: a fixed file name in the configured data folder, never from a request. + # sobelow_skip ["Traversal.FileModule"] defp write_cache(state) do json = Jason.encode!(%{ diff --git a/server/lib/kotiko/migrations.ex b/server/lib/kotiko/migrations.ex index 5d746e8..cf730e4 100644 --- a/server/lib/kotiko/migrations.ex +++ b/server/lib/kotiko/migrations.ex @@ -60,6 +60,8 @@ defmodule Kotiko.Migrations do end @doc "Copies the database into `/backups` and prunes old copies. Returns the path." + # Sobelow: backups go under the configured data folder, never a path from a request. + # sobelow_skip ["Traversal.FileModule"] def backup!(repo, data_dir) do dir = Path.join(data_dir, "backups") File.mkdir_p!(dir) @@ -82,6 +84,8 @@ defmodule Kotiko.Migrations do end # Newest first by the UTC time in the name; keeps @keep_backups. + # Sobelow: only names matching the backup pattern, inside the configured backups folder. + # sobelow_skip ["Traversal.FileModule"] defp prune(dir) do dir |> File.ls!() diff --git a/server/lib/kotiko/token.ex b/server/lib/kotiko/token.ex index d4b30c2..adfddc7 100644 --- a/server/lib/kotiko/token.ex +++ b/server/lib/kotiko/token.ex @@ -22,6 +22,8 @@ defmodule Kotiko.Token do """ def resolve(env_token, data_dir) + # Sobelow: `api-token` in the configured data folder, never a path from a request. + # sobelow_skip ["Traversal.FileModule"] def resolve(nil, data_dir) do path = path(data_dir) @@ -68,6 +70,8 @@ defmodule Kotiko.Token do Writes `contents` to `path` (mode 0600). The file is made private before the token goes in, and swapped in with a rename, so the token is never in a file other users can read. """ + # Sobelow: callers pass the token file's path from the data folder, never a request's. + # sobelow_skip ["Traversal.FileModule"] def save(path, contents) do tmp = path <> ".new" _ = File.rm(tmp) diff --git a/server/lib/mix/kotiko_task_env.ex b/server/lib/mix/kotiko_task_env.ex index cf5972b..69f504e 100644 --- a/server/lib/mix/kotiko_task_env.ex +++ b/server/lib/mix/kotiko_task_env.ex @@ -9,6 +9,8 @@ defmodule Kotiko.TaskEnv do """ @doc "The environment, overridden by the .env file as run.sh does. Blank values are unset." + # Sobelow: the .env file an operator's mix task names (default `.env`), not a request. + # sobelow_skip ["Traversal.FileModule"] def read_env(file) do dotenv = case File.read(file) do diff --git a/server/lib/mix/tasks/kotiko.export.ex b/server/lib/mix/tasks/kotiko.export.ex index cbdcd43..b841057 100644 --- a/server/lib/mix/tasks/kotiko.export.ex +++ b/server/lib/mix/tasks/kotiko.export.ex @@ -28,6 +28,8 @@ defmodule Mix.Tasks.Kotiko.Export do defp write(nil, pending), do: Backup.export(&IO.binwrite(:stdio, &1), include_pending: pending) + # Sobelow: the operator names the output file on the command line; there's no request. + # sobelow_skip ["Traversal.FileModule"] defp write(path, pending) do File.open!(path, [:write, :binary], fn f -> n = Backup.export(&IO.binwrite(f, &1), include_pending: pending) diff --git a/server/lib/mix/tasks/kotiko.import.ex b/server/lib/mix/tasks/kotiko.import.ex index af8a9d6..4445e24 100644 --- a/server/lib/mix/tasks/kotiko.import.ex +++ b/server/lib/mix/tasks/kotiko.import.ex @@ -18,6 +18,8 @@ defmodule Mix.Tasks.Kotiko.Import do @switches [env_file: :string, data_dir: :string] @impl true + # Sobelow: the operator names the backup file on the command line; there's no request. + # sobelow_skip ["Traversal.FileModule"] def run(args) do {opts, files} = OptionParser.parse!(args, strict: @switches) file = List.first(files) || Mix.raise("Name the backup file: mix kotiko.import FILE") diff --git a/server/mix.exs b/server/mix.exs index 28b7677..e4a291b 100644 --- a/server/mix.exs +++ b/server/mix.exs @@ -33,6 +33,8 @@ defmodule Kotiko.MixProject do [ {:bandit, "~> 1.5"}, {:plug, "~> 1.16"}, + # Plug.Crypto.non_executable_binary_to_term/2 for the lookup cache (comes with plug). + {:plug_crypto, "~> 2.0"}, {:jason, "~> 1.4"}, {:req, "~> 0.5"}, {:telemetry, "~> 1.0"}, @@ -40,6 +42,8 @@ defmodule Kotiko.MixProject do {:ecto_sqlite3, "~> 0.17"}, {:credo, "~> 1.7", only: [:dev, :test], runtime: false}, {:mix_audit, "~> 2.1", only: [:dev, :test], runtime: false}, + # Security-focused static analysis (docs/security/dependency-and-static-analysis-policy.md). + {:sobelow, "~> 0.16", only: [:dev, :test], runtime: false}, # Slice 30 §11: the CycloneDX SBOM attached to each release (mix sbom.cyclonedx). {:sbom, "~> 0.11", only: :dev, runtime: false}, {:stream_data, "~> 1.1", only: :test} diff --git a/server/mix.lock b/server/mix.lock index 1a85115..f1c537c 100644 --- a/server/mix.lock +++ b/server/mix.lock @@ -27,6 +27,7 @@ "purl": {:hex, :purl, "0.5.0", "67cc2a57142236ad4a93184e8db5b83e8285b4b7cf7ad4a67eeefcb61e3134f3", [:mix, :rebar3], [{:stream_data, "~> 1.1", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "658057133f7698b250821828185997279dba75f3991f5b570357cf3d73f64b4d"}, "req": {:hex, :req, "0.7.4", "23e9ffec17de032a46a4b15ed65c09793893bf4a7c680f4bbf6227fce6bdf74d", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "4b192d63253e8dcc6221ef992ea9ebef7d3555166e8423aa5b553e86bc3c69a2"}, "sbom": {:hex, :sbom, "0.11.0", "b36348696dc1a0a0a350ebf26de12b7508fb774e565e213cba95751fc73a5447", [:mix], [{:hex_core, "~> 0.19.0", [hex: :hex_core, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:optimus, "~> 0.6.1", [hex: :optimus, repo: "hexpm", optional: false]}, {:protobuf, "~> 0.17.0", [hex: :protobuf, repo: "hexpm", optional: false]}, {:purl, "~> 0.5.0", [hex: :purl, repo: "hexpm", optional: false]}], "hexpm", "edbd95e75155fed8fe61a19f21ea590c58178fe237c816afa534bc514ae53eb9"}, + "sobelow": {:hex, :sobelow, "0.16.0", "13e3f761bc7fcc88e81ae23ddef9140617a12a3efb26601e5516453290539398", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "00a0260edf9aeac35d9116366f55dc9451d11e0e696e28ec5570c6a56bd41f1e"}, "stream_data": {:hex, :stream_data, "1.4.0", "026f929db613aabea6208012ae9b8970d3fd5f88b3bdf26831bc536f98c42036", [:mix], [], "hexpm", "2b0ee3a340dcce1c8cf6302a763ee757d1e01c54d6e16d9069062509d68b1dc9"}, "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, "thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"}, diff --git a/server/test/kotiko/llm_test.exs b/server/test/kotiko/llm_test.exs index 528a35c..17465cd 100644 --- a/server/test/kotiko/llm_test.exs +++ b/server/test/kotiko/llm_test.exs @@ -461,6 +461,18 @@ defmodule Kotiko.LLMTest do assert Cache.count() == 0 end + test "a stored entry holding a function is a miss, never code to run" do + key = Cache.key(%{text: "da", mode: "auto", hint_lang: nil, recent: [], base_langs: ["en"]}) + at = Word.timestamp(Words.now()) + planted = %{words: [fn -> :ran end]} |> :erlang.term_to_binary() |> Base.encode64() + + Repo.insert_all("lookup_cache", [ + %{key: key, result: planted, model: "m1", inserted_at: at, last_hit_at: at, hits: 0} + ]) + + assert Cache.get(key) == :miss + end + test "identical lookups at the same time share one model call" do LLMStub.stub(fn _, conn -> LLMStub.slow(conn, 100, @da) end)