# 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-25` — `Path.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/../../` provided they know a filename. - Write into arbitrary directories the process can write to via `/resources/` 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.cs` — `RegisterUser` 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-12` — `ToHash()` 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` (`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-7` — `AdminPassword`, `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, 12` — `superadmin-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:63` — `logger.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 - [x] All source directories scanned (`Azaion.AdminApi/`, `Azaion.Services/`, `Azaion.Common/`, `env/db/`, `Dockerfile`) - [x] Each finding has file path and (where relevant) line numbers - [x] No false positives from test files or comments — test-fixture credentials (F-10) are explicitly framed as accepted-risk