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
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,63 @@ API-first management plane and horizontally scalable, isolated scanner engines.
> [`ARCHITECTURE.md`](./ARCHITECTURE.md) and [`docs/`](./docs/) for the spec, and the GitHub
> milestones/issues for what is left.

## The console

![The IcebergSST overview screen: a dark navigation rail, four KPI tiles for critical and open
findings, a table of the newest open findings with severity chips, and panels for running and
recent scans.](./docs/img/overview-light.png)

Server-rendered HTML driven by HTMX and Alpine, under a strict Content-Security-Policy. It is a
client of the same API routes documented in [`docs/api.md`](./docs/api.md) — not a second
implementation of them. Dark mode follows the operating system.

<details>
<summary><b>More screens</b> — findings and triage, live scan status, sources, the engine fleet, dark mode</summary>

<br>

**Findings.** Every filter is part of the URL and is sent to the API as written, so the rows always
reconcile with the query above them.

![The findings queue: a filter bar for source, state, severity, rule, assignee and suppression,
above a table of findings with severity and state chips.](./docs/img/findings-light.png)

**A finding, and why it is in the state it is in.** The snippet was masked inside the engine before
it ever crossed the wire; the database has never held the secret. The history below the triage panel
is the `FindingEvent` trail.

![A finding detail view showing the redacted JDBC connection string in a dark evidence well, its
location in Confluence, the triage panel, and a history of the assignment and the accepted-risk
decision with the analyst's reasoning.](./docs/img/finding-detail-light.png)

**A scan in flight.** The status region polls itself every three seconds and stops when the scan
reaches a terminal state — the browser never has to know which statuses those are.

![A running scan in dark mode: a progress bar at two of four tasks finished, tallies for units
scanned and findings, and a table of per-task state including one running and one queued.](./docs/img/scan-live-dark.png)

**A source.** The credential is write-only at the API, so the form has nothing to echo back — it
reports that one is stored and offers to replace it.

![A source detail view showing scan history, the Confluence connection form with space chips, and a
credential field marked as stored with a Replace button.](./docs/img/source-detail-light.png)

**The fleet.** Engines hold no database credentials. When a rolling deploy leaves two rule-pack
versions in force, the page says so rather than picking one.

![The engine dashboard: three engines with status and heartbeat ages, one offline, and a panel
reporting that two rule-pack versions are in force.](./docs/img/engines-light.png)

**Dark mode** is the same token set with dark surfaces — no toggle, no cookie, and no inline
bootstrap script (which the CSP would forbid anyway).

![The overview screen in dark mode, with the same layout on dark surfaces.](./docs/img/overview-dark.png)

</details>

> Screenshots are of fabricated demo data on a local instance. Every snippet shown is already
> masked, because that is the only form the database can hold.

## What it does

- **Discover & scan** content in Confluence (MVP), with Jira and SMB/NFS file shares to follow.
Expand Down
11 changes: 10 additions & 1 deletion apps/api/src/iceberg_api/web/static/css/iceberg.css
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,16 @@ select.field {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%2364748b' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 10px center; padding-right: 30px;
}
.field-hint { display: block; margin-top: 5px; font-size: 12px; color: var(--muted); line-height: 1.4; }
/* Hints live *inside* `.stack > label`, which is mono/uppercase/tracked because it
is a field label. A hint is prose, so it resets all three rather than inheriting
them — otherwise a sentence of guidance renders as shouted monospace. */
.field-hint {
display: block; margin-top: 5px;
font-family: var(--fs-sans); font-size: 12px; font-weight: 400;
letter-spacing: normal; text-transform: none;
color: var(--muted); line-height: 1.45;
}
.field-hint code { font-size: 0.95em; }

/* Stacked labelled form */
.stack { display: flex; flex-direction: column; gap: 0.95rem; }
Expand Down
11 changes: 9 additions & 2 deletions apps/api/src/iceberg_api/web/templates/partials/triage.html
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,16 @@
</span>
<span class="timeline-ts">{{ event.created_at | dt }}</span>
</div>
{% if event.from_value or event.to_value %}
{% if event.kind.value == 'assign' %}
{#- An assign event records the assignee's *id*, which is what stays
correct when somebody is renamed. Show the name to the reader. -#}
<div class="timeline-body">
{{ event.from_value | person(user_names) }} → <strong>{{ event.to_value | person(user_names) }}</strong>
</div>
{% elif event.from_value or event.to_value %}
<div class="timeline-body mono" style="font-size:12.5px">
{{ event.from_value or '—' }} → {{ event.to_value or '—' }}
{{ event.from_value | humanize if event.from_value else '—' }}
→ {{ event.to_value | humanize if event.to_value else '—' }}
</div>
{% endif %}
{% if event.comment %}
Expand Down
19 changes: 19 additions & 0 deletions apps/api/src/iceberg_api/web/templating.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,24 @@ def _humanize(value: object) -> str:
return str(value).replace("_", " ").capitalize()


def _person(value: object, names: dict[uuid.UUID, str]) -> str:
"""A user id recorded on an audit event, rendered as the person's name.

An assignment event stores the assignee's id in ``to_value``, because that is
what stays correct when somebody is renamed. A trail that reads
"assign — → 966d7342-…" is technically complete and practically useless, so
the name is substituted wherever the reader is allowed to know it, and the id
is shortened rather than dropped when they are not.
"""
if value in (None, ""):
return "unassigned"
try:
key = uuid.UUID(str(value))
except ValueError:
return str(value)
return names.get(key, f"{str(value)[:12]}…")


def build_environment() -> Environment:
"""The template environment.

Expand All @@ -99,6 +117,7 @@ def build_environment() -> Environment:
env.filters["ago"] = _format_ago
env.filters["short"] = _shorten
env.filters["humanize"] = _humanize
env.filters["person"] = _person
env.globals["app_name"] = APP_NAME
return env

Expand Down
Binary file added docs/img/engines-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/img/finding-detail-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/img/findings-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/img/overview-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/img/overview-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/img/scan-live-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/img/source-detail-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
15 changes: 15 additions & 0 deletions docs/web.md
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,21 @@ breadcrumb + `.canvas`/`.canvas-inner`). Unauthenticated pages use
rail-scoped styling from the `--rail*` tokens — a `.btn` there paints a white box
on dark chrome.

## Screenshots

`docs/img/*.png` are captured by hand and embedded in the README. They are not
generated by CI and will drift as screens change — treat a stale one as a doc
bug, not a broken build.

Reproducing them needs two things this repository deliberately does not contain:
a database of fabricated findings, and a way to sign in. Authentication is OIDC
only (ADR 0005), so a local instance cannot be signed into without a provider,
and a development bypass — a login route that trusts a query string — is exactly
the kind of thing that survives into a production image. Both therefore live
outside the tree: a seeding script and a wrapper that imports the real
`create_app()` and adds a single `/dev-login` route. Nothing under `apps/` knows
either exists.

## Security properties this surface adds

- **Strict CSP** on every response except FastAPI's interactive docs, which load
Expand Down
Loading