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
6 changes: 3 additions & 3 deletions .github/allowed_signers
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@
# v* tag against the copy of this file on main; a tag signed by any other key, or not signed,
# stops the release before anything is built. GPG keys go in .github/release-keys.asc instead.
#
# One line per key, the same keys as in MAINTAINERS.md. Adding or removing a key is a reviewed
# pull request. Format (see "ALLOWED SIGNERS" in `man ssh-keygen`):
# One line per key, the same keys as in MAINTAINERS.md. A key is added or removed by pull
# request; a second maintainer's review isn't enforced yet (docs/security/assurance-case.md,
# residual risks). Format (see "ALLOWED SIGNERS" in `man ssh-keygen`):
#
# you@example.com namespaces="git" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA...
#

# Ayla Croft (@HackTuah), release manager: SHA256:HqIaFq/thUm844NjYC3a+4Br40cqD2yJHEq1L2iiw4E
dev@scriptkittyos.com namespaces="git" ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAiWNJRQYwtCxb7uEMB5i1XdR6k9Xd+hF6+kMQa4nQ3j dev@scriptkittyos.com
39 changes: 36 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ jobs:
# .env, database, key or oversized file in the tree or a pull request's commits (GitHub's
# push rulesets work only on private repositories), and every commit a pull request adds
# is signed off (the Developer Certificate of Origin, CONTRIBUTING.md).
#
# On a pull request, gitleaks' settings and the check scripts come from the base commit,
# so a pull request can't switch off its own checks (security review C-01): its edits to
# them take effect once it is merged. On main they come from the tree.
secrets:
runs-on: ubuntu-24.04
env:
Expand All @@ -36,21 +40,50 @@ jobs:
"https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz"
echo "${GITLEAKS_SHA256} $RUNNER_TEMP/gitleaks.tar.gz" | sha256sum -c -
tar -xzf "$RUNNER_TEMP/gitleaks.tar.gz" -C "$RUNNER_TEMP" gitleaks
# Security review C-01's regression test, with this gitleaks: a pull request that adds a
# token and allows it in its own .gitleaks.toml and .gitleaksignore still fails.
- name: A pull request can't switch off its own scan (test)
env:
GITLEAKS: ${{ runner.temp }}/gitleaks
run: node --test test/unit/pr-checks-base.test.mjs
- name: The checks' settings and scripts, from the base commit on a pull request
env:
BASE: ${{ github.event.pull_request.base.sha }}
run: |
ref="${BASE:-HEAD}"
git cat-file -e "$ref^{commit}"
# The base's own copy of the helper. Only the pull request that adds it has none.
if git cat-file -e "$ref:scripts/base-file.sh" 2>/dev/null; then
git show "$ref:scripts/base-file.sh" > "$RUNNER_TEMP/base-file.sh"
else
echo "::warning title=No base-file.sh on the base::Using this tree's copy; only the pull request that adds it should see this."
cp scripts/base-file.sh "$RUNNER_TEMP/base-file.sh"
fi
bash "$RUNNER_TEMP/base-file.sh" "$ref" "$RUNNER_TEMP/trusted" \
.gitleaks.toml .gitleaksignore scripts/check-forbidden-files.mjs scripts/check-dco.mjs
# gitleaks also reads the .gitleaksignore of the folder it scans, whatever
# --gitleaks-ignore-path says, so the tree's copies go.
rm -f .gitleaks.toml .gitleaksignore
- name: Scan the full history
# HEAD's full history (all of main plus this pull request's commits). Not --all: the
# checkout fetches every branch, and another branch's finding would fail this one.
run: '"$RUNNER_TEMP/gitleaks" git --log-opts="HEAD" --redact --config .gitleaks.toml --exit-code 1 .'
# Inline "gitleaks:allow" comments don't count: a pull request could add one.
run: >-
"$RUNNER_TEMP/gitleaks" git --log-opts="HEAD" --redact --exit-code 1
--config "$RUNNER_TEMP/trusted/.gitleaks.toml" --gitleaks-ignore-path "$RUNNER_TEMP/trusted/.gitleaksignore" --ignore-gitleaks-allow
.
- name: No secret, database or oversized files
env:
BASE: ${{ github.event.pull_request.base.sha }}
HEAD: ${{ github.event.pull_request.head.sha }}
run: node scripts/check-forbidden-files.mjs $BASE $HEAD
# Without a pull request (a push to main), the tree only.
run: node "$RUNNER_TEMP/trusted/scripts/check-forbidden-files.mjs" ${BASE:+"$BASE" "$HEAD"}
- name: Every commit is signed off (DCO)
if: github.event_name == 'pull_request'
env:
BASE: ${{ github.event.pull_request.base.sha }}
HEAD: ${{ github.event.pull_request.head.sha }}
run: node scripts/check-dco.mjs "$BASE" "$HEAD"
run: node "$RUNNER_TEMP/trusted/scripts/check-dco.mjs" "$BASE" "$HEAD"

server:
name: server (${{ matrix.elixir }})
Expand Down
21 changes: 20 additions & 1 deletion .github/workflows/dependency-scan.yml
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,26 @@ jobs:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0 # the base commit, for its osv-scanner.toml
persist-credentials: false
# On a pull request, the accepted findings come from the base commit's osv-scanner.toml,
# so a pull request can't accept its own vulnerable dependency (security review C-01);
# its edits to the file take effect once it is merged. Elsewhere (main, the weekly run,
# a release) they come from the tree.
- name: The scan's settings, from the base commit on a pull request
env:
BASE: ${{ github.event.pull_request.base.sha }}
run: |
ref="${BASE:-HEAD}"
git cat-file -e "$ref^{commit}"
# The base's own copy of the helper. Only the pull request that adds it has none.
if git cat-file -e "$ref:scripts/base-file.sh" 2>/dev/null; then
git show "$ref:scripts/base-file.sh" > "$RUNNER_TEMP/base-file.sh"
else
echo "::warning title=No base-file.sh on the base::Using this tree's copy; only the pull request that adds it should see this."
cp scripts/base-file.sh "$RUNNER_TEMP/base-file.sh"
fi
bash "$RUNNER_TEMP/base-file.sh" "$ref" "$RUNNER_TEMP/trusted" osv-scanner.toml
- name: Install OSV-Scanner
run: |
curl -sSfL -o "$RUNNER_TEMP/osv-scanner" \
Expand All @@ -52,7 +71,7 @@ jobs:
- name: Scan the lockfiles
run: |
set +e
"$RUNNER_TEMP/osv-scanner" scan source --config osv-scanner.toml \
"$RUNNER_TEMP/osv-scanner" scan source --config "$RUNNER_TEMP/trusted/osv-scanner.toml" \
--lockfile package-lock.json \
--lockfile site/package-lock.json \
--lockfile server/mix.lock \
Expand Down
50 changes: 35 additions & 15 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ jobs:
outputs:
version: ${{ steps.verify.outputs.version }}
prerelease: ${{ steps.verify.outputs.prerelease }}
commit: ${{ steps.verify.outputs.commit }}
steps:
# The checking script and the allowed signers come from main, never from the tag being
# checked, so a tag can't vouch for itself.
Expand Down Expand Up @@ -93,12 +94,30 @@ jobs:
contents: read
outputs:
version: ${{ steps.version.outputs.version }}
env:
# Fixed seeds for the property tests, so a release's tests are the same on every run and
# on a rerun (security review C-08). CI keeps a new seed on every pull request and push
# to main, so it keeps exploring; what fails there is fixed before it reaches a release.
FC_SEED: "20261006"
MIX_TEST_SEED: "20261006"
steps:
# The tag on a tag push; the chosen branch on a manual run. No caches in this workflow,
# so nothing a pull request wrote can reach a release.
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# What is built is the commit verify-tag checked, nothing else (security review C-02).
- name: The checked-out commit is the verified one
if: github.event_name == 'push'
env:
VERIFIED: ${{ needs.verify-tag.outputs.commit }}
run: |
head="$(git rev-parse HEAD)"
if [ -z "$VERIFIED" ] || [ "$head" != "$VERIFIED" ]; then
echo "::error title=Not the verified commit::verify-tag checked ${VERIFIED:-no commit}, but the checkout is $head."
exit 1
fi
echo "Building $head, the commit verify-tag checked."
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: 22
Expand All @@ -118,7 +137,8 @@ jobs:
- run: npm ci
# The same checks as CI, on the tree being released: the extension's and the server's
# tests with their coverage gates. The property tests run in both, so the dynamic
# analysis runs before every release.
# analysis runs before every release, with the fixed seeds above (StreamData takes
# ExUnit's --seed).
- run: npm run lint
- run: npm run coverage
- name: Server tests
Expand All @@ -127,7 +147,7 @@ jobs:
MIX_ENV: test
run: |
mix deps.get
mix test --cover
mix test --cover --seed "$MIX_TEST_SEED"
- name: Build the zips twice and compare
env:
VERSION: ${{ steps.version.outputs.version }}
Expand Down Expand Up @@ -247,7 +267,11 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- run: node scripts/check-security-gate.mjs
# The lead's exact "Gate: closed" line (slice 54 §6); a review opened for this version
# must be the closed one.
- env:
VERSION: ${{ needs.verify-tag.outputs.version }}
run: node scripts/check-security-gate.mjs --version "$VERSION"

publish-chrome:
needs: [verify-tag, store-gate]
Expand Down Expand Up @@ -354,20 +378,16 @@ jobs:
with:
name: release-files
path: dist
- if: steps.creds.outputs.ready == 'true'
name: Check the zip and unpack it
env:
VERSION: ${{ needs.verify-tag.outputs.version }}
run: |
(cd dist && sha256sum -c SHA256SUMS)
unzip -q "dist/kotiko-firefox-$VERSION.zip" -d "$RUNNER_TEMP/firefox"
# Listed on addons.mozilla.org: submit and don't wait for review. AMO signs the version
# after review and sends it to users itself.
# after review and sends it to users itself. The file submitted is the release's own
# zip, checked against SHA256SUMS first, not a zip web-ext builds again (security review
# C-06); scripts/amo-submit.mjs explains.
- if: steps.creds.outputs.ready == 'true'
name: Submit to Firefox Add-ons
name: Submit the release zip to Firefox Add-ons
env:
WEB_EXT_API_KEY: ${{ secrets.AMO_JWT_ISSUER }}
WEB_EXT_API_SECRET: ${{ secrets.AMO_JWT_SECRET }}
run: >-
npx --no-install web-ext sign --no-config-discovery --channel listed
--source-dir "$RUNNER_TEMP/firefox" --artifacts-dir "$RUNNER_TEMP/signed" --approval-timeout 0
VERSION: ${{ needs.verify-tag.outputs.version }}
run: |
(cd dist && sha256sum -c SHA256SUMS)
node scripts/amo-submit.mjs --zip "dist/kotiko-firefox-$VERSION.zip" --sums dist/SHA256SUMS
2 changes: 1 addition & 1 deletion SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## Supported versions

Kotiko is before 1.0. Only the latest minor release gets security fixes.
Only the latest release gets security fixes: from 1.0.0 on (the first store release, still pending), the latest 1.x. Release candidates (`vX.Y.Z-rc.N` pre-releases, for review) and versions older than 1.0.0 get none.

## Reporting a vulnerability

Expand Down
7 changes: 5 additions & 2 deletions docs/reproducible-builds.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
# Reproducible builds

Anyone can rebuild a release's extension zips from its tag and get the same bytes as the
files on the GitHub release and in the stores. The release workflow checks this on every
release by building twice and comparing; this page is how you check it yourself.
zips on the GitHub release, which are the files the release workflow uploads to the Chrome
Web Store and Firefox Add-ons. (The stores then sign what they distribute, which adds their
signature to the same files: [verify.md](verify.md#3-the-store-versions).) The release
workflow checks this on every release by building twice and comparing; this page is how you
check it yourself.

## Rebuild a release

Expand Down
14 changes: 11 additions & 3 deletions docs/security/assurance-case.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ its argument and evidence.
| 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 | Every file the server keeps there is 0600 (the database is created private before SQLite opens it, so `-wal` and `-shm` are too) and a folder it makes 0700; each start re-checks them; `run.sh` and the systemd unit use umask 077 |
| 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)) |
| A malicious pull request | The code base | Required CI checks before merging (the `main` ruleset), with a read-only token; on a pull request the secret and dependency scans take their allowlists and check scripts from the base commit, so it can't switch them off ([`base-file.sh`](../../scripts/base-file.sh)). Review by a maintainer who isn't the author is the aim but not enforced (section 7) |

## 3. Trust boundaries

Expand Down Expand Up @@ -100,7 +100,7 @@ its argument and evidence.
| Server or extension to model provider | The learner's typed text, the key | HTTPS to an address the owner set; answers parsed as JSON and checked by the word spec; deadline and attempt cap per lookup | [`llm.ex`](../../server/lib/kotiko/llm.ex) `interpret/3`; [`spec/models.json`](../../spec/models.json) budgets; [`lib/llm/client.js`](../../extension/lib/llm/client.js) |
| Server to Telegram | Updates in, messages out | HTTPS; sender allowlist | [`bot.ex`](../../server/lib/kotiko/bot.ex); [`telegram.ex`](../../server/lib/kotiko/telegram.ex) |
| Server to file system | The database, token, backups | `kotiko.db` and its `-wal`/`-shm`, `api-token`, `models-cache.json` 0600; `backups/` 0700 with 0600 files; a data folder the server makes 0700, one that holds only Kotiko's files made 0700 at start (otherwise a warning); umask 077 in `run.sh` and the unit (`UMask=0077`) | [`private.ex`](../../server/lib/kotiko/private.ex); [`data_dir.ex`](../../server/lib/kotiko/data_dir.ex) `make_private/1`; [`migrations.ex`](../../server/lib/kotiko/migrations.ex) `backup!/2`; [`data_dir_test.exs`](../../server/test/kotiko/data_dir_test.exs); [`boot_test.exs`](../../server/test/kotiko/boot_test.exs) |
| CI to stores | Release packages | The store jobs run in the `release` environment: a maintainer approves each run, and only `v*` tags (signed, verified against `.github/allowed_signers`) may deploy. The maintainer creates the environment before the first release | [slice 30](../../slices/30-release-pipeline/SPEC.md) |
| CI to stores | Release packages | The store jobs run in the `release` environment (created 2026-10-06): its required reviewer, the maintainer, approves each run, and only `v*` tags may deploy. A tag is built only if it is signed by a key in `.github/allowed_signers` on `main`, under its own name, and only the release manager can create one. The stores get the release's own zips, checked against `SHA256SUMS` | [slice 30](../../slices/30-release-pipeline/SPEC.md); [`release.yml`](../../.github/workflows/release.yml); [`verify-tag.sh`](../../scripts/verify-tag.sh) |

## 4. Secure design principles

Expand Down Expand Up @@ -139,7 +139,7 @@ a small JSON API.
| 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, 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) |
| 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; required CI checks on every pull request, though not a second person's review ([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 Expand Up @@ -195,6 +195,14 @@ These match "What you can't expect" in the [requirements](requirements.md).
- **No rate limit** for requests that carry the token.
- **Response hardening headers** (`X-Content-Type-Options` and similar) are not sent yet;
only `/health` sends `cache-control: no-store`. Planned (slice 01 section 7).
- **No enforced second review.** The `main` ruleset requires the CI checks but no
approving review, so one maintainer account (or a stolen token with write access) can
merge its own pull request, including changes to `.github/allowed_signers` and the
workflows. Accepted while one maintainer does nearly all the work
([CODE_REVIEW.md](../CODE_REVIEW.md), step 2). The release tag and the store upload still
need the release manager: only they can create a `v*` tag, and the `release`
environment's reviewer approves each upload. A pull request can also change a workflow
itself, and that change runs on the pull request; only review catches it.
- **No releases yet**, so there are no signatures or attestations to verify; users run
from source. The release pipeline that signs, attests and builds reproducibly is in place
(slice 30); the first release is v1.0.0.
Expand Down
4 changes: 3 additions & 1 deletion docs/security/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,9 @@ Kotiko has two parts: the browser extension, and an optional server you run your
These are goals in the plan. They move to "What you can expect" when the slice ships.

- Signed, attested release packages and store-reviewed builds: slice 30 (release
pipeline). There are no releases yet; today you run Kotiko from source.
pipeline). The pipeline is in place, and release candidates (`v1.0.0-rc.N`) are tagged
for the pre-release security review (slice 54); the first store release, v1.0.0, is
pending. Until it is out, you run Kotiko from source.
- Hardening headers on every server response: slice 01 (addition from slice 53). (The
extension's pages already have an explicit content security policy, `manifest.json`.)
- A permission review of the extension against the store's rules: slice 28.
Expand Down
Loading
Loading