8000 ci(threat-model): check the threat model's prose, YAML and JSON agree by adeelahmad · Pull Request #207 · adeelahmad/lense · GitHub
[go: up one dir, main page]

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
28 changes: 28 additions & 0 deletions .github/workflows/threat-model.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: threat model

# Checks that threat-model.md (canonical), threat-model.yaml and threat-model.json agree: the YAML's prose hash, the
# property ids in all three, and the open-question numbers. See ci/validate_threat_model.py.
on:
push:
branches: [main]
paths: [threat-model.md, threat-model.yaml, threat-model.json, ci/validate_threat_model.py, .github/workflows/threat-model.yml]
pull_request:
paths: [threat-model.md, threat-model.yaml, threat-model.json, ci/validate_threat_model.py, .github/workflows/threat-model.yml]
workflow_dispatch:

concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

permissions:
contents: read

jobs:
validate:
runs-on: ${{ fromJSON(vars.CI_RUNNER || '"ubuntu-latest"') }}
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v7
- name: Validate the threat model
run: uv run --no-project --python 3.12 --with pyyaml python ci/validate_threat_model.py
2 changes: 1 addition & 1 deletion DEVLOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ Todo:
the plugin's validator was not available in the session that made this edit, so re-run it)

Watch out: the prose is canonical. After any edit to `threat-model.md`, regenerate the YAML and JSON (its hash is in
the YAML) and re-run the plugin's `validate_model.py`.
the YAML) and run `ci/validate_threat_model.py` (CI runs it on every change to these files).

## 2026-10-04 · Graph history: versions, replay, rollback

Expand Down
190 changes: 190 additions & 0 deletions ci/validate_threat_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
#!/usr/bin/env python3
"""Check that threat-model.md, threat-model.yaml and threat-model.json agree.

The prose (threat-model.md) is canonical; the YAML and JSON are derived from it. This script is the repo's own
stand-in for the threat-model plugin's validator, which lives outside the repo and so can't run in CI. It checks:

- the YAML's prose_version is the sha256 of threat-model.md;
- both companions parse and carry their required top-level keys;
- the YAML and JSON name the same repository, commit and date;
- the claimed (P-), disclaimed (N-) and known-non-finding (KNF-) ids are the same set in all three files, and the
matrix-derived (MX-) ids are the same in both companions;
- every P-, N-, KNF- or MX- id mentioned anywhere is one of those defined ids;
- the open questions are numbered Q1..Qn in the JSON, and every Qn cited anywhere is one of them.

Usage: python ci/validate_threat_model.py [repo_root]. Exits 1 and lists every problem when anything disagrees.
"""

from __future__ import annotations

import hashlib
import json
import re
import sys
from pathlib import Path

import yaml

ID_RE = re.compile(
r"(?<![A-Za-z0-9_-])(P|N|KNF|MX)-[A-Z][A-Z0-9]*(?:-[A-Z0-9]+)*(?![A-Za-z0-9_-])"
)
Q_RE = re.compile(r"(?<![A-Za-z0-9_-])Q(\d+)(?![0-9])")
ROW_RE = re.compile(r"^\|\s*((?:P|N|KNF)-[A-Z0-9-]+)\s*\|", re.MULTILINE)

YAML_KEYS = (
"schema prose_version components entry_points adversaries properties_claimed properties_disclaimed "
"downstream_responsibilities known_misuses known_non_findings dispositions"
).split()
JSON_KEYS = (
"spec_version repository commit date components entry_points adversaries properties_provided "
"properties_not_provided downstream_responsibilities known_misuse known_non_findings dispositions open_questions"
).split()


def by_kind(ids: set[str]) -> dict[str, set[str]]:
out: dict[str, set[str]] = {"P": set(), "N": set(), "KNF": set(), "MX": set()}
for i in ids:
out[i.split("-", 1)[0]].add(i)
return out


def validate(root: Path) -> list[str]:
errors: list[str] = []
prose_raw = (root / "threat-model.md").read_bytes()
prose = prose_raw.decode()
10BC8 yaml_text = (root / "threat-model.yaml").read_text()
json_text = (root / "threat-model.json").read_text()
try:
y = yaml.safe_load(yaml_text)
except yaml.YAMLError as e:
return [f"threat-model.yaml does not parse: {e}"]
try:
j = json.loads(json_text)
except json.JSONDecodeError as e:
return [f"threat-model.json does not parse: {e}"]

# Prose hash.
want = "threat-model.md@sha256:" + hashlib.sha256(prose_raw).hexdigest()
if y.get("prose_version") != want:
errors.append(
f"threat-model.yaml prose_version is {y.get('prose_version')!r}; the prose hashes to {want!r}"
)

# Shape.
if y.get("schema") != "threat-model-sidecar/v2":
errors.append(
f"threat-model.yaml schema is {y.get('schema')!r}, expected 'threat-model-sidecar/v2'"
)
if j.get("spec_version") != 1:
errors.append(
f"threat-model.json spec_version is {j.get('spec_version')!r}, expected 1"
)
errors += [f"threat-model.yaml is missing {k}" for k in YAML_KEYS if k not in y]
errors += [f"threat-model.json is missing {k}" for k in JSON_KEYS if k not in j]
if errors:
return errors

# Same snapshot.
for jk, yk in (
("repository", "x-repository"),
("commit", "x-modeled-commit"),
("date", "x-model-date"),
):
if str(j[jk]) != str(y.get(yk)):
errors.append(
f"threat-model.json {jk} {j[jk]!r} differs from threat-model.yaml {yk} {y.get(yk)!r}"
)

# Defined ids, per file.
defined = {
"threat-model.md": by_kind(set(ROW_RE.findall(prose))),
"threat-model.yaml": by_kind(
{p["id"] for p in y["properties_claimed"]}
| {p["id"] for p in y["properties_disclaimed"]}
| {k["id"] for k in y["known_non_findings"]}
),
"threat-model.json": by_kind(
{p["property"] for p in j["properties_provided"]}
| {p["property"] for p in j["properties_not_provided"]}
),
}
canon = defined["threat-model.md"]
for name, ids in defined.items():
for kind in ("P", "N", "KNF"):
if name == "threat-model.json" and kind == "KNF":
continue # JSON known_non_findings carry no ids
for missing in sorted(canon[kind] - ids[kind]):
errors.append(
f"{missing} is defined in threat-model.md but not in {name}"
)
for extra in sorted(ids[kind] - canon[kind]):
errors.append(
f"{extra} is defined in {name} but not in threat-model.md"
)
# MX- records come from the §1.7 contract-dimension matrix, so only the companions define them.
if defined["threat-model.yaml"]["MX"] != defined["threat-model.json"]["MX"]:
errors.append(
f"MX- ids differ: threat-model.yaml has {sorted(defined['threat-model.yaml']['MX'])}, "
f"threat-model.json has {sorted(defined['threat-model.json']['MX'])}"
)
if len(j["known_non_findings"]) != len(canon["KNF"]):
errors.append(
f"threat-model.json has {len(j['known_non_findings'])} known_non_findings; "
f"threat-model.md defines {len(canon['KNF'])}"
)

# Every id mentioned is defined.
all_defined = set().union(*canon.values(), defined["threat-model.yaml"]["MX"])
for name, text in (
("threat-model.md", prose),
("threat-model.yaml", yaml_text),
("threat-model.json", json_text),
):
for undefined in sorted(
{m.group(0) for m in ID_RE.finditer(text)} - all_defined
):
line = text[: text.index(undefined)].count("\n") + 1
errors.append(
f"{name}:{line} mentions {undefined}, which no table in threat-model.md defines"
)

# Open questions.
claims = [q.get("claim", "") for q in j["open_questions"]]
for n, claim in enumerate(claims, 1):
if not claim.startswith(f"Q{n}:"):
errors.append(
f"threat-model.json open_questions[{n - 1}] should start with 'Q{n}:'"
)
count = len(claims)
for name, text in (
("threat-model.md", prose),
("threat-model.yaml", yaml_text),
("threat-model.json", json_text),
):
for m in Q_RE.finditer(text):
if not 1 <= int(m.group(1)) <= count:
line = text[: m.start()].count("\n") + 1
errors.append(
f"{name}:{line} cites {m.group(0)}; there are {count} open questions"
)
return errors


def main() -> int:
root = Path(
sys.argv[1] if len(sys.argv) > 1 else Path(__file__).resolve().parent.parent
)
errors = validate(root)
for e in errors:
print(f"error: {e}")
if errors:
print(
f"\n{len(errors)} problem(s). The prose is canonical: fix the YAML and JSON to match it."
)
return 1
print("threat model: prose, YAML and JSON agree")
return 0


if __name__ == "__main__":
sys.exit(main())
12 changes: 10 additions & 2 deletions threat-model.json
Original file line number Diff line number Diff line change
Expand Up @@ -942,6 +942,14 @@
"provenance": "documented",
"source": "docs/configuration.md \"Neither may reach anything\""
},
{
"property": "P-CONVERT-TIMEOUT",
"violation_symptom": "hang",
"severity_tier": "correctness",
"conditions": "every document conversion and capture ends within documents.convert_seconds and leaves no process of its group running; ffmpeg decoding (6 h), antiword, catdoc and pdftotext reads (300 s) and ffmpeg encoding for a speech service (600 s) end at their timeouts",
"provenance": "documented",
"source": "maintainer ruling, 2026-10-06 (Q20)"
},
{
"property": "P-TEMPLATE",
"violation_symptom": "integrity-bypass",
Expand Down Expand Up @@ -1217,14 +1225,14 @@
},
{
"property": "N-DECODERS",
"reason": "sandboxing or memory-safety for ffmpeg, poppler, antiword and similar decoders; memory bugs inside them route to §1.9; does not cover Lens's own process control (timeouts, killing children): that is the unresolved x-process-control row",
"reason": "sandboxing or memory-safety for ffmpeg, poppler, antiword and similar decoders; memory bugs inside them route to §1.9; does not cover Lens's own process control (timeouts, killing children): that is P-CONVERT-TIMEOUT",
"false_friend": false,
"provenance": "inferred",
"source": "open question Q20"
},
{
"property": "N-BOMBS",
"reason": "bounds on decompressed size, nested emails or media duration (docx XML, email attachments, long media); does not cover a conversion or capture outliving Lens's own timeout (x-process-control, Q20)",
"reason": "bounds on decompressed size, nested emails or media duration (docx XML, email attachments, long media); does not cover a conversion or capture outliving Lens's own timeout (P-CONVERT-TIMEOUT)",
"false_friend": false,
"provenance": "inferred",
"source": "open question Q22"
Expand Down
7 changes: 4 additions & 3 deletions threat-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,7 @@ Every row is claimed, disclaimed, N/A with a reason, or unresolved. Family short
| Ingest | Numeric domain: duration, decompressed size; recursion | unresolved | no duration cap; docx XML read whole; emails inside emails, no stated depth cap | Q22 | *(inferred, Q22)* |
| Ingest | Failure atomicity | claimed | an upload file holds whole chunks only | P-UPLOAD | *(documented, domain/uploads.py docstring "whole chunks")* |
| Ingest | Callback / collaborator execution | unresolved | decoders run in the worker's process tree, unsandboxed | Q20 | *(inferred, Q20)* |
| Ingest | x-process-control | unresolved | a conversion or capture outliving its timeout, or children left alive after a kill | Q20 | *(inferred, Q20)* |
| Ingest | x-process-control | claimed | a conversion or capture outliving its timeout, or children left alive after a kill | P-CONVERT-TIMEOUT | *(maintainer, 2026-10-06, Q20)* |
| Ingest | Serialization / reconstruction | claimed | nothing uploaded is executed; served as audio, video or download | P-UPLOAD | *(documented, docs/configuration.md "uploaded is executed")* |
| Ingest | Reference lifecycle | unresolved | temp dirs and process groups cleaned after conversion, best effort | Q34 | *(inferred, Q34)* |
| Ingest | Concurrency / reentrancy | unresolved | jobs run at least once; a retried step may repeat side effects | Q12 | *(inferred, Q12)* |
Expand Down Expand Up @@ -491,6 +491,7 @@ Paths in the Voided by column are relative to `fastapi_backend/app/`. Searches c
| P-NO-CODE | No non-admin can run code on the host. This covers owners, editors, viewers, API tokens, MCP agents, OAuth apps and model tool calls acting for one. Python tools are written by admins only and run only while an admin owns them. | integrity-bypass | security-critical | *(maintainer, 2026-10)* | Voided by: Making someone admin; authoring gate — `domain/extensions.py:129-130`; run gate — `domain/extensions.py:600-601`. `grep -rnE "eval\(\|exec\(\|pickle\|yaml\.load\(\|__import__" fastapi_backend/app` — one `exec`, inside the code-tool child. |
| P-UPLOAD | Uploads accept only the types in `uploads.extensions` and are size-capped. Nothing uploaded is executed. Files are served as audio, video or a download, and names carry no folders. | bad-data-accepted | security-critical | *(documented, docs/configuration.md "Uploads accept only the types")* | Voided by: Admin-added types; check — `domain/uploads.py:157-158`. The `attachments` role takes any type (never executed). Bytes are not checked against the extension. |
| P-CONVERT-OFFLINE | Chromium and LibreOffice reach nothing while converting. HTML is cleaned of anything that could fetch or run. | info-leak | security-critical | *(documented, docs/configuration.md "Neither may reach anything")* | Voided by: Chromium without sandbox — `domain/convert.py:273`; operator-set `documents.chromium` or `soffice` binaries. |
| P-CONVERT-TIMEOUT | Every document conversion and capture ends within `documents.convert_seconds` and leaves no process of its group running; ffmpeg decoding (6 h), antiword, catdoc and pdftotext reads (300 s) and ffmpeg encoding for a speech service (600 s) end at their timeouts. | hang | correctness-only | *(maintainer, 2026-10-06, Q20)* | Voided by: children that ffmpeg or a reader starts itself (only converters run in their own process group) — `domain/ingest.py:794`, `domain/speech.py:183`. |
| P-TEMPLATE | Templates render in a sandboxed Jinja environment. They cannot reach Python internals, output is capped, and reports escape HTML. | integrity-bypass | security-critical | *(documented, docs/processing.md "sandboxed Jinja environment")* | Voided by: Shipped report templates use an unsandboxed environment over server-built values — `domain/render.py:22`. `grep -rn 'Environment(\\|from_string' fastapi_backend/app` — hits in `domain/render.py` and `domain/templates.py`. |
| P-ENCRYPTION | With `encryption.files` on, files are encrypted per namespace. A file cut short, edited or reordered fails to open. A vault has no recovery code, password or admin override, and separate workers never get its key. | info-leak, integrity-bypass | security-critical | *(documented, docs/encryption.md "There is no recovery code, password or admin override")* | Voided by: `encryption.files` off — `domain/keyring.py:545`; shipped default off (`domain/store.py:402`) until a new archive's first start or `lens encrypt`. Unlocked vault keys stay in the API for `encryption.vault_minutes`. `secret.key` placement (Q14). |
| P-TELEMETRY | Telemetry is off unless turned on, has no built-in destination, and never sends content, names, addresses or error messages. | info-leak | security-critical | *(documented, docs/telemetry.md "Never sent")* | Voided by: none identified (not required for this symptom; no exhaustive search) |
Expand Down Expand Up @@ -543,8 +544,8 @@ Exported by phase 3.6 (backtest) and de-identified. Closing rows carry their sta
| N-HTTP-SETUP | Passkey protection on plain HTTP. There the code alone makes the admin and a link alone signs in | Plain HTTP is supported on one machine only | security-critical | no | *(documented, docs/authentication.md "the code alone makes the admin")* |
| N-OAUTH-CLIENT | Checking the app's `state` or guarding its redirect after the code arrives; registration is open, consent is the gate | the app's side only. Lens's redirect-URI, PKCE and consent checks are P-OAUTH | correctness-only | no | *(documented, docs/authentication.md "Apps register themselves")* |
| N-CHROMIUM | Chromium's own sandbox in containers or as root; the `netguard` proxy is what stops a page | Conversion and capture | security-critical | yes: "headless browser" is not isolation | *(documented, docs/deployment.md "what stops a page is Lens's proxy")* |
| N-DECODERS | Sandboxing or memory-safety for ffmpeg, poppler, antiword and similar decoders | Memory bugs inside them route to §1.9. Does not cover Lens's own process control (timeouts, killing children): that is the unresolved x-process-control row | security-critical | no | *(inferred, Q20)* |
| N-BOMBS | Bounds on decompressed size, nested emails or media duration | docx XML, email attachments, long media. Does not cover a conversion or capture outliving Lens's own timeout (x-process-control, Q20) | security-critical | no | *(inferred, Q22)* |
| N-DECODERS | Sandboxing or memory-safety for ffmpeg, poppler, antiword and similar decoders | Memory bugs inside them route to §1.9. Does not cover Lens's own process control (timeouts, killing children): that is P-CONVERT-TIMEOUT | security-critical | no | *(inferred, Q20)* |
| N-BOMBS | Bounds on decompressed size, nested emails or media duration | docx XML, email attachments, long media. Does not cover a conversion or capture outliving Lens's own timeout (P-CONVERT-TIMEOUT) | security-critical | no | *(inferred, Q22)* |
| N-PROMPT | Resistance to prompt injection from transcripts, documents, OCR, web pages or tool replies. "Use only the excerpts" is a prompt, not a control | Impact on words and notes only; crossing the person's reads, approvals or role is P-RETRIEVAL or P-APPROVAL. Exfiltration through rendered answers is Q26 | correctness-only | yes: a system prompt is not a filter | *(inferred, Q26)* |
| N-MODEL-OUTPUT | Correct or grounded model output; answers can be wrong | Checking sources re-asks the same model | correctness-only | no | *(documented, docs/assistant.md "Check sources")* |
| N-REMOTE | Trust in remote content: storage listings, mail, calendars, IIIF manifests, model replies | Treated as data. Does not cover Lens's own cache keys or names for remote content (P-REMOTE-NAMES, Q43) | security-critical | no | *(inferred, Q17)* |
Expand Down
Loading
Loading
0