Skip to content
acaacxPublic

About

DevSecOps dashboard: ingests, normalizes and displays security findings from Semgrep, Trivy, Checkov and Gitleaks behind one scanner-independent domain model.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

DevSecOps Dashboard

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 dev

Open 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)

Security architecture

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.

Layout

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

Supported scanners

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".


Scanner normalization

Severity

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 error maps to HIGH, not CRITICAL. Promoting every SARIF error to critical makes the critical count meaningless.
  • Semgrep ERROR + impact: HIGH + confidence not LOW becomes CRITICAL. Semgrep has only three severities; without this, a SQL-injection finding permanently outranks nothing.
  • Checkov defaults to MEDIUM (CHECKOV_DEFAULT_SEVERITY). Open-source Checkov emits severity: 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

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.


SARIF support

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 ruleId and by ruleIndex, on the driver or on extensions
  • prefers numeric security-severity over the coarse level
  • reads partialFingerprints as stable identity (survives line movement)
  • suppressions become status: SUPPRESSED rather than being dropped
  • kind: pass / notApplicable are 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).

Security finding lifecycle

(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:

  1. firstDetectedAt is never overwritten. It is the age of the problem and it feeds mean-time-to-remediate. Out-of-order scans keep the earliest value.
  2. Human decisions outrank scanners. ACCEPTED_RISK, FALSE_POSITIVE and SUPPRESSED are never auto-reopened or auto-resolved; their lastDetectedAt still 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.

Manual decisions

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.

Decision history

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, resolvedAt and 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.


Deduplication

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.


Scan ingestion

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
  }
}

Authentication

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.

Input safety

  • 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 unknown and is parsed defensively, because scanners add fields between releases and a strict schema would reject valid reports
  • a malformed payload produces a FAILED ScanRun and a safe error, never a crash
  • errors and telemetry never include payload fragments or secrets

XSS posture

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.


API

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.


GitHub Actions integration

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.


Inventory correlation

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.


Adding a new scanner

No UI changes required. That is the acceptance criterion.

  1. Add the scanner to SCANNER_TYPES in src/domain/security/enums.ts
  2. Create src/lib/security/adapters/<name>-adapter.ts implementing ScannerAdapter (canHandle + parse)
  3. Map severity — add vendor tokens to normalization/severity.ts if needed
  4. Map category — a fixed category, or per-result if the tool is multi-purpose
  5. Add fixtures under tests/fixtures/<name>/
  6. Add tests mirroring tests/adapters/*.test.ts
  7. 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.


Storage

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.

Running with PostgreSQL

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 dev

npm 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.

Schema

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
  • TIMESTAMPTZ throughout, 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.

Changing a finding's status

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.

Connection retry

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.

Seeding behaviour with a database

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.

Not yet implemented

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.


Mock data

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.

Environment variables

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.

Authentication

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.

Creating the first account

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 approver

The 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.com

The 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.

The development account

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.

How sessions work

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.

Roles

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 viewer

Enforcement 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.


Testing

npm test

426 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 test

Each 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.


Design

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.

Known limitations

  • 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.

About

DevSecOps dashboard: ingests, normalizes and displays security findings from Semgrep, Trivy, Checkov and Gitleaks behind one scanner-independent domain model.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages