Skip to content

Security Dossier (CISO)

Classification: Restricted — CISO / Security Team
Version: 1.1
Date: 2026-05-23
Audience: CISO, Security Officers, Security Engineering


Security overview

Repod is an enterprise-grade Linux package repository manager supporting .deb (APT), .rpm, and .apk (Alpine) packages from a single format-agnostic backend (REPO_FORMAT). It provides full control over the software supply chain, from package intake to distribution to target systems.

Security principles

  • Defense in depth — every incoming package traverses a multi-stage, independent control pipeline (antivirus → CVE → GPG → integrity).
  • Least privilege — five distinct roles with precisely scoped permission boundaries.
  • Separation of duties — CVE remediation decisions are reserved for the security team; operators cannot self-approve vulnerable packages.
  • Full traceability — every sensitive action is recorded in append-only JSONL audit files that cannot be modified or deleted via the API.
  • Regulatory alignment — NIS2 (EU 2022/2555), ANSSI SecNumCloud, GDPR.

Core components

Component Technology Role
Backend API FastAPI / Python 3.11 Business logic, authentication, security pipeline
Database PostgreSQL 16 Users, manifests index, inventory, install jobs, package search, CVE records
Antivirus ClamAV 1.4.3 Malware detection on every upload
CVE scanner Grype v0.112.0 (Anchore) Known vulnerability analysis
SBOM generator Syft v1.44.0 CycloneDX 1.5 + SPDX 2.3 per package
Frontend React + Nginx CISO / operator interface
Storage Docker volume /repos Packages, manifests, audit logs, GPG keyrings

Authentication and identity management

Local authentication

  • Password storage: bcrypt via passlib[bcrypt]. No plaintext passwords stored anywhere.
  • Password policy (enforced server-side):
    • Minimum 8 characters
    • At least 1 uppercase letter AND at least 1 digit or special character
  • Password reset: tokens stored as SHA-256 hashes only. Valid for 30 minutes.
  • Account status: is_active verified on every authenticated request. Deactivating an account immediately invalidates all active sessions.

JWT tokens (user sessions)

Property Value
Algorithm HS256
Lifetime 60 minutes (configurable via JWT_EXPIRE_MINUTES)
Secret key validation Application refuses to start if JWT_SECRET_KEY is empty or a default value when ENV=production
Per-request check Active status verified on every request, not only at token creation

Known limitation — no JWT revocation

A valid token remains functional until natural expiry (60 min) even after explicit logout. Compensating control: the 60-minute window limits exploitation exposure; account deactivation is checked on every request.

API tokens (CI/CD)

Property Value
Format repod_ prefix + cryptographically secure random suffix
Storage SHA-256 hash only — plaintext never persisted
Expiry Configurable per token (optional)
Revocation Immediate, by admin or token owner

LDAP / Active Directory (optional)

Property Value
Library ldap3
TLS Certificate verification enabled by default (verify_cert=True)
Auto-provisioning Local account created on first successful LDAP login with a random, non-usable local password
Bind password masking Masked as *** in GET /settings responses

Rate limiting

Endpoint Limit
Authentication (/auth/*) 10 req / min
Package upload 20 req / min
Import / fetch 10 req / min
Batch operations 5 req / min
Repository sync 3 req / min

Access control (RBAC)

Five privilege levels with precisely scoped permissions:

Permission admin maintainer uploader auditor reader
User management
Settings modification
Package upload
Package import
Package promotion / deletion
Repository sync
CVE decision (approve/reject)
Audit log access
Package read access
SBOM export
API token management ✅ (all) ✅ (own) ✅ (own)

admin — full platform access. Only role that can manage users, change system settings, and configure CVE policies.

maintainer — full package lifecycle. Can approve/reject CVE-flagged packages. Cannot manage users or system settings.

uploader — upload and import packages only. Designed for CI/CD pipelines requiring minimal access.

auditor — read-only access to packages and audit logs. Suitable for compliance teams and external auditors.

reader — read-only access to packages only. No audit log access.

Scoped access control (per-distribution, per-machine)

The role matrix above is the first, global layer of RBAC — it decides which actions a role can perform anywhere in the system. Repod adds a second, optional, scoped layer on top, restricting which resources within an action a given role or user group can reach. This is a genuinely unified model applied to two resource types — same rules, same guarantees, same underlying evaluation pattern (services/distribution_access.py, services/machine_access.py) — not three parallel RBAC systems:

Resource Table Grants access to
Distributions (jammy, almalinux9, ...) distribution_access A role or a user group
Machines (individually, or by inventory tag) machine_access A role or a user group

Both layers share five properties, verified by the automated test suite (tests/test_distribution_access.py, tests/test_machine_access.py):

  1. Open by default. A distribution or machine with zero rows in the corresponding table is reachable by any authenticated user whose role already permits the action — installing the scoped-access layer never locks out an existing deployment. Restriction is opt-in: it only takes effect once an administrator explicitly adds a grant.
  2. AND, not OR, with the role matrix above. Scoped access never widens what a role can do — a reader granted access to a restricted machine still cannot trigger a scan (blocked by the global role check) or install packages (same). It only ever narrows which machines/distributions a role-permitted action can reach.
  3. admin always bypasses both layers. This is deliberate: it guarantees no distribution or machine can end up unreachable by everyone, which would otherwise require direct database access to recover from.
  4. 404, not 403, on denial. A restricted-and-inaccessible resource is made indistinguishable from a resource that does not exist — this prevents a lower-privileged user from even confirming that a given distribution or machine exists. List endpoints (GET /distributions/, GET /inventory/clients) apply the same rule by silently omitting inaccessible entries rather than erroring.
  5. Grants combine, they are never partially applied. For machines, a given user can be granted access via an inventory tag (any machine carrying that tag) or via a specific machine id (an override). If several of a machine's tags grant access to different groups, any one matching grant is sufficient (union) — consistent with how multiple distribution_access rows for the same distribution already combine. If a machine has an explicit id-level override, that override replaces its tag-derived grants entirely rather than adding to them — the same "override replaces, never merges" convention already used for maintenance windows and compliance profile assignment.

Operational note on multi-purpose tagging

Because tag-based grants combine as a union, a machine carrying two tags with different purposes (e.g. a role tag like webserver and a sensitivity tag like prod-db) becomes reachable by every group granted access via either tag. This is intentional (adding a second tag must never silently revoke a team's existing access), but it means tagging discipline is itself a control an organization should document and enforce: do not combine team-identity tags and sensitivity tags on the same machine unless every team with access to any of that machine's tags is meant to see it.

Enforced on: every client_id-scoped inventory endpoint (SSH fingerprint, scan trigger/status/cancel, packages, updates, CVE, compliance, containers), install job creation/read/confirm/cancel (a job is treated as inaccessible if any of its targets is), and the NIS2 posture/report endpoints (silently scoped to the caller's accessible machines — a restricted user can never widen the report's scope beyond what they can already see by passing explicit client_ids/tags query parameters).

Known limitation — not yet enforced: the fleet-wide aggregate dashboards (GET /inventory/summary, and compliance_router.py's /summary, /matrix, /by-package, /executive-summary) compute over the entire active fleet regardless of machine-level restrictions — they expose aggregate counts only (no machine hostnames/IPs), so the leak is limited to "how many machines/CVEs exist" rather than which specific machines. Closing this gap requires threading a client-id scope through several existing SQL aggregation queries in services/inventory.py and services/compliance_engine.py — tracked as follow-up work, not addressed in this pass to avoid rushing changes to security-relevant aggregation logic.


Package security pipeline

Every incoming package traverses a 6-stage sequential pipeline before publication.

flowchart TD
    A([Package received]) --> B[Stage 1\nFormat validation]
    B -->|FAIL| R1([Rejected — HTTP 400])
    B --> C[Stage 2\nSHA-256 provenance check]
    C -->|MISMATCH| R2([Rejected])
    C --> D[Stage 3\nClamAV antivirus scan]
    D -->|VIRUS| Q1([Quarantined])
    D --> E[Stage 4\nGrype CVE analysis\n+ EPSS + CISA KEV]
    E -->|policy=block| Q2([Quarantined])
    E -->|policy=review| PR([pending_review\nCISO queue])
    E --> F[Stage 5\nGPG signature verification]
    F -->|INVALID| R3([Rejected])
    F --> G[Stage 6\nDependency check]
    G --> PUB([Published to repository])

Stage-by-stage description

Stage 1 — Format validation Verifies structural integrity of the package file:

dpkg-deb --info — rejects malformed, truncated, or non-compliant .deb files.

rpm -qp --info — rejects malformed or corrupt .rpm files.

Stage 2 — SHA-256 provenance check
Compares the uploaded package hash against the source repository index. Any mismatch results in immediate rejection.

Stage 3 — ClamAV antivirus scan
Submitted to ClamAV (signature database updated daily by freshclam). Malware detection triggers quarantine and blocks publication.

Stage 4 — Grype CVE scan
Grype cross-references the package SBOM against NVD, GitHub Advisory Database, and CISA KEV. Response is determined by the configured policy:

Severity Policy Behavior
Critical block Quarantined — never published
Critical review pending_review — mandatory CISO queue
High block Quarantined
High review pending_review — CISO queue
Medium warn Published with warning flag
Low allow Published without restriction

Policies are configurable per-severity by administrators.

Stage 5 — GPG signature verification
If a detached signature is present, it is verified against the keyring. A present-but-invalid signature is a hard failure. An absent signature is a soft pass (not all packages carry detached signatures).

Stage 6 — Dependency availability check
Verifies declared dependencies against the internal pool. Missing dependencies generate a warning (non-blocking by default; set strict_deps=true for air-gapped environments).


Vulnerability management (CVE)

CISO review queue

Packages where at least one CVE triggers the review policy enter pending_review status. They are visible in the CISO interface but inaccessible to repository consumers until a decision is made.

Available actions (roles: admin, maintainer):

  • Approve — package published; justification mandatory and recorded.
  • Reject — package moved to quarantine; justification mandatory and recorded.

SLA by severity

Severity Default SLA Configurable
Critical 0 days (immediate decision required) Yes
High 30 days Yes
Medium 90 days Yes
Low No SLA Yes

SLA breaches surface as alerts in the admin interface. Email and webhook notifications are available for SLA breach events.

CVE contextual enrichment

Each CVE in the CISO review queue displays:

  • CVSS v3 score (base + attack vector)
  • EPSS score (30-day exploitation probability)
  • CISA KEV catalog membership
  • Affected package, version, and available fix
  • Previous decision justification (if re-reviewed)

Continuous re-verification after publication

The Grype scan described above runs once, at upload/import time — a single snapshot. Two further controls extend coverage over the lifetime of an already-published package, closing the gap between "was scanned once" and "is still known-good today":

Control Mechanism Scope Trigger
CVE re-matching via stored SBOM Same engine (Grype), re-run against its own vulnerability database as it evolves, using a CycloneDX SBOM captured as a byproduct of the original scan — never re-opens the original package file All 7 supported package formats, every edition, including Community Daily, enabled by default
Dual-scan (Grype + Trivy) A second, independent engine (Trivy) re-scans already-published packages; each CVE is tagged detected_by: ["grype"], ["trivy"], or ["grype","trivy"] Deb/RPM/APK/Maven/PyPI/npm (OCI excluded — already fully scanned by Grype) — SaaS only, opt-in Periodic sweep (dual_scan_daily cron)

Both mechanisms route a newly-discovered policy breach to the same pending_review RSSI queue described above — no separate decision workflow — and both deliberately downgrade a block-severity finding to review rather than an outright re-rejection, since re-matching and Trivy-only findings are, by construction, less precise than the original upload-time Grype scan (re-matching has no new binary to re-verify against; Trivy-only is a second opinion on a package already accepted). Neither control ever runs in the upload/import path — both operate exclusively on already-validated packages, so neither adds latency to a publish. See CVE review workflow — Beyond the upload-time scan for the full mechanism and Enable dual-scan for setup.


SBOM and software traceability

Supported formats

Standard Version Body
CycloneDX 1.5 OWASP
SPDX 2.3 ISO/IEC 5962:2021

SBOM contents

Each generated SBOM contains:

  • Complete component inventory (direct and transitive dependencies)
  • Associated CVE identifiers and their triage status
  • SHA-256 hash of each component
  • Vendor metadata, version, SPDX license identifier
  • Generation timestamp and last CVE scan timestamp

Regulatory alignment

Framework Article / Section Coverage
NIS2 (EU 2022/2555) Article 21 — supply chain security Software inventory, vulnerability management
ANSSI SecNumCloud Software inventory Component traceability
Executive Order 14028 (US) SBOM for delivered software Reference only

SBOM API

# Per-package SBOM (CycloneDX)
curl -H "Authorization: Bearer $TOKEN" \
  "http://localhost:8000/api/v1/sbom/nginx/1.24.0?format=cyclonedx&arch=amd64"

# Full repository SBOM
curl -H "Authorization: Bearer $TOKEN" \
  "http://localhost:8000/api/v1/sbom/export?format=cyclonedx"

Audit trail

Format and storage

Property Value
Format JSONL (JSON Lines) — one event per line
Organization One file per day: /repos/audit/YYYY-MM-DD.jsonl
Access Read-only; roles admin, maintainer, auditor
Write mode Append-only — no modification or deletion API
Retention Configurable (default: 90 days)

Event structure

{
  "timestamp": "2026-05-23T14:32:01.412000+00:00",
  "action": "UPLOAD",
  "user": "john.doe",
  "result": "SUCCESS",
  "package": "nginx",
  "version": "1.24.0",
  "detail": "status=pending_review | cve_findings=2"
}

Logged event types

Event Trigger
LOGIN Authentication attempt — result includes source IP
USER_CREATE Account creation (local or LDAP auto-provision)
USER_UPDATE Account modification (role, status, email)
USER_DELETE Account deletion
PASSWORD_CHANGE User-initiated password change
PASSWORD_RESET Admin-initiated or token-based password reset
UPLOAD Package upload (pipeline results included)
VALIDATE Validation pipeline failure detail
DELETE Package deletion
IMPORT Import from an external repository
SYNC Repository index synchronization
SECURITY_DECISION CVE approve/reject decision with justification
RESCAN Package re-scanned for CVEs
CLAMAV_UPDATE Antivirus signature database update
INIT_DISTS Distribution initialized

GDPR note

Audit logs include user IP addresses (personal data under GDPR). Retention must be aligned with the organization's internal data retention policy. Legal basis for processing: legitimate interest / NIS2 legal obligation.


Availability and resilience

High availability (active-passive and active-active)

Repod supports running multiple backend-api replicas against a shared PostgreSQL endpoint and a shared /repos filesystem, for deployments with an availability requirement beyond a single instance.

Measure Status Detail
Leader election PostgreSQL-advisory-lock-based (pg_try_advisory_lock); the leader replica alone runs the scheduled cron jobs (security sync, SLA checks, retention, backups, mirror, drift scans, exposure snapshots)
Automatic failover Advisory lock is session-scoped — a leader process dying releases it automatically; the next replica to (re)start acquires leadership. Requires the orchestrator (Compose restart:, Kubernetes, systemd) to restart the failed container for failover to take effect
Job-creation gating on passive replicas Endpoints that start an in-memory-tracked background job (inventory scan, install, mirror, sync) return 503 on a passive replica rather than silently creating untracked state
Distributed job state (Redis) ✅ (opt-in) JOB_STATE_BACKEND=redis moves scan/install/mirror/sync job progress, cancellation/confirmation, concurrency limits, and logs into Redis, so any replica can serve or act on a job regardless of which replica created it — lifts the leader gate for those flows once verified active
Cross-replica live event delivery ✅ (opt-in) The same Redis backend distributes the dashboard SSE event stream (GET /dashboard/events) and the live backend-log tail (GET /logs/stream) via Redis pub/sub, so a subscriber connected to any replica sees events published on any other
Fail-soft on Redis unavailability Each component falls back to local, in-memory behavior independently if Redis is unreachable, logs the fallback at ERROR level, and — critically — keeps enforcing the leader gate exactly as if Redis were never configured, rather than silently allowing an unsafe distributed write
Operational visibility GET /health exposes checks.info.ha.is_leader, .instance_id, .scheduler_active, and .job_state_backend.{scan,install,mirror,sync,sse,logs} — the last always reflects the backend genuinely in use after any fallback, not the configured intent

Both mechanisms are opt-in and off by default; a standard single-instance deployment is unaffected by any of this. See Architecture — High availability for the design rationale and Deploy multi-replica high availability for the deployment procedure and failure-mode walkthrough.


Infrastructure hardening

Containerization

Measure Status Detail
Docker socket not mounted Backend has no access to /var/run/docker.sock
Source code not mounted in production Only /repos volume mounted
Non-root user Backend runs as appuser (UID 1000)
GPG via shared volume /repos/gnupg shared without Docker socket

Source protection (on-premise anti-tamper)

Measure Status Detail
License/quota enforcement compiled to native code services/license.py, services/quota.py, services/oci_auth.py, auth/jwt.py compiled to .so extension modules (Nuitka) in backend/Dockerfile's builder stage
.py source removed before the final image layer exists Deletion happens in the builder stage, before COPY --from=builder /app /app — copying then deleting in the same final-stage layer would leave the file recoverable from the prior layer (docker history / dive)
Rest of the codebase plaintext Only the license/quota/token-signing enforcement surface is compiled; the wider codebase ships as readable Python — this is anti-tamper hardening of the enforcement path, not general source-code obfuscation

Limits

This raises the bar (readable Python → stripped ELF binary) but is not absolute — a sufficiently determined party with root access to their own on-premise host can still eventually extract the compiled logic. As with any on-premise software, the commercial EULA and audit rights are the primary backstop, not the compilation step alone.

FastAPI application

Measure Status Detail
Swagger UI disabled in production ENV=production/docs returns 404
Hot-reload disabled uvicorn starts without --reload
JWT secret validation at startup Refuses to start with default/empty key
Secret masking in /settings SMTP and LDAP passwords masked as ***

Outbound webhook SSRF protection

Measure Status Detail
Webhook URL validation services/ssrf_guard.py resolves the host and blocks requests to loopback, link-local, multicast, and unspecified addresses
Applies to Notification webhooks (webhook_url) and POST /settings/test-webhook
Private (RFC1918) ranges ✅ allowed Required for on-premise chat tools (e.g. Mattermost on the internal LAN)
DNS resolution failure fail-open If the hostname cannot be resolved, the request is allowed through (treated as a transient DNS issue, not a block)

HTTP security headers

Header Value
X-Frame-Options SAMEORIGIN
X-Content-Type-Options nosniff
X-XSS-Protection 1; mode=block
Referrer-Policy strict-origin-when-cross-origin
Content-Security-Policy default-src 'self'; script-src 'self' 'unsafe-inline'; …
Permissions-Policy camera=(), microphone=(), geolocation=(), payment=()

CSP unsafe-inline

'unsafe-inline' is present in script-src and style-src due to React's use of inline styles. Migration to a nonce-based CSP is planned for v3.


Known limitations and compensating controls

No JWT revocation

Risk Medium
Description JWT tokens remain valid until expiry (60 min) after logout or deactivation
Compensating control Account deactivation is verified on every request; 60-minute window limits exposure
Planned fix Redis-based token blacklist in v2.x

No HTTPS in default configuration

Risk High (if deployed without reverse proxy)
Description TLS termination is delegated to a reverse proxy
Compensating control Mandatory deployment behind Nginx / Traefik / Caddy
Documentation Reverse proxy guide →

Backend port exposed on all interfaces

Risk Medium (without reverse proxy)
Description Port 8000 bound to all interfaces by default
Compensating control Set BIND_HOST=127.0.0.1 in .env + firewall rules

CSP with unsafe-inline

Risk Low (internal interface, no user-controlled script input)
Planned fix Nonce-based CSP in v3

Compliance checklist

NIS2 (EU 2022/2555)

Requirement Status Implementation
Supply chain security CVE pipeline, SBOM, GPG signing, antivirus
Vulnerability management Grype, CISO queue, configurable SLA, EPSS, CISA KEV
Logging and monitoring JSONL audit trail, configurable retention, 19 event types
Access control 5-role RBAC, JWT, API tokens
Incident handling ⚠️ Audit trail present — internal IR procedure required at org level
Encryption in transit ⚠️ Delegated to reverse proxy
Business continuity To be defined at organization level
Security testing Penetration test / code review to be scheduled

ANSSI SecNumCloud

Requirement Status Implementation
Software inventory CycloneDX 1.5 + SPDX 2.3 per package
Audit logs Append-only JSONL, configurable retention
Access control RBAC, least privilege
Environment separation ⚠️ Docker containers — network isolation to be reinforced
Encryption at rest /repos volume unencrypted — manage at OS/infrastructure level
Key management ⚠️ Integrated GPG, validated JWT secret — HSM not included

GDPR

Requirement Status Implementation
Data minimization Only email, role, and IP address collected
Data retention Configurable (default 90 days)
Data security bcrypt hashing, restricted access
Records of processing activities To be documented by the organization

Vulnerability reporting

Security vulnerabilities must be reported through a confidential channel.

Contact: security@[organization]
Channel: Do not use the public issue tracker.
Response SLA: 72 hours for acknowledgment — 30 days for remediation of High/Critical findings.