Centralised security and delivery observability. Ingests, normalizes, stores and displays security findings from open-source scanners — Semgrep, Trivy, Checkov and Gitleaks — behind one scanner-independent domain model.
Built with Next.js 16 (App Router), React 19, TypeScript (strict), Tailwind CSS v4, Recharts, Zod and Vitest.
npm install
npm run devOpen http://localhost:3000 — it redirects to /dashboard. The store is seeded
with clearly-labelled mock scan results so every page has data on first run.
| Command | What it does |
|---|---|
npm run dev |
Development server |
npm run build |
Production build |
npm run lint |
ESLint |
npm run typecheck |
next typegen + tsc --noEmit |
npm test |
Vitest (unit + component; Postgres tests skip without a DB) |
npm run db:migrate |
Apply SQL migrations (requires DATABASE_URL) |
GitHub Actions
│
├── Semgrep ──┐
├── Trivy ────┤
├── Checkov ──┤ SARIF / native JSON
└── Gitleaks ─┘
│
▼
Scanner Adapters ← the only layer that knows a vendor format
│
▼
Result Normalizer ← severity, category, fingerprint
│
▼
SecurityFinding (domain) ← scanner-independent
│
▼
SecurityFindingRepository ← swappable persistence
│
▼
SecurityService / Ingestion ← all metrics computed here
│
▼
API layer
│
▼
UI
The rule that shapes everything else: React components never see scanner
output. They receive SecurityFinding objects and pre-computed statistics. A
component cannot decide that something is a "secret" or "critical" — an adapter
decided that long before render.
src/
domain/security/ enums, SecurityFinding, ScanRun, FindingDecision, errors, inventory contracts
domain/auth/ User + Role, Session, auth errors
lib/auth/ password, cookie, guards, safe-next, repository/, services/, container
lib/api/http.ts JSON response + error-shape helpers
lib/security/
adapters/ semgrep, trivy, checkov, gitleaks + registry
parsers/sarif-parser.ts generic SARIF 2.1.0 reader
normalization/ severity.ts, categories.ts, fingerprint.ts
repository/ interface, in-memory + PostgreSQL implementations
postgres/sql-filters.ts pure SQL builders (unit-tested without a DB)
services/ security-service.ts, scan-ingestion-service.ts
validation/schemas.ts Zod schemas for the HTTP boundary
mock/ MOCK payloads + seeder (dev only)
lifecycle.ts NEW / EXISTING / REOPENED / RESOLVED
observability.ts lightweight event seam
container.ts composition root
lib/db/pool.ts PostgreSQL connection pool
lib/format.ts shared display formatting
providers/platform.ts GitHub | Azure | Security provider seam
app/api/security/ REST endpoints
app/dashboard/ Overview, Security, Applications, Pipelines
app/login/ login page + signIn / signOut Server Actions
components/security/ charts, table, drawer, scanner status
components/shell/ sidebar, header, page chrome
components/ui/ shared primitives
proxy.ts cookie-presence redirect — NOT a security boundary
db/migrations/ forward-only SQL migrations
scripts/migrate.mjs migration runner
scripts/user.mjs account provisioning CLI
tests/ unit + component + repository-contract tests
| Scanner | Formats | Category | Notes |
|---|---|---|---|
| Semgrep | native JSON, SARIF | SAST |
native preferred — carries CWE, impact, autofix, stable fingerprint |
| Trivy | native JSON, SARIF | SCA, CONTAINER, IAC, SECRET, CONFIGURATION |
native strongly preferred — SARIF loses package/fixed-version data |
| Checkov | native JSON, SARIF | IAC |
handles both the object and array envelope shapes |
| Gitleaks | native JSON, SARIF | SECRET |
secret values are discarded, never stored |
Architecture is prepared for — but does not implement — Syft, Grype, OWASP ZAP, CodeQL, Dependabot, GitHub Secret Scanning and Microsoft Defender for Cloud. Each is an adapter plus a registry line; see "Adding a new scanner".
src/lib/security/normalization/severity.ts is the only place that knows vendor
severity vocabularies. It accepts strings (HIGH, warning, blocker), numbers
(CVSS) and numeric strings, and returns UNKNOWN rather than guessing.
Documented policies, each in one named constant so they are easy to change:
- SARIF
errormaps toHIGH, notCRITICAL. Promoting every SARIF error to critical makes the critical count meaningless. - Semgrep
ERROR+impact: HIGH+ confidence not LOW becomesCRITICAL. Semgrep has only three severities; without this, a SQL-injection finding permanently outranks nothing. - Checkov defaults to
MEDIUM(CHECKOV_DEFAULT_SEVERITY). Open-source Checkov emitsseverity: null; a real severity always overrides the default. - Gitleaks defaults to
CRITICAL(GITLEAKS_DEFAULT_SEVERITY). A live credential in version control is treated as critical by policy.
Category answers "what kind of problem is this", independent of which tool found
it. Single-purpose scanners get a fixed category. Trivy resolves per result,
because one Trivy report legitimately contains OS package CVEs (CONTAINER),
application dependencies (SCA), IaC misconfigurations (IAC) and secrets
(SECRET) side by side.
parseSarif() reads SARIF 2.1.0 and knows nothing about any specific tool.
Adapters call it for the generic 90% and override only what they alone know.
- resolves rules by
ruleIdand byruleIndex, on the driver or on extensions - prefers numeric
security-severityover the coarselevel - reads
partialFingerprintsas stable identity (survives line movement) suppressionsbecomestatus: SUPPRESSEDrather than being droppedkind: pass/notApplicableare skipped — those are scan metadata- capped at 50,000 results per document
- defensive throughout: a missing optional field yields an incomplete finding,
not an exception. Only a payload that is not SARIF is rejected
(
InvalidSarifError).
(absent) ──scan sees it──▶ NEW stored as OPEN
OPEN ──scan sees it──▶ EXISTING lastDetectedAt advances
OPEN ──scan misses──▶ RESOLVED resolvedAt set
RESOLVED ──scan sees it──▶ REOPENED stored as OPEN, resolvedAt cleared
OPEN ──▶ ACCEPTED_RISK | FALSE_POSITIVE | SUPPRESSED (human decisions)
Two invariants:
firstDetectedAtis never overwritten. It is the age of the problem and it feeds mean-time-to-remediate. Out-of-order scans keep the earliest value.- Human decisions outrank scanners.
ACCEPTED_RISK,FALSE_POSITIVEandSUPPRESSEDare never auto-reopened or auto-resolved; theirlastDetectedAtstill advances so "accepted and still present" is distinguishable from "accepted and since fixed".
Auto-resolution is scoped: a scan only resolves findings from the same
scanner, repository and environment. A Semgrep run says nothing about Trivy
findings. Pass autoResolveMissing: false for partial or path-filtered scans.
The finding detail drawer offers ACCEPTED_RISK, FALSE_POSITIVE, SUPPRESSED
and reopening to OPEN. All three human-decided statuses require a
justification, stored in statusReason alongside statusChangedAt: an
accepted risk nobody explained is not auditable, which is the only reason the
status is worth having. The reason is trimmed and capped at 500 characters.
Reopening to OPEN takes an optional reason, and clears the old one when none
is given — a justification written for a status the finding no longer holds
reads as a current decision. A change to the status a finding already has is a
no-op, so that path cannot be used to rewrite a justification without a real
transition.
reconcileFinding restores both fields from the stored finding, so a later scan
cannot erase a justification, and clears whatever an adapter supplied on the NEW
path, so a scanner cannot plant one.
Manual RESOLVED is deliberately not offered. RESOLVED means a scan
stopped seeing the finding; letting a person assert it by hand makes
mean-time-to-remediate a number anyone can improve without fixing anything.
canTransition still permits OPEN -> RESOLVED for the ingestion path — the
restriction is UI policy, enforced again in the Server Action so the <select>
is not the only thing standing in the way.
The deciding account's email is recorded in statusChangedBy, snapshotted at
the moment of the decision rather than joined through a foreign key: a risk
acceptance is an audit record that has to survive the person leaving. Deleting
an account therefore leaves every decision it signed intact, and a later email
change does not rewrite history. reconcileFinding carries it exactly as it
carries the justification — restored on the merge path, cleared on the NEW path.
Only an APPROVER may decide. A viewer sees the control disabled with the
reason, and setFindingStatusAction re-checks the role regardless of what the
drawer rendered.
Every human status change is also appended to finding_decisions: who moved
the finding, from which status to which, when, and the justification. The
write is atomic with the status change — one transaction, both or neither.
The drawer shows the trail newest-first.
What is not recorded, on purpose:
- Scanner-driven transitions (auto-resolve, reopen-by-scan). No person made
them;
firstDetectedAt,resolvedAtand the trend data describe the machine's side. - Decisions taken before this feature shipped. They were not backfilled — the drawer says "Earlier decisions were not recorded." rather than showing a synthesized row.
History is append-only at the interface level: neither storage driver exposes
an update or delete for decisions. Reading the trail requires a session but no
role; writing decisions still requires APPROVER.
generateFindingFingerprint() produces a deterministic SHA-256 identity so a
pipeline running on every push does not create duplicate findings.
Included: scanner, rule id (or title), repository, file, start line,
package name, CVE, resource / Azure resource id, environment. A scanner-native
stable id (SARIF partialFingerprints, Gitleaks Fingerprint, Semgrep
extra.fingerprint) short-circuits all positional inputs.
Excluded, deliberately: timestamps of any kind, severity (scanners re-rate findings), status, description (vendors reword rule text), commit SHA, branch, and installed package version (the state of a dependency finding, not its identity).
Known trade-off: startLine is part of the identity, so unrelated edits that
shift line numbers resolve the old finding and open a new one. Scanner-native
stable ids avoid this and are preferred wherever a scanner provides one.
Duplicates are collapsed twice: within a single payload (Trivy lists a CVE once per layer) and against the store by fingerprint.
POST /api/security/scans
Authorization: Bearer $SECURITY_INGEST_TOKEN
Content-Type: application/json
{
"scanner": "TRIVY",
"format": "json",
"repositoryId": "repo_order_service",
"repositoryName": "order-service",
"branch": "main",
"commitSha": "71b3e5d…",
"workflowRunId": "1234567890",
"workflowRunUrl": "https://github.com/org/repo/actions/runs/1234567890",
"applicationId": "app_commerce",
"environment": "production",
"scannedAt": "2026-08-10T11:52:00Z",
"results": { "…": "raw scanner output" }
}
Response 201:
{
"scanRunId": "run_…",
"scanner": "TRIVY",
"status": "COMPLETED",
"summary": {
"received": 12, "duplicatesInPayload": 2,
"created": 3, "updated": 7, "reopened": 0, "resolved": 1
}
}The endpoint requires Authorization: Bearer <token> matching
SECURITY_INGEST_TOKEN, compared in constant time.
It fails closed: if SECURITY_INGEST_TOKEN is unset, the endpoint returns
503 in production. It is permitted without a token only when
NODE_ENV !== "production", for local development.
Upgrade path for a real deployment, in rough order of value: per-repository tokens so one leaked CI secret cannot write for every repo; GitHub OIDC so Actions exchanges a short-lived token instead of holding a static one; and a network boundary (mTLS or a private ingress) so the endpoint is not reachable from the public internet at all.
- body capped at 8 MB, checked before parsing (Content-Length and actual size)
- envelope validated with Zod; repository, branch and commit are pattern-constrained
- no field accepts a filesystem path or a URL to fetch — output travels in the body
- the scanner payload itself is intentionally not schema-validated; it reaches
adapters as
unknownand is parsed defensively, because scanners add fields between releases and a strict schema would reject valid reports - a malformed payload produces a
FAILEDScanRun and a safe error, never a crash - errors and telemetry never include payload fragments or secrets
All scanner-supplied text — title, description, remediation, metadata — is
rendered as text through JSX, which escapes it. There is no
dangerouslySetInnerHTML anywhere in this codebase, and sourceUrl is
scheme-checked (http:/https: only) before becoming a link, so a
javascript: URL in a rule definition cannot execute.
| Endpoint | Purpose |
|---|---|
GET /api/security/findings |
Paginated, filtered findings |
GET /api/security/findings/:id |
One finding |
GET /api/security/findings/:id/history |
The finding's decision trail, newest first |
GET /api/security/statistics |
Aggregates + trend + scanner health |
GET /api/security/scans |
Scan runs + scanner health |
POST /api/security/scans |
Ingest scanner output (authenticated) |
Filters are comma-separated and applied server-side:
GET /api/security/findings?severity=CRITICAL,HIGH&scanner=GITLEAKS&status=OPEN
GET /api/security/findings?repository=payment-service&environment=production
GET /api/security/findings?search=CVE-2026-3456&page=2&pageSize=25
GET /api/security/statistics?timeframe=7d&trendDays=30
Supported: severity, scanner, category, status, repository,
environment, search, timeframe (24h / 7d / 30d / all), page,
pageSize, sortBy, sortDirection. Search covers title, CVE, CWE,
repository, file, resource, rule id and package name.
A complete example lives in
docs/examples/github-actions-security-scan.yml
(kept under docs/ so this repository never executes it). It runs all four
scanners, uploads each report as a normal artifact, and posts the raw output to
the dashboard:
- name: Send Semgrep results to the dashboard
if: always()
run: |
jq -n \
--arg scanner "SEMGREP" \
--arg repositoryName "${{ github.repository }}" \
--arg branch "${{ github.ref_name }}" \
--arg commitSha "${{ github.sha }}" \
--arg workflowRunId "${{ github.run_id }}" \
--slurpfile results semgrep.json \
'{scanner: $scanner, repositoryName: $repositoryName, branch: $branch,
commitSha: $commitSha, workflowRunId: $workflowRunId,
results: $results[0]}' > payload.json
curl --fail-with-body -sS -X POST "$DASHBOARD_URL/api/security/scans" \
-H "Authorization: Bearer $DASHBOARD_TOKEN" \
-H "Content-Type: application/json" \
--data @payload.json
env:
DASHBOARD_URL: ${{ vars.DEVSECOPS_DASHBOARD_URL }}
DASHBOARD_TOKEN: ${{ secrets.DEVSECOPS_DASHBOARD_TOKEN }}if: always() matters: scanners exit non-zero when they find something, and a
finding is exactly when you most want the dashboard updated.
Repository and Azure inventory are owned elsewhere. No inventory scanner is
implemented here — src/domain/security/inventory.ts contains contracts only:
Finding → Repository → Application → Environment → Azure Resource
NoopAssetCorrelationService is wired in today; it enriches nothing.
InventoryAssetCorrelationService is the reference implementation for when an
InventoryProvider exists. Connecting it is a one-line change in
src/lib/security/container.ts.
Correlation is strictly ID-driven. getRepositoryByName is documented as
exact-match only: silently attributing a critical finding to the wrong
application is worse than no correlation at all. Values already asserted by CI
always win over inventory-derived ones.
Not every finding reaches the end of the chain — a Semgrep finding in a library repo may never map to an Azure resource, and that is a valid final state.
No UI changes required. That is the acceptance criterion.
- Add the scanner to
SCANNER_TYPESinsrc/domain/security/enums.ts - Create
src/lib/security/adapters/<name>-adapter.tsimplementingScannerAdapter(canHandle+parse) - Map severity — add vendor tokens to
normalization/severity.tsif needed - Map category — a fixed category, or per-result if the tool is multi-purpose
- Add fixtures under
tests/fixtures/<name>/ - Add tests mirroring
tests/adapters/*.test.ts - Register it in
createDefaultAdapterRegistry()
The UI derives scanner lists from the data (statistics.byScanner), and labels
fall back to a title-cased enum value, so a new scanner appears in charts,
filters and the table automatically.
Two interchangeable implementations of SecurityFindingRepository and
ScanRunRepository. Both are verified against the same contract test suite
(tests/repository/repository-contract.ts, 53 assertions), which is what makes
"persistence is swappable" a fact rather than a hope.
| Driver | When it is used | Trade-off |
|---|---|---|
memory |
default with no DATABASE_URL |
zero setup; state is per-process and lost on restart |
postgres |
whenever DATABASE_URL is set |
durable, multi-instance safe, aggregates computed in SQL |
Selection lives in one place (src/lib/security/container.ts):
SECURITY_STORAGE=postgres|memory explicit override
DATABASE_URL set -> postgres
neither -> memory
Forgetting DATABASE_URL degrades to memory rather than crashing; setting it is
all it takes to get durable storage.
docker run -d --name dashboard-pg \
-e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=dashboard \
-p 5432:5432 postgres:17-alpine
export DATABASE_URL=postgres://postgres:postgres@localhost:5432/dashboard
npm run db:migrate
npm run devnpm run db:migrate applies every file in db/migrations/ once, in filename
order, each inside a transaction, recording applied versions in
schema_migrations. Forward-only and deliberately tiny: no ORM, no codegen, no
rollback machinery to misunderstand.
| Table | Purpose |
|---|---|
security_findings |
one row per normalized finding, keyed by fingerprint |
scan_runs |
one row per scanner execution; scanner health is derived from these |
finding_decisions |
append-only trail of human status decisions |
users |
provisioned accounts: email, scrypt hash, VIEWER / APPROVER role |
sessions |
one row per live session, keyed by the SHA-256 of the cookie token |
login_attempts |
fixed-window failed-login counter, per email |
schema_migrations |
applied migration versions |
fingerprint is the primary key, so
INSERT … ON CONFLICT (fingerprint) DO UPDATE gives the deduplication semantics
transactionally and across processes — the same rule the in-memory store applies
with a Map. Indexes cover (status, severity), (repository_name, status),
(scanner, last_detected_at) and the ingestion resolution scope.
Notes on the SQL:
- aggregates (statistics, trend, repository summaries) run in the database, not by loading rows into Node, so they stay index-bound as data grows
- trend day boundaries are computed in JavaScript and passed as a
timestamptz[], so both drivers bucket by exactly the same UTC midnights - search escapes LIKE metacharacters, so a search for
100%is literal text - filter values are always bound parameters; nothing is interpolated
TIMESTAMPTZthroughout, converted to ISO-8601 UTC at the repository boundary
The migrations, in order:
| File | What it adds |
|---|---|
001_init.sql |
security_findings, scan_runs, indexes |
002_finding_status_reason.sql |
status_reason TEXT, status_changed_at TIMESTAMPTZ |
003_auth.sql |
users, sessions, login_attempts |
004_status_changed_by.sql |
status_changed_by TEXT |
005_finding_decisions.sql |
finding_decisions, FK to the finding, cascade on delete |
Every column added to security_findings after 001 is nullable, because each
describes a human decision and every row predating the migration has none. None
of them are indexed, because none are filtered or sorted on. finding_decisions
does carry one index, (finding_id, decided_at DESC) — it is read per finding in
exactly that order, so the drawer's query is a single index scan.
finding_decisions.finding_id is a real foreign key with ON DELETE CASCADE,
which status_changed_by deliberately is not: the repository exposes no delete
for findings, so the constraint costs nothing and states the right behaviour for
a path nothing exercises. decided_by is NOT NULL — requireApprover()
guarantees an author, so an unsigned row is a bug and the schema says so.
The write path is a Next.js Server Action, not an API route. A public
endpoint able to mark a CRITICAL finding as a false positive is a direct way to
hide a real vulnerability, so no such endpoint exists — and the one credential CI
holds, SECURITY_INGEST_TOKEN, is ingest-only and never reaches a browser.
The action calls requireApprover() before it does anything else: a valid
session, and the APPROVER role on top of it. See
Authentication and Roles.
The action returns a discriminated result rather than throwing: only
SecurityDomainError messages, which are safe by construction, reach the
browser. Anything else becomes a fixed INTERNAL_ERROR message, so a database
failure cannot deliver an internal hostname to the page.
Transient database failures are retried with exponential backoff and full jitter — without jitter, every request that failed during a failover retries at the same instant and knocks the database over again as it comes back.
What is retried, and what deliberately is not:
| Retried | Not retried |
|---|---|
connection class 08* |
unique/foreign-key violations 23* |
operator intervention 57P01–57P04 (restart, failover) |
syntax errors 42601 |
40001 serialization / 40P01 deadlock |
undefined table/column 42P01 |
53300 too many connections |
invalid input 22P02 |
socket errors: ECONNRESET, ECONNREFUSED, ETIMEDOUT, … |
any unrecognised error |
Retrying a constraint violation would burn the budget and hide the bug, so a recognised-but-permanent SQLSTATE fails on the first attempt.
Retry is applied at the operation level, never to a single statement inside
an open transaction — a connection lost mid-transaction leaves it aborted
server-side, so saveMany replays the whole batch from BEGIN. This is only
safe because every write is an idempotent upsert keyed on fingerprint/id:
replaying one lands exactly the same row.
Each retry emits a db.query.retry event carrying the operation label, attempt
number and delay — never SQL or parameters. npm run db:migrate additionally
retries its initial connect, since a freshly started database container accepts
TCP before it finishes initialising.
Mock seeding runs only when the store is empty. On a durable database a restart therefore does not pile up duplicate scan runs, and if data is already present the UI stops labelling it as mock — because it may be real.
scanner_sources, repositories, applications and environments are named in
the architecture but not created: the first has no configuration to hold yet, and
the last three are inventory projections that belong to whatever system supplies
the inventory. Adding them would be schema for schema's sake.
Seeded when SECURITY_DATA_SOURCE is unset or mock (the default); set
SECURITY_DATA_SOURCE=live to start empty.
Mock content lives only in src/lib/security/mock/, is labelled MOCK DATA in
the sidebar, and contains no real credentials. It is scanner-native payloads
replayed through the real adapters across three timestamps (T-21d, T-10d, now),
so the seeded dashboard exercises parsing, fingerprinting, auto-resolution and
MTTR rather than displaying hand-written findings. No production code path
imports it.
| Variable | Default | Purpose |
|---|---|---|
SECURITY_INGEST_TOKEN |
unset | Bearer token for POST /api/security/scans. Required in production. |
SECURITY_DATA_SOURCE |
mock |
mock seeds demo data, live starts empty. |
DATABASE_URL |
unset | PostgreSQL connection string. Setting it switches storage to Postgres. |
SECURITY_STORAGE |
auto | Force postgres or memory, overriding the DATABASE_URL default. |
DATABASE_POOL_MAX |
10 |
Maximum pooled connections. |
DATABASE_MAX_RETRIES |
3 |
Retries after the first attempt on transient database failures. |
DATABASE_RETRY_BASE_DELAY_MS |
100 |
First backoff step; doubles per attempt. |
DATABASE_RETRY_MAX_DELAY_MS |
2000 |
Ceiling for a single backoff step. |
TEST_DATABASE_URL |
unset | Test-only. When set, the Postgres test suites run against it. Wiped between tests. |
NODE_ENV |
set by Next | production adds Secure to the session cookie, refuses dev-account seeding, and strips internal error detail from responses. |
USER_CLI_PASSWORD |
unset | Test/automation escape hatch for npm run user, which otherwise prompts. Prefer the prompt: an env var is readable by any process in the environment and lands in shell history. |
Every dashboard page and every read API is behind a login. The one exception is
POST /api/security/scans, which keeps its bearer token so CI can ingest scans
without holding a session.
There is no self-signup. On a dashboard listing exploitable vulnerabilities, anyone who could reach the login page could otherwise grant themselves access — so accounts are provisioned from the command line.
npm run db:migrate
npm run user -- create --email you@example.com --role approverThe password is prompted on stdin with echo disabled, never taken as a flag:
argv is visible in ps and lands in shell history. Minimum length is 12
characters, with no composition rules. USER_CLI_PASSWORD overrides the prompt
for tests and automation — it exists because the suite has to drive the CLI
non-interactively, and it is the worse option everywhere else.
npm run user -- list
npm run user -- role --email you@example.com --role viewer
npm run user -- delete --email them@example.comThe CLI talks to Postgres directly, so it needs DATABASE_URL. It refuses to
run without one: it is a separate process from the server, so against the
in-memory driver it would write to a store the server cannot see.
With no DATABASE_URL — the zero-setup default — the server seeds a single
dev@localhost approver on boot and prints its randomly generated password to
the server log. The password is new on every boot, so nothing fixed can survive
into a deployment.
Seeding is refused outright when NODE_ENV=production, when the storage driver
is Postgres, when SECURITY_DATA_SOURCE=live, and when any account already
exists.
Passwords are hashed with scrypt from node:crypto — no new dependency — and
stored as self-describing scrypt$N$r$p$salt$hash strings, so cost parameters
can be raised later without invalidating existing rows.
A session is a row, not a signed cookie. The cookie carries 32 bytes of random token; the database stores only its SHA-256. That means two things: a dump of the session table yields nothing usable, and logout is real revocation — the row is deleted, so a copied cookie stops working instantly rather than remaining valid until it expires.
Session lifetime is an absolute 12 hours with no sliding renewal. Failed logins are throttled at 10 attempts per 15 minutes per email, counted whether or not that account exists — a wrong password and an unknown email give the identical message and spend comparable time, so neither is an account-enumeration oracle.
Accounts are VIEWER or APPROVER.
| Read the dashboard and every recorded justification | Change a finding's status | |
|---|---|---|
VIEWER |
yes | no |
APPROVER |
yes | yes |
The split exists because developers need to see their own findings without being able to dismiss them.
The role is chosen at provisioning and changed afterwards:
npm run user -- create --email alice@example.com --role approver
npm run user -- role --email alice@example.com --role viewerEnforcement is in the Server Action (requireApprover()), not in the UI. The
drawer's disabled control is a courtesy; a crafted request meets the same
refusal, the same way manual RESOLVED is refused there rather than only
hidden.
A decision records the deciding account's email on the finding as
statusChangedBy — see Manual decisions.
npm test426 tests without a database (five Postgres files skip); 518 with one.
Covering adapters (all four, native JSON and SARIF), the SARIF parser, severity and category normalization, fingerprint determinism, deduplication, the finding lifecycle, statistics and trend calculation, scanner health, the SQL builders, and the findings table's rendering, filters and detail drawer.
The repository contract suites are the important ones. The same 53 findings assertions run against both the in-memory and the PostgreSQL store, covering the awkward cases — NULL handling in filters, LIKE-metacharacter escaping, page clamping, tiebreak ordering, MTTR being undefined rather than zero, and the append-only decision history. A second contract suite does the same for the 22 auth-store assertions: email case folding, session expiry boundaries, cascade on user deletion, and a throttle window that reopens. If the two drivers ever diverge, those suites fail.
To include the Postgres suites:
docker run -d --name dashboard-test-pg \
-e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=dashboard_test \
-p 5433:5432 postgres:17-alpine
export TEST_DATABASE_URL=postgres://postgres:postgres@localhost:5433/dashboard_test
npm testEach Postgres test file owns a dedicated schema, so they stay isolated while
running in parallel. Without TEST_DATABASE_URL they skip and the rest of the
suite runs normally.
Fixtures in tests/fixtures/ are sanitized and contain placeholder credentials
only. Two tests exist specifically to prove that Gitleaks and Trivy secret
findings never carry the secret value into the domain model — one of them caught
a real leak through the SARIF message.text field during development.
The interface is a dark-first ops console: near-black layered surfaces,
hairline borders, dense tables, IBM Plex Sans paired with IBM Plex Mono for
anything machine-generated (CVEs, rule ids, paths, counts). Colour is almost
entirely reserved for severity encoding, with a single cyan accent marking
interactive state — so a coloured pixel always means something. Motion respects
prefers-reduced-motion.
- In-memory storage is still the default; Postgres is opt-in via
DATABASE_URL. - Transient database failures are retried, but there is no circuit breaker: a sustained outage means every request pays the full retry budget before failing.
- GitHub and Azure providers are declared and stubbed, not implemented; pages that would use them say so rather than showing placeholder data.
- With the in-memory driver, accounts and sessions do not survive a restart, so the dashboard reseeds a development account on each boot. Authentication on the memory driver is a development convenience; a deployment means Postgres.
- The login throttle is a fixed window per email. On the memory driver its counter resets when the process restarts.
- Line-number-based fingerprints churn on unrelated edits where a scanner provides no stable id.
- Scan ingestion is authenticated but not rate-limited.