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_activeverified 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):
- 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.
- AND, not OR, with the role matrix above. Scoped access never widens
what a role can do — a
readergranted 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. adminalways 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.- 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. - 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_accessrows 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.