A vendor name or domain yields a defensible, evidence-linked risk record assembled from lawfully-public OSINT. Risk (0–100) and confidence (0–1) are reported as separate axes and are never collapsed into a single figure.
The methodology is the product. The rationale is documented in
docs/methodology.md and the model itself is machine-readable in
scoring.yaml. This document covers installation and operation; see
docs/running_guide.md for Postgres/Neon configuration, monitoring and
troubleshooting.
┌──────────┐ POST /score ┌─────────────────────────┐ SSE progress ┌──────────┐
│ frontend │ ───────────────► │ backend (FastAPI) │ ───────────────► │ scorecard│
│ :5173 │ ◄─────────────── │ collectors, scoring, │ ◄─────────────── │ renders │
└──────────┘ /api proxy │ append-only evidence db │ full record └──────────┘
└─────────────────────────┘
- Python ≥ 3.11
- Node ≥ 18 and npm (verified against Node 26)
- No paid credentials required. Core sources are unauthenticated; several optional collectors (ABN Lookup, Companies House, OTX) use free API keys and are skipped cleanly when unset.
- Postgres — set
DATABASE_URL(any Postgres instance; a Neon connection string is sufficient). There is no local-file fallback.
cd backend
python -m venv .venv
# Windows (Git Bash): source .venv/Scripts/activate
# Windows (PowerShell): .venv\Scripts\Activate.ps1
# macOS/Linux: source .venv/bin/activate
pip install -r requirements.txt
cp .env.example .env # optional: contact User-Agent, API keys
uvicorn app.api:app --port 8000 --reloadThe service listens on 127.0.0.1:8000, with OpenAPI documentation at /docs.
Storage: Postgres only. Set DATABASE_URL (a Neon connection string, or any Postgres) — the
application fails at startup without it. There is no local-file fallback: the append-only,
hash-stamped evidence store is the legal artefact the score is published from (Finding A), and a
weaker persistence guarantee is not a substitute for it.
The examples below assume API=http://127.0.0.1:8000; substitute the deployed origin as
appropriate.
# submit a vendor for scoring — returns a job id and an SSE stream URL
curl -s -X POST "$API/api/vendors/score" \
-H 'Content-Type: application/json' -d '{"domain":"atlassian.com"}'
# stream collector progress for a running job
curl -N "$API/api/jobs/<job_id>/stream"
# retrieve the completed record (risk, confidence, quadrant, categories)
curl -s "$API/api/vendors/atlassian" | python -m json.tool
# retrieve the hash-stamped evidence receipts behind each finding
curl -s "$API/api/vendors/atlassian/evidence"
# vendor identity — industry, size, country, ownership, and assigned peer cohort
curl -s "$API/api/vendors/atlassian/profile" | python -m json.tool
# peer comparison, or the stated reason no comparison is published
curl -s "$API/api/vendors/atlassian/benchmark" | python -m json.tool
# comparison is cohort-bounded: mismatched vendors return 409 naming both cohorts
curl -s -X POST "$API/api/compare" \
-H 'Content-Type: application/json' -d '{"refs":["atlassian","snowflake"]}'Scoring can also be driven directly, without the service:
python -m app.harness --domain atlassian.com --ref atlassian --name Atlassian # all collectors
python -m app.harness --domain atlassian.com --ref atlassian --only dns # one collectorWith the API running, in a separate shell:
cd frontend
npm install
npm run dev # http://localhost:5173Vite proxies /api to the backend origin, keeping the two same-origin as SSE requires. The
interface accepts a vendor name (Atlassian) or domain (snowflake.com) and renders the scorecard
as results stream in. See frontend/README.md for the designed states.
An optional read-layer LLM digest of a completed record. It is disabled by default and
provider-agnostic, targeting any OpenAI-compatible chat/completions endpoint. Setting the three
variables below enables the Summarise evidence action on the scorecard:
# in backend/.env — configure a single provider (see .env.example for alternatives)
TPRM_LLM_BASE_URL=https://openrouter.ai/api/v1
TPRM_LLM_API_KEY=sk-...
TPRM_LLM_MODEL=google/gemini-2.0-flash-001The constraints are structural. The summary never computes or alters a score and never
writes to the evidence store: it reads the published score and the hash-stamped receipt index, is
returned marked AI-generated, and cites the hashes it was derived from. Raw collector payloads
are never transmitted to the model — only score roll-ups and receipt metadata leave the system,
which is the single documented egress point (see docs/methodology.md
Part 3, Data egress). Absent the credentials, POST /api/vendors/{ref}/summary returns 503
and the action is not surfaced.
# backend (from backend/, with the virtualenv active)
pytest -q # 1,332 passed, 2 xfailed
ruff check app tests # lint
# frontend (from frontend/)
npm run lint
npm run buildThe suite requires
DATABASE_URL(the same Postgres instance the application uses). Each test that touches the store runs in a disposable schema (tprm_test_<uuid>), created on entry and dropped in full afterwards, so the suite never reads from or writes to thepublicschema. The pure-logic tests (scoring, benchmarking, normalisation) run offline and require no database.
Atlassian · Snowflake · MYOB · OneTrust · Slack — production TPRM targets spanning
listed and private ownership, software-shipping and pure-SaaS delivery, and single-register versus
corroborated identity. Xero exercises the sanctions gate: it is correctly blocked and routed to
human adjudication.
- Entity resolution does not guess. Most collectors are domain-specific, so a bare name
(
Atlassian) is not scored automatically: the service returns candidate domains for confirmation and proceeds only once one is selected. Inferring<slug>.comand scoring it silently would risk assessing the wrong company. Supplying a domain scores directly. - Low-confidence results are the norm, and are reported as such. A result banded low-confidence and labelled The Ghost means unassessed, not safe — an intended property of the model, not a defect.
digital_footprintdepends on Certificate Transparency (crt.sh, with a Cert Spotter fallback). crt.sh availability is inconsistent and the anonymous Cert Spotter tier is rate-limited. During a CT outage the category returns zero and a clean vendor may band as The Ghost — an honest and recoverable outcome.TPRM_CERTSPOTTER_TOKENraises the CT limit;TPRM_OTX_API_KEYadds passive-DNS redundancy that carries the category when CT is unavailable.- Several sources require an identifying User-Agent. Wikidata and HIBP expect a descriptive,
contact-bearing
User-Agent. The.env.exampledefault is sufficient for evaluation; supply a real contact address for any deployment. - The sanctions gate terminates in human adjudication. A watchlist match blocks the assessment and emits no score by design (Autonomous Sanctions Act s 16(7) — the adjudication record is the evidence).
A given posture carries different implications across industries, so the scorecard interprets it against a peer cohort defined on four factors — industry × revenue × headcount × region. This changes no arithmetic: identical findings produce an identical posture regardless of sector; only the interpretation differs. The model file is unmodified and the frozen regression corpus continues to pass.
Revenue and headcount are treated as separate dimensions rather than a single blended size. They frequently disagree, and the disagreement carries information: a 40-person firm turning over $400M presents concentrated transactional leverage, whereas a 4,000-person firm at equivalent revenue presents a substantially larger attack surface.
Cohort widening is what makes four factors tractable. 12 sectors × 5 revenue bands × 5 headcount bands × 5 regions yields 1,500 possible cohorts, and exact matches rarely populate — a 114-vendor run produced two populated cohorts, the largest containing four members. Where the exact cohort is too thin, the system widens in a fixed order — dropping region, then revenue, then headcount — and discloses the substitution on the card: "wider peer group used: industry + headcount + region". Widening never proceeds past industry; there is deliberately no rung that compares a vendor against the entire scored population.
Refusal to compare is a designed behaviour. Where no rung reaches cohort.min_cohort_n peers
(default 8, configured in benchmarks.yaml), the card reports "insufficient
peers (4 of 8) at every cohort width" in place of a percentile. Comparison is cohort-bounded at the
API: POST /api/compare across vendors from different cohorts returns 409 naming both.
Statistical claims are bounded to what the sample supports. Percentiles are rounded to the step
the sample can express, and that step is published (50 ±25 at n=4); a quartile is reported
alongside, as it remains stable when a single peer joins or leaves. Stale and thinly-evidenced peers
are excluded, and the exclusion is disclosed. Every benchmark carries the standing caveat that peers
are the vendors this deployment has scored — a convenience sample, not an industry norm.
Per-category variance is also reported, since "breach history is 30 points below peers" is
actionable where a single aggregate figure is not.
benchmarks.yaml ships with no sector targets, deliberately. No authoritative source publishes
them, and asserting that "financial services should score 95" carries the same defect as the
category weights removed in §5.6. An expected_posture entry loads only with a basis: field
naming a real source.
# cohorts require a population; seed one by scoring real vendors (global, 10 sectors)
python -m app.seed_cohorts --list # preview the target set
python -m app.seed_cohorts --sector technology # seed a single sector
python -m app.seed_cohorts --status # cohort depth against the gateTwo optional client-supplied inputs are recorded as client-supplied and neither is scored:
criticality (the buyer's own dependency on the vendor, not externally observable) and size_band
(public sources publish an industry for most vendors but a headcount for considerably fewer, and a
sector without a size cannot form a cohort).
scoring.yaml the model (the deliverable) — bands, penalties, gates, confidence
benchmarks.yaml peer cohorts, size bands, the minimum-peers gate — interpretation, no arithmetic
backend/ FastAPI API, collectors, scoring engine, append-only evidence store
frontend/ React + Vite single-screen streaming scorecard
docs/ five documents — methodology, the model, design decisions (see below)
ops/ scheduled monitoring (cron / systemd / Windows Task Scheduler)
Start here: methodology.md — the research and rationale; the document the
model is derived from. Then system_retrospective.md for current,
verified status.
Five documents. Each is split into parts; the part is the citable unit.
| Document | Part | What it covers |
|---|---|---|
methodology.md |
1 · Research methodology | The product. Full derivation of the model and what OSINT can and cannot reach |
| 2 · Legal & standards basis | Model-level legal basis — once collected, does the score stand up? | |
| 3 · Source assessment | Per-source signals, reliability, limits and legal/ToS position — may we lawfully collect this? | |
scoring_model.md |
1 · The model in brief | The guided summary of scoring.yaml — severity ladder, grades, roll-up, the three axes |
| 2 · Metrics reference | Every published metric and the arithmetic implemented, each constant read from code | |
| 3 · Vendor age differentiation | How age is used — and why it never touches cybersecurity posture | |
design_decisions.md |
1 · Peer benchmarking | Cohort scoping, placement thresholds, the refusal-to-compare gate |
| 2 · Financial data integration | Collector chains, API surface, schema, the Business Stability axis | |
| 3 · Vendor comparison scenarios | Nine acceptance scenarios in plain English, executable as a test file | |
system_retrospective.md |
— | What was built, what was rejected, and the defects found and fixed along the way |
running_guide.md |
— | Postgres/Neon specifics, monitoring and troubleshooting — the long form of this README |