Skip to content

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_by

A 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

PageRouteShows
Login/portal/loginpassword plus a mailed one-time code
Overview/portal/overviewthe estate as it stands after the newest completed run
Hosts/portal/hostsone 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/surfacewhat exists and answers, from the recon pass — HTTP status and title, CNAME, A/AAAA, subdomain-takeover flags
Certificates/portal/certificatesthe leaf certificate per TLS service, with expiry windows, wildcards and self-signed
Crypto/portal/cryptocrypto-relevant findings by severity, filterable down to PQC-relevant only
Runs/portal/runsrun 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/reportsper run: date, domain, download
Account/portal/accountusers, 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:

ChangeKeyed on
hosts appeared / gonehost name
ports opened / closedhost:port
findings new / resolvedfinding id + title, counted per severity
certificates changedhost:port

Login and sessions

ConcernBehaviour
Cookie__Host-kartis_session, HttpOnly, Secure, SameSite=Lax; 256-bit random, only its SHA-256 stored
Lifetime12 hours idle, 7 days absolute
Passwordargon2id, 64 MiB / 3 iterations / 4 threads
Second factorsix-digit code by email, 10 minutes, single use, hash stored — mandatory for every role
CSRFdouble submit: token cookie plus hx-headers, verified on every non-GET
Brute forcebackoff per account and per IP, lockout after 10 attempts
Rolesowner (scope, on-demand runs, user management), viewer (read, report download)
Auditlogin, 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:

ClassEntryTreatment
VerifiedTXT record, or explicit in the signed scopeactive scanning: port scan, TLS handshake, WAF detection
Observedfound through the org graphpassive 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:

  1. A failed scheduled run is not retried automatically. The portal shows the failure; the customer restarts it.
  2. Runs missed during downtime are caught up once, not once per missed slot.
  3. On-demand quota is debited when a run completes, so a failed run costs the customer nothing.
  4. 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.