From 0288dd8b015cd507e4fcc03000d1ec65fc285d19 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Tue, 6 Oct 2026 15:56:34 -0400 Subject: [PATCH 1/6] fix(server): read the lookup cache without executable terms The lookup cache keeps each model answer as an Erlang term in the server's own database. Reading it back used binary_to_term with :safe, which stops new atoms but still accepts a function. A row changed on disk could then hand the server a function as part of an answer. The cache now reads entries with Plug.Crypto.non_executable_binary_to_term (and :safe), so such a row is a cache miss. plug_crypto already came with plug; it is now a direct dependency because the server calls it. A regression test plants a function in a cache row and expects a miss. Found by Sobelow (Misc.BinToTerm, high confidence). Nothing from the network reaches this call, so it was hardening, not an exploitable bug. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Ayla Croft --- server/lib/kotiko/llm/cache.ex | 4 +++- server/mix.exs | 2 ++ server/test/kotiko/llm_test.exs | 12 ++++++++++++ 3 files changed, 17 insertions(+), 1 deletion(-) 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/mix.exs b/server/mix.exs index 28b7677..3924548 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"}, 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) From 757713fb001d8135f3c9f2b15b2ccb4001eae56b Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Tue, 6 Oct 2026 15:56:41 -0400 Subject: [PATCH 2/6] ci(server): run Sobelow on every pull request Sobelow is a security analyser for Elixir. It looks for SQL and command injection, path traversal, unsafe binary_to_term, atom exhaustion and insecure settings. It now runs in CI's server job (newest Elixir) and fails on any finding, at any confidence. Kotiko is a plain Plug app, so server/.sobelow-conf sets router: :none: the Phoenix router checks have nothing to read, and the other checks still run. It also scans the mix tasks, migrations and scripts, fails if a file can't be parsed, and makes no network requests. The first scan found one real issue, fixed in the previous commit, and 23 low-confidence path traversal findings in 17 functions. Every one is a file call whose path comes from the server's own settings (the data folder, the token file, backups, the model list cache) or from an operator's command line (mix kotiko.import, mix kotiko.export, the .env file). None takes a path from a request. Each of those functions now carries a sobelow_skip comment under a line saying why, so a new file call anywhere else still fails CI. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Ayla Croft --- .github/workflows/ci.yml | 3 +++ server/.sobelow-conf | 29 +++++++++++++++++++++++++++ server/lib/kotiko/config.ex | 2 ++ server/lib/kotiko/data_dir.ex | 14 +++++++++++++ server/lib/kotiko/llm/catalog.ex | 4 ++++ server/lib/kotiko/migrations.ex | 4 ++++ server/lib/kotiko/token.ex | 4 ++++ server/lib/mix/kotiko_task_env.ex | 2 ++ server/lib/mix/tasks/kotiko.export.ex | 2 ++ server/lib/mix/tasks/kotiko.import.ex | 2 ++ server/mix.exs | 2 ++ server/mix.lock | 1 + 12 files changed, 69 insertions(+) create mode 100644 server/.sobelow-conf 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/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/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 3924548..e4a291b 100644 --- a/server/mix.exs +++ b/server/mix.exs @@ -42,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"}, From a2a1fe4d1c083afca4e7cd555ebd5aa05f6f500e Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Tue, 6 Oct 2026 15:57:13 -0400 Subject: [PATCH 3/6] ci: scan every lockfile for known vulnerabilities, also before a release OSV-Scanner checks the three lockfiles (the dev tools, the docs site and the server's mix.lock) against the OSV database, which also lists malicious packages. It runs on every pull request and push to main, every Monday, and from the release workflow: a release isn't built when the scan fails. Any known vulnerability fails it, at any severity, unless osv-scanner.toml accepts it with a reason and an expiry date. OSV-Scanner over npm audit: one tool covers both npm lockfiles and mix.lock, it needs no node_modules, and accepted findings expire on their own. The dependency review action only sees what a pull request changes, so it can't check a release. The binary is a pinned release checked against its SHA-256, like gitleaks. The scan is a reusable workflow, so the release runs the same check with the same pin. mix deps.audit and mix hex.audit stay in CI. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Ayla Croft --- .github/workflows/dependency-scan.yml | 69 +++++++++++++++++++++++++++ .github/workflows/release.yml | 19 ++++++-- osv-scanner.toml | 15 ++++++ 3 files changed, 99 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/dependency-scan.yml create mode 100644 osv-scanner.toml 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/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. From 0c72e01b2f8bbf22c34df024ebe82bd2225a81b0 Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Tue, 6 Oct 2026 15:57:13 -0400 Subject: [PATCH 4/6] ci(deps): Dependabot updates the docs site's npm packages too The site has its own package.json and lockfile in site/, which Dependabot didn't watch. It now opens weekly updates for them, grouped like the others. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Ayla Croft --- .github/dependabot.yml | 8 ++++++++ 1 file changed, 8 insertions(+) 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 } From 70c79c7aa2e6764ec74d20fad27d2388d9b00fbe Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Tue, 6 Oct 2026 15:57:13 -0400 Subject: [PATCH 5/6] ci: run CodeQL on JavaScript and the workflows CodeQL, GitHub's security analyser, now checks the JavaScript (the extension, scripts, tests and docs site) and the GitHub Actions workflows (script injection, untrusted checkouts, token permissions) with the security-extended queries. It runs on every pull request and push to main, and every Monday. Results go to the Security tab and the "Code scanning results" check. Both languages are analysed from source, so there is nothing to build. The action is pinned by commit SHA. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Ayla Croft --- .github/workflows/codeql.yml | 50 ++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 .github/workflows/codeql.yml 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 }} From f902d274e576c052544ee269d00055ecc308f0db Mon Sep 17 00:00:00 2001 From: Ayla Croft Date: Tue, 6 Oct 2026 15:57:14 -0400 Subject: [PATCH 6/6] docs(security): dependency and static analysis policy What the dependency scans and the security analysers check, what blocks a merge or a release, how fast a finding is fixed (the same targets as a reported vulnerability), how a finding that can't be exploited is accepted and where that is recorded, and the licenses a runtime dependency may have. SECURITY.md and CONTRIBUTING.md link to it. The assurance case now names OSV-Scanner and Sobelow, and the lookup cache's safer decoding. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Ayla Croft --- CONTRIBUTING.md | 4 + SECURITY.md | 6 + docs/security/assurance-case.md | 8 +- .../dependency-and-static-analysis-policy.md | 129 ++++++++++++++++++ 4 files changed, 143 insertions(+), 4 deletions(-) create mode 100644 docs/security/dependency-and-static-analysis-policy.md 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`.