Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
50 changes: 50 additions & 0 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
@@ -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 }}
69 changes: 69 additions & 0 deletions .github/workflows/dependency-scan.yml
Original file line number Diff line number Diff line change
@@ -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"
19 changes: 15 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 4 additions & 4 deletions docs/security/assurance-case.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
Loading