Portal
The portal is the human-facing half of Kartis: a server-rendered web surface
mounted at /portal/... inside the kartis-scan binary, next to the
bearer-token /v1/... API that Eon Center, Eon Aethis and Eon Insights use.
It is off by default — an existing deployment that only serves machine
callers keeps exactly the surface it had. Turn it on in kartis-scan.yaml
(see Configuration).
No npm, no build step: html/template with go:embed, htmx vendored in
third_party/htmx/, one hand-written stylesheet, and charts as
server-generated inline SVG.
Tenancy
Both surfaces resolve to the same tenant boundary before any query runs:
kartis-scan
├── /v1/* bearer token machine callers: Eon Center, Eon Insights
└── /portal/* cookie session humans
│
└── both resolve to requested_byA portal session resolves to the same requested_by value the API tokens
already carry, rather than introducing a second scoping path. Eon Center’s
tokens keep requested_by IS NULL and stay cross-customer callers.
Pages
| Page | Route | Shows |
|---|---|---|
| Login | /portal/login | password plus a mailed one-time code |
| Overview | /portal/overview | the estate as it stands after the newest completed run |
| Hosts | /portal/hosts | one row per host from its newest completed run: IP, open ports, product/version, last seen |
| Host detail | /portal/hosts/{host} | that host’s services, certificates and findings, with per-finding advice |
| Attack surface | /portal/surface | what exists and answers, from the recon pass — HTTP status and title, CNAME, A/AAAA, subdomain-takeover flags |
| Certificates | /portal/certificates | the leaf certificate per TLS service, with expiry windows, wildcards and self-signed |
| Crypto | /portal/crypto | crypto-relevant findings by severity, filterable down to PQC-relevant only |
| Runs | /portal/runs | run history with status, trigger and counts |
| Run detail | /portal/runs/{uuid} | one run’s outcome |
| Compare runs | /portal/runs/compare?run=&run= | two runs side by side |
| Reports | /portal/reports | per run: date, domain, download |
| Account | /portal/account | users, password change, domain verification status |
Hosts and Attack surface answer different questions. Hosts shows what has been scanned; Attack surface shows what exists and answers, read from the recon pass’s liveness data. The same estate can therefore look thin on one page and hold thousands of hosts on the other.
Comparing two runs keys each kind of change on what makes it meaningful:
| Change | Keyed on |
|---|---|
| hosts appeared / gone | host name |
| ports opened / closed | host:port |
| findings new / resolved | finding id + title, counted per severity |
| certificates changed | host:port |
Login and sessions
| Concern | Behaviour |
|---|---|
| Cookie | __Host-kartis_session, HttpOnly, Secure, SameSite=Lax; 256-bit random, only its SHA-256 stored |
| Lifetime | 12 hours idle, 7 days absolute |
| Password | argon2id, 64 MiB / 3 iterations / 4 threads |
| Second factor | six-digit code by email, 10 minutes, single use, hash stored — mandatory for every role |
| CSRF | double submit: token cookie plus hx-headers, verified on every non-GET |
| Brute force | backoff per account and per IP, lockout after 10 attempts |
| Roles | owner (scope, on-demand runs, user management), viewer (read, report download) |
| Audit | login, scope change, on-demand trigger, report download, verification change |
The one-time code carries a code and nothing else — no findings, no host names, no customer name in the subject, so a misdelivered message discloses only that the address has a Kartis account. If SMTP submission fails the login returns a generic error and the failure is written to the audit table; the code is never shown in the response as a fallback, under any configuration.
A login depends on mail arriving, so treat deliverability as part of the deployment: SPF authorising the sending path, DKIM signing on the sender address with DMARC aligned, and a dedicated transactional sender.
Domain verification
Per root domain, the customer publishes:
_kartis-verify.<apex>. TXT "kartis-verify=<token>"The scheduler refuses to run against an unverified domain. The record is re-checked monthly: one that disappears means the customer no longer controls the domain.
Domains found by org expansion can’t be furnished with a TXT record by definition — they’re the ones the customer had forgotten. Two classes resolve that:
| Class | Entry | Treatment |
|---|---|---|
| Verified | TXT record, or explicit in the signed scope | active scanning: port scan, TLS handshake, WAF detection |
| Observed | found through the org graph | passive only: CT logs, DNS, WHOIS, GLEIF — no packet to their infrastructure |
An observed domain renders with an add to scope action, which triggers a fresh verification.
Scheduled runs
kartis-scheduler is a separate binary that only needs the database — it
doesn’t scan, doesn’t talk to Redis and serves no HTTP. On each tick it picks
up sessions whose next slot has passed and inserts a pending run; the
existing claim chain does the rest.
Cadence is monthly or biweekly plus an anchor (day of month, or weekday),
computed in Go — there is no cron expression to get wrong. The next slot is
materialised, so the portal can show next scan on 1 October without
recomputing it.
Four rules worth knowing before you operate it:
- A failed scheduled run is not retried automatically. The portal shows the failure; the customer restarts it.
- Runs missed during downtime are caught up once, not once per missed slot.
- On-demand quota is debited when a run completes, so a failed run costs the customer nothing.
- Exceeding the host limit does not truncate the run. Everything is stored and the overage is recorded — a scan that silently stopped at host 501 would produce a report that lies.
Deployment
The portal is HTTPS-only by construction: its session cookie carries the
__Host- prefix and the Secure flag, which a browser rejects over plain
HTTP. Run it behind your own TLS front and set trusted_proxies to that
proxy, so forwarded client addresses and the forwarded scheme are believed
from it and nowhere else. deploy/nginx/kartis.conf in the source repository
is a working reference: one public vhost carrying /portal/ and nothing
else, with /v1/* deliberately absent from it.
The insecure option drops the cookie prefix and the Secure flag. It
exists for a test over plain HTTP and for nothing else.