Files
admin/_docs/05_security/static_analysis.md
T
Oleksandr Bezdieniezhnykh c7b297de83
ci/woodpecker/push/01-test Pipeline failed
ci/woodpecker/push/02-build-push unknown status
refactor: remove deploy.cmd and update Dockerfile for health checks
- Deleted the deploy.cmd script as it was no longer needed.
- Updated Dockerfile to include curl for health checks and added a non-root user for improved security.
- Modified health check command to use curl for better reliability.
- Adjusted docker-compose.test.yml to reflect changes in health check configuration.
- Cleaned up appsettings.json and removed unused configuration properties.
- Removed Resource entity and related requests from the codebase as part of the architectural shift.
- Updated documentation to reflect the removal of hardware binding and related endpoints.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 08:47:21 +03:00

13 KiB

Static Analysis (SAST)

Date: 2026-05-13 Method: targeted code review of the cycle-1 surface (/devices, /classes CRUD, /get-update, /resources/publish) plus regression sweep over the pre-existing endpoints /login, /users/*, /resources/*. No automated SAST tool was run — all findings are manually identified with file/line evidence.

Post-audit status (2026-05-13): F-1 closed via revert of AZ-183 (OTA feature deleted end-to-end); F-3 closed via UNIQUE INDEX users_email_uidx + RegisterUser/RegisterDevice consolidation; F-5 closed automatically as a consequence of F-1; D-1 closed via Newtonsoft.Json 13.0.4 bump. F-2 (path traversal) remains open — pre-existing, deferred to a separate ticket.

Findings

F-1: /get-update exposes per-resource plaintext EncryptionKey to any authenticated caller (HIGH — cycle-1 regression — CLOSED via revert)

  • Severity: High
  • Status: CLOSED (2026-05-13) — the entire OTA feature was deleted: /get-update and /resources/publish endpoints, IResourceUpdateService / ResourceUpdateService / ResourceColumnEncryption, the Resource entity, the resources table, the apiUploaderPolicy policy, the ResourcesConfig.EncryptionMasterKey config field, and the e2e/Azaion.E2E/Tests/ResourceUpdateTests.cs test class. AZ-183 is reverted; the OTA delivery model is itself obsolete in the target architecture (browser-only SaaS + fTPM-secured Jetsons).
  • Category: Broken Access Control / Cryptographic Failures
  • Original locations (now deleted):
    • Azaion.AdminApi/Program.cs:301-312 — endpoint registration used .RequireAuthorization() (any logged-in user).
    • Azaion.Services/ResourceUpdateService.cs:39-48 — every ResourceUpdateItem returned to the caller contained EncryptionKey = ResourceColumnEncryption.Decrypt(resource.EncryptionKey, MasterKey).
  • Description: The resources.encryption_key column was encrypted at rest with ResourcesConfig.EncryptionMasterKey precisely to mitigate DB compromise. But the application API decrypted and serialized that key into the HTTP response for any caller holding a valid JWT. A low-privilege user could submit POST /get-update {Architecture, DevStage, CurrentVersions: {}} and receive — for every published resource — (CdnUrl, Sha256, EncryptionKey). With EncryptionKey + CdnUrl, the attacker could pull the encrypted blob from the CDN and decrypt it locally.
  • Impact (had it shipped): Confidentiality of every published resource (firmware, model weights, installer payloads) reduced to "any authenticated session".
  • Resolution rationale: rather than tightening the policy or filtering the response, the user assessment is that the OTA delivery model itself is no longer needed — the suite is now installed/updated through the browser. Removing the surface eliminates the vulnerability and reduces the attack surface; F-5 (lazy-loaded EncryptionMasterKey) is also closed automatically.

F-2: Path traversal via dataFolder route segment (HIGH — pre-existing, re-flagged)

  • Severity: High
  • Category: Broken Access Control / Injection (Path)
  • Locations:
    • Azaion.Services/ResourcesService.cs:20-25Path.Combine(ResourcesFolder, dataFolder) accepts .. and absolute paths without validation.
    • Consumed by Program.cs:201, 213, 219, 224/resources/{dataFolder?} (any-auth upload), /resources/list/{dataFolder?} (any-auth read), /resources/clear/{dataFolder?} (admin), /resources/get/{dataFolder?} (any-auth read).
  • Description: Path.Combine("Content", "../../etc") resolves to etc, escaping the configured root. A non-admin caller can:
    • List arbitrary directories via /resources/list/../../.
    • Read encrypted contents of arbitrary files via /resources/get/../../<path> provided they know a filename.
    • Write into arbitrary directories the process can write to via /resources/<traversal> upload.
  • Impact: Server-side file disclosure and arbitrary file write bounded by process privileges. Pre-existing — already noted in _docs/00_problem/security_approach.md "Known Security Observations" #3 — but the cycle-1 audit re-confirms it is unmitigated.
  • Remediation:
    • Sanitize dataFolder: reject any value containing .., /, \, or starting with a drive letter; alternatively, allow only [A-Za-z0-9_-]+ segments.
    • Verify that the resolved absolute path starts with the resolved ResourcesFolder absolute path — Path.GetFullPath(combined).StartsWith(Path.GetFullPath(root)) — and reject otherwise.

F-3: users.email lacks a UNIQUE constraint — race in both RegisterUser and (cycle-1) RegisterDevice (HIGH — CLOSED)

  • Severity: High
  • Status: CLOSED (2026-05-13).
  • Resolution:
    • Added migration env/db/06_users_email_unique.sql containing CREATE UNIQUE INDEX IF NOT EXISTS users_email_uidx ON public.users (email);. The migration is wired into e2e/db-init/00_run_all.sh.
    • Azaion.Services/UserService.csRegisterUser no longer check-then-inserts. It catches Npgsql.PostgresException with SqlState == PostgresErrorCodes.UniqueViolation (23505) and rethrows as BusinessException(EmailExists). The race is closed atomically by the index.
    • RegisterDevice was refactored to delegate the row insert to RegisterUser (the user's explicit guidance: "reuse the code in the implementation RegisterDevice -> should call RegisterUser"). Two concurrent provisioning calls that race on the same serial now hit the UNIQUE INDEX and surface BusinessException(EmailExists); the caller can retry.
  • Residual risk: a Postgres sequence for device serials (device_serial_seq) would also remove the serial-allocation race window and avoid the retry. Out of scope for this audit fix; can be added as a follow-up.

F-4: /devices returns plaintext device password in the JSON body (MEDIUM — accepted by design, hardening required)

  • Severity: Medium
  • Category: Cryptographic Failures / Data Exposure
  • Locations:
    • Azaion.AdminApi/Program.cs:158-162
    • Azaion.Services/UserService.cs:84-89 (assembles RegisterDeviceResponse)
  • Description: The endpoint deliberately returns the plaintext device password "exactly once" so the provisioning script can write it to device.conf. ApiAdmin-only, so abuse blast radius is bounded — but the password is reachable via:
    • Reverse-proxy access logs that capture response bodies
    • Browser DevTools / network history when triggered from the admin UI
    • Swagger UI's "Try it out" response panel in any environment where Swagger is exposed (today: IsDevelopment() only — verified).
  • Impact: Credentials may persist in unintended log sinks beyond their intended one-shot consumption.
  • Remediation:
    • Set response headers Cache-Control: no-store, no-cache, Pragma: no-cache on this endpoint specifically.
    • Document an SRE runbook entry: do NOT enable response-body logging on the reverse proxy for POST /devices.
    • Optional: add an X-One-Shot-Credential: true header so log scrubbers can match-and-mask.

F-5: EncryptionMasterKey validation is lazy — first failing request, not startup (MEDIUM — CLOSED)

  • Severity: Medium
  • Status: CLOSED (2026-05-13) — ResourcesConfig.EncryptionMasterKey and the ResourceUpdateService.MasterKey getter were both deleted along with the OTA feature (see F-1). The lazy-validation surface no longer exists; appsettings.json and docker-compose.test.yml no longer reference the field.

F-6: API container runs as root (MEDIUM)

  • Severity: Medium
  • Category: Security Misconfiguration
  • Location: Dockerfile:1, 20-25
  • Description: mcr.microsoft.com/dotnet/aspnet:10.0 defaults to root. There is no USER directive after FROM base AS final. CIS Docker Benchmark §4.1 calls this out: a process running as root inside the container has more privileges than necessary, and a container escape (CVE-2024-21626 class) becomes a root-on-host exploit.
  • Impact: Defense-in-depth weakness. No specific exploit, but failure mode is severe.
  • Remediation: Add USER app to the final stage (the .NET 10 base image already provisions a non-root app user, UID 1654). The Content/log directory permissions need to be checked once the change is made.

F-7: SHA-384 password hashing without per-user salt or KDF (MEDIUM — pre-existing)

  • Severity: Medium
  • Category: Cryptographic Failures
  • Locations:
    • Azaion.Services/Security.cs:11-12ToHash() hashes raw UTF-8 bytes with SHA-384.
    • Azaion.Services/UserService.cs:44, 110, 78 — used for RegisterUser, ValidateUser, RegisterDevice (cycle-1 added the RegisterDevice call site).
  • Description: SHA-384 is a fast cryptographic hash, not a password-hashing algorithm. No per-user salt, no work factor, no memory hardness. A leaked password_hash column lets an offline attacker grind ~10⁹ candidates per second per GPU.
  • Impact: Database leak directly compromises all user passwords in tractable time.
  • Remediation: Migrate to Argon2id (e.g., Konscious.Security.Cryptography.Argon2) or bcrypt (BCrypt.Net-Next) with per-user salt. Two-phase rollout: rehash on next successful login until the SHA-384 column is empty, then drop it.

F-8: No rate limiting on /login (MEDIUM — pre-existing)

  • Severity: Medium
  • Category: Auth Failures
  • Location: Azaion.AdminApi/Program.cs:137-143
  • Description: Combined with F-7, an attacker who can reach /login can brute-force credentials. ASP.NET Core 10 ships AddRateLimiter() out of the box.
  • Remediation: Add a fixed-window or sliding-window limiter scoped to /login (e.g., 10 requests / IP / minute, with exponential backoff).

F-9: LoginRequest, SetUserQueueOffsetsRequest lack server-side validation (LOW — pre-existing)

  • Severity: Low
  • Category: Security Misconfiguration
  • Locations:
    • Azaion.Common/Requests/LoginRequest.cs — no validator class
    • Azaion.Common/Requests/SetUserQueueOffsetsRequest.cs — no validator class
  • Description: Other request DTOs use AbstractValidator<T> (RegisterUserValidator, GetUpdateValidator, etc.). These two are unguarded — LoginRequest accepts any-length email/password, SetUserQueueOffsetsRequest accepts any email shape and any offsets payload.
  • Remediation: Add validators with EmailAddress() + MinimumLength(12) (matching RegisterUserValidator) and bounds checks for Offsets.

F-10: Hardcoded credentials and JWT secret in test fixtures (LOW — accepted)

  • Severity: Low
  • Category: Hardcoded Credentials
  • Locations:
    • docker-compose.test.yml:31-33, 37 — DB credentials, JWT secret, encryption master key as compose env vars.
    • e2e/Azaion.E2E/appsettings.test.json:4-7AdminPassword, UploaderPassword, JwtSecret.
  • Description: These are e2e-only and consistent across the harness. They are NOT used in production builds. Flagged here for visibility only — they MUST NEVER drift into the prod compose / appsettings.
  • Remediation: Add a CI guard: fail the pipeline if any of these literals appear in Azaion.AdminApi/appsettings.json or Azaion.AdminApi/appsettings.Production.json.

F-11: env/db/01_permissions.sql ships placeholder DB passwords as a setup template (LOW)

  • Severity: Low
  • Category: Hardcoded Credentials (template / docs)
  • Location: env/db/01_permissions.sql:2, 7, 12superadmin-pass, admin-pass, readonly-pass.
  • Description: The file is the operator setup template. The e2e harness immediately overrides these with test_password (e2e/db-init/99_test_seed.sql:1-2), so the placeholders never reach a runtime. But the file lives at env/db/ with no header comment marking it template-only.
  • Remediation: Add a top-of-file comment -- TEMPLATE: replace placeholder passwords before applying to any environment. Consider renaming to 01_permissions.example.sql.

F-12: Unstructured logging in ResourcesService.SaveResource (LOW)

  • Severity: Low
  • Category: Logging Failures (operational)
  • Location: Azaion.Services/ResourcesService.cs:63logger.LogInformation($"Resource {data.FileName} Saved Successfully").
  • Description: String interpolation defeats Serilog's structured property capture; the FileName is not searchable as a field. Not a security issue, but flagged because the security-event-logging principle (audit trail) requires structured fields.
  • Remediation: logger.LogInformation("Resource {FileName} saved successfully", data.FileName);

F-13: No HTTPS enforcement in application code (LOW — pre-existing, design)

  • Severity: Low
  • Category: Cryptographic Failures
  • Location: Azaion.AdminApi/Program.cs — no app.UseHttpsRedirection(), no Hsts.
  • Description: HTTPS is assumed at the reverse proxy. Acceptable design choice if and only if the reverse proxy and its config are part of the secure boundary.
  • Remediation: Document the assumption in a deployment runbook; consider UseHsts() when the upstream chain terminates TLS.

Self-verification

  • All source directories scanned (Azaion.AdminApi/, Azaion.Services/, Azaion.Common/, env/db/, Dockerfile)
  • Each finding has file path and (where relevant) line numbers
  • No false positives from test files or comments — test-fixture credentials (F-10) are explicitly framed as accepted-risk