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).
Tenancy
Both surfaces resolve to the same tenant boundary before any query runs:
kartis-scan
├── /v1/* bearer token machine callers
└── /portal/* cookie session humans
│
└── both resolve to the same tenantA portal session is scoped to the same tenant as that customer’s API tokens.
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- prefixed, HttpOnly, Secure, SameSite=Lax; random, only a hash stored |
| Lifetime | idle timeout plus an absolute maximum |
| Password | argon2id |
| Second factor | one-time code by email, short-lived, single use — mandatory for every role |
| CSRF | token verified on every non-GET request |
| Brute force | backoff per account and per IP, followed by lockout |
| 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 periodically: 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 and serves no HTTP. It picks up sessions whose next slot has
passed and queues a run for each.
Cadence is monthly or biweekly plus an anchor (day of month, or weekday);
there is no cron expression to get wrong. The portal shows the next slot, for
example next scan on 1 October.
Two 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.
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. Expose only /portal/ on the public vhost and keep
/v1/* off it.
The insecure option drops the cookie prefix and the Secure flag. It
exists for a test over plain HTTP and for nothing else.