Files
satellite-provider/_docs/05_security/static_analysis_cycle8.md
T
Oleksandr Bezdieniezhnykh ac40a8b352 [AZ-808] [AZ-809] [AZ-810] [AZ-811] [AZ-812] Cycle 8 security audit
PASS_WITH_WARNINGS. Zero Critical / High.

New cycle-8 findings:
- F-AZ809-1 (Medium / A04 Insecure Design): unbounded
  geofences.polygons enables an authenticated DoS on
  POST /api/satellite/route. Cap candidate: 50 or 500.
- F-AZ810-1 (Low / A09): JsonException.Message echoed in
  UavUploadValidationFilter (new instance of cycle-7 F-AZ795-1
  pattern in a second code path).
- F-AZ810-2 (Low / Informational): UavTileMetadata.CapturedAt
  typed DateTime not DateTimeOffset; freshness window drifts in
  non-UTC dev environments. Zero impact in UTC-deployed prod.

Carry-overs (cycle 7): F-AZ795-1, F-AZ795-2, D-AZ795-1 still
open. Cycle 4 D2-cy4 still open (test-runtime Medium).

Cycle-8 architectural wins recorded: per-endpoint validation
reached 100% coverage; three approved validation paths
formalised; OSM wire-format normalisation under strict mode
(AZ-812); UAV-handler defence-in-depth retained.

Highest-priority cycle-9 follow-up: F-AZ809-1 polygon cap.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-23 15:17:31 +03:00

27 KiB
Raw Blame History

Static Analysis (Cycle 8)

Date: 2026-05-23 Mode: Delta scan Scope: Source code introduced or changed by AZ-808 + AZ-809 + AZ-810 + AZ-811 + AZ-812. Cycle-7 baseline (static_analysis_cycle7.md) remains authoritative for the AZ-794 / AZ-795 / AZ-796 surface; this scan only audits the cycle-8 delta.

Files in scope (40 changed source files; non-test detail):

  • API — 11 files
    • SatelliteProvider.Api/Program.cs (DI + endpoint wiring deltas — WithValidation<RequestRegionRequest>, WithValidation<CreateRouteRequest>, WithValidation<GetTileByLatLonQuery>, AddEndpointFilter<UavUploadValidationFilter>, AddTransient<UavUploadValidationFilter>, RejectUnknownQueryParamsEndpointFilter registration)
    • SatelliteProvider.Api/DTOs/GetTileByLatLonQuery.cs (new — record with nullable bindings)
    • SatelliteProvider.Api/Validators/RegionRequestValidator.cs (new — AZ-808)
    • SatelliteProvider.Api/Validators/CreateRouteRequestValidator.cs (new — AZ-809)
    • SatelliteProvider.Api/Validators/GeofencePolygonValidator.cs (new — AZ-809)
    • SatelliteProvider.Api/Validators/RoutePointValidator.cs (new — AZ-809)
    • SatelliteProvider.Api/Validators/UavTileMetadataValidator.cs (new — AZ-810)
    • SatelliteProvider.Api/Validators/UavTileBatchMetadataPayloadValidator.cs (new — AZ-810)
    • SatelliteProvider.Api/Validators/UavUploadValidationFilter.cs (new — AZ-810)
    • SatelliteProvider.Api/Validators/GetTileByLatLonQueryValidator.cs (new — AZ-811)
    • SatelliteProvider.Api/Validators/RejectUnknownQueryParamsEndpointFilter.cs (new — AZ-811)
    • SatelliteProvider.Api/Swagger/ParameterDescriptionFilter.cs (AZ-811 — added lat/lon/zoom description entries)
  • Common — 6 DTO files (AZ-808/809/810/812 — [JsonRequired] annotations + AZ-812 rename)
    • SatelliteProvider.Common/DTO/RequestRegionRequest.cs
    • SatelliteProvider.Common/DTO/CreateRouteRequest.cs
    • SatelliteProvider.Common/DTO/GeofencePolygon.cs
    • SatelliteProvider.Common/DTO/GeoPoint.cs
    • SatelliteProvider.Common/DTO/RoutePoint.cs
    • SatelliteProvider.Common/DTO/UavTileMetadata.cs
  • Test code (reviewed for fixture-only secrets + auth-bypass patterns) — 8 new validator unit tests + 4 new integration test files + several modified integration test helpers.
  • Shell scripts (reviewed for embedded secrets + unsafe sequences) — 4 new probe scripts (probe_latlon_validation.sh, probe_region_validation.sh, probe_route_validation.sh, probe_upload_validation.sh) + 1 modified perf script (run-performance-tests.sh, wire-rename diff only).

Method: Read each new file end-to-end; targeted Grep for injection / hardcoded-credential / unsafe-API patterns (password|secret|api.?key|bearer|token over SatelliteProvider.Api/Validators returned 0 matches); diff-review of every DTO change vs. its cycle-7 baseline; trace each [JsonRequired] chain through to its FluentValidation rule.

Findings

F-AZ809-1 — Unbounded geofences.polygons collection enables an authenticated DoS via CreateRouteRequest (Medium / A04 — Insecure Design)

  • Location: SatelliteProvider.Api/Validators/CreateRouteRequestValidator.cs:72-82 (When(req => req.Geofences is not null, () => …)).
  • Description: The CreateRouteRequestValidator chains a RuleForEach(req => req.Geofences!.Polygons).SetValidator(new GeofencePolygonValidator()) block but only enforces NotEmpty on the collection. There is no upper bound on Geofences.Polygons.Count. The parent collection Points IS capped (MaxPoints = 500 at line 27) — the polygons collection is the only nested list-bearing field on this endpoint without a cap.
  • Code:
    When(req => req.Geofences is not null, () =>
    {
        RuleFor(req => req.Geofences!.Polygons)
            .NotNull().WithMessage("`geofences.polygons` is required when `geofences` is present.")
            .NotEmpty().WithMessage("`geofences.polygons` must contain at least 1 polygon when `geofences` is present.")
            .OverridePropertyName("geofences.polygons");
    
        RuleForEach(req => req.Geofences!.Polygons)
            .SetValidator(new GeofencePolygonValidator())
            .OverridePropertyName("geofences.polygons");
    });
    
  • Exploit math (worst-case envelope under current configuration):
    • KestrelServerOptions.Limits.MaxRequestBodySize = uavBatchBodyLimit = MaxBatchSize × MaxBytes = 100 × 5 MiB = 500 MiB (set globally in Program.cs:41-43 for the UAV endpoint; the same limit applies to every endpoint by default because Kestrel exposes a per-server, not per-endpoint, default).
    • Minimum JSON polygon footprint: {"northWest":{"lat":1.0,"lon":2.0},"southEast":{"lat":3.0,"lon":4.0}} ≈ 90 bytes including the comma separator.
    • Theoretical maximum polygon count in a single 500 MiB request: 500 MiB / 90 bytes ≈ 5.8 million polygons.
    • With invalid polygons (e.g. lat out of range), GeofencePolygonValidator adds 2 corner-range ValidationFailure objects + 1 cross-field NW-of-SE failure = ~3 failures per polygon. Worst-case allocation: ~17 million ValidationFailure instances + the matching error-map keys before the filter formats the ValidationProblemDetails body.
  • Impact: Medium. A single authenticated authorized request can saturate the LOH (large object heap) and trigger a full GC pass on the API process. The endpoint is RequireAuthorization()-gated (line 251 in Program.cs) so anonymous callers cannot reach the validator — the attacker must hold a valid JWT. But once authenticated, a single malformed request degrades the service for every other tenant operator until GC reclaims the heap. Repeatable. No data leak, no privilege escalation; pure availability impact.
  • OWASP mapping: A04 — Insecure Design (missing rate/size limit on a collection-bearing input field). Adjacent to A05 (Security Misconfiguration — the global Kestrel limit was set for the UAV endpoint in cycle 5 but applies to every endpoint).
  • Remediation: Add Must(p => p is null || p.Count <= MaxPolygons) with a defensible upper bound. Reasonable cap candidates:
    • MaxPolygons = 50 (consistent with the historical use case — a route is unlikely to need more than a handful of geofence rectangles for AOI restriction).
    • MaxPolygons = MaxPoints = 500 (consistent with the sibling Points cap on the same DTO).
    • The matching cumulative_review_batches_01-04_cycle8_report.md already enumerates points.Count <= 500 (route), items.Count <= 100 (UAV upload), coords.Count <= 1000 (tile inventory, cycle 7) as bounded — the missing entry for geofences.polygons is the one inconsistency.
  • Status: open — file as a cycle-9 follow-up under AZ-809 (or a sibling child ticket). Not release-blocking for cycle 8 itself: exploitation requires an authenticated caller with a valid GPS-permission-less JWT — the same threat model already had access to the cycle-7-pre-existing inventory.Tiles.Count cap, so the marginal new exposure is moderate, not catastrophic. But it MUST be fixed before any untrusted-tenant exposure is added to the route endpoint.

F-AZ810-1 — JsonException.Message propagated to client in UavUploadValidationFilter (Low / A09 — Information Disclosure)

  • Location: SatelliteProvider.Api/Validators/UavUploadValidationFilter.cs:75-84 (the catch (JsonException ex) block of the metadata parse).
  • Code:
    catch (JsonException ex)
    {
        return Results.ValidationProblem(new Dictionary<string, string[]>
        {
            [MetadataField] = new[] { $"`metadata` could not be parsed as JSON: {ex.Message}" },
        });
    }
    
  • Description: The cycle-8 UavUploadValidationFilter echoes the raw JsonException.Message directly to the client as the value of errors["metadata"]. This is the same information-disclosure pattern as cycle-7 F-AZ795-1 (in GlobalExceptionHandler.cs:108-117), introduced in a second code path that bypasses the global exception handler (the filter intercepts and returns Results.ValidationProblem(...) directly). Cycle-7 F-AZ795-1 remains open; cycle 8 adds a second instance of the same pattern that would also need to be sanitised by the same remediation.
  • Impact: Low. Same severity classification as F-AZ795-1. Auth-gated (.RequireAuthorization(SatellitePermissions.UavUploadPolicy) at Program.cs:238) — only callers holding a valid JWT with the GPS permission claim can reach the filter and trigger this path. The leaked content (type names, parse positions, System.Text.Json fingerprint) is already inferable from the OpenAPI spec; the new path narrows the attack surface for an authenticated GPS-permissioned operator but does not expose secrets, PII, or pivot vectors.
  • Remediation: Sanitise the response message to a generic string (e.g. "metadata could not be parsed as JSON. See the server log for details.") while continuing to log the raw ex.Message server-side under the request's correlationId. Best done in tandem with F-AZ795-1's remediation since both paths surface the same exception class through the same response shape. Add an integration-test assertion in UavUploadValidationTests that no System.* substring appears in the response body's errors[] value, mirroring the cycle-7 gap noted in static_analysis_cycle7.md § F-AZ795-1 Test coverage gap.
  • Status: open — file as a cycle-9 follow-up child of the same F-AZ795-1 ticket so both call sites get the sanitiser at once.

F-AZ810-2 — UavTileMetadata.CapturedAt typed DateTime not DateTimeOffset (Low / Informational — Time-handling correctness)

  • Location: SatelliteProvider.Common/DTO/UavTileMetadata.cs:30 + SatelliteProvider.Api/Validators/UavTileMetadataValidator.cs:52-60.

  • Code:

    // UavTileMetadata.cs:30
    [JsonRequired]
    public DateTime CapturedAt { get; init; }
    
    // UavTileMetadataValidator.cs:52-60
    RuleFor(m => m.CapturedAt)
        .Must(capturedAt => capturedAt.ToUniversalTime() <= tp.GetUtcNow().UtcDateTime.AddSeconds(futureSkewSeconds))
            .WithMessage($"`capturedAt` must be within {futureSkewSeconds}s of the current time (no future-dated tiles).")
        .Must(capturedAt => capturedAt.ToUniversalTime() >= tp.GetUtcNow().UtcDateTime.AddDays(-maxAgeDays))
            .WithMessage($"`capturedAt` must be within the last {maxAgeDays} days.");
    
  • Description: When System.Text.Json deserializes an ISO-8601 string into a DateTime, the resulting DateTime.Kind depends on the string's offset suffix:

    • "2026-05-22T12:00:00Z"Kind = Utc.
    • "2026-05-22T12:00:00+03:00"Kind = Local after normalization to local time.
    • "2026-05-22T12:00:00" (no suffix) → Kind = Unspecified.

    For Kind = Unspecified, DateTime.ToUniversalTime() treats the value as local time, which means the freshness comparison drifts by the host's timezone offset. In a UTC-deployed prod container (Linux TZ=UTC), the local offset is zero and there is no observable impact. In a developer's local environment (e.g. TZ=Europe/Kyiv = UTC+02:00 or +03:00), a capturedAt value of "2026-05-22T12:00:00" would be treated as 2026-05-22T10:00:00Z (in summer), shifting the freshness window by the offset.

  • Impact: Low / Informational. Zero observable impact in the supported deployment configuration (Docker containers run in UTC by default; the cycle-2 docker-compose.yml does not override TZ). The freshness rule could be loosely-bounded by the same offset (in either direction) in a dev environment with a non-UTC host TZ. No security exploit: an attacker cannot force the server's TZ; they can only submit capturedAt values, and the server's UTC-deployed configuration treats those deterministically.

  • OWASP mapping: A09 — Security Logging and Monitoring Failures (adjacent — a stale freshness window could mask an out-of-band attack on the upload endpoint).

  • Remediation (two options):

    1. Strict: Change the DTO type to DateTimeOffset so the parsed value always carries an explicit offset, and add a JSON converter that rejects offset-less ISO-8601 strings at deserialization (JsonConverterAttribute pointing at a custom converter that checks for the Z / +HH:MM suffix and throws JsonException if missing — surfaces as a 400 via GlobalExceptionHandler).
    2. Lenient: Add a FluentValidation rule that rejects DateTime.Kind == DateTimeKind.Unspecified so the caller must supply a tz-aware ISO-8601 string. Keeps the DTO shape; doesn't break clients that already send "Z" suffix.

    Option 2 is the minimum behaviour-preserving fix. Option 1 is correct for a v2.0 of uav-tile-upload.md.

  • Status: open — file as a Low cycle-9 follow-up. Not release-blocking for cycle 8 because every documented client in uav-tile-upload.md example payloads and every integration-test fixture sends the Z suffix.

Pattern Sweep — Cycle-8 Delta

Injection (SQL / Command / XSS / Template)

Pattern Result
string.Format, interpolation $"...", or concatenation feeding into a Dapper / Npgsql command in the new files None. The 9 new files in SatelliteProvider.Api/Validators/ and SatelliteProvider.Api/DTOs/ do not touch the data layer.
Process.Start, subprocess, eval, Invoke-Expression, raw system() None.
User-input echoed into HTML (XSS) None. The API returns JSON only. The cycle-8 RejectUnknownQueryParamsEndpointFilter echoes the offending parameter name back as a JSON string value — System.Text.Json performs canonical JSON escaping of control characters; no HTML injection vector.
Template injection (Razor / Liquid / etc.) None. No templating in the new files.

Authentication & Authorization

Pattern Result
Hardcoded credentials, secrets, API keys `Grep -i 'password
Missing .RequireAuthorization() on a public endpoint Every cycle-8 endpoint binding in Program.cs retains .RequireAuthorization(): RequestRegion (line 251), CreateRoute (267), GetTileByLatLon (213), UploadUavTileBatch (238 — additionally requires the GPS permission claim via SatellitePermissions.UavUploadPolicy).
Validator running before auth check No. ASP.NET Core endpoint filters run AFTER the routing layer's authorization middleware (app.UseAuthorization() at Program.cs:206). All four cycle-8 filters (ValidationEndpointFilter<RequestRegionRequest>, ValidationEndpointFilter<CreateRouteRequest>, RejectUnknownQueryParamsEndpointFilter + ValidationEndpointFilter<GetTileByLatLonQuery>, UavUploadValidationFilter) cannot run for anonymous callers — the 401 short-circuit fires first.
Permission/policy regression RequiresGpsPermission on /api/satellite/upload retained. RejectUnknownQueryParamsEndpointFilter does NOT call RequireAuthorization() itself — it relies on the endpoint chain's existing .RequireAuthorization() (line 213). Correct.

Cryptographic Failures

Pattern Result
Weak hash (MD5 / SHA1) used for passwords or signatures None in cycle 8. Pre-existing UUIDv5 SHA-1 surface (Uuidv5.Create) is untouched.
New crypto material introduced None. Cycle 8 has no cryptography.
Plaintext transmission API listens on https://+:8080 with ALPN (cycle-6 baseline, unchanged).

Data Exposure

Pattern Result
Sensitive data in logs The new validators and filters do NOT log the request body. GlobalExceptionHandler 5xx branch logs Method, Path, correlationId, and the exception object (pre-existing). Cycle 8 doesn't widen this.
Sensitive fields in API responses F-AZ810-1 (JsonException.Message echo) above is the only new echo-back path. No password hashes, no PII (the four endpoints carry only metadata: geospatial coords, integer IDs, timestamps). The RejectUnknownQueryParamsEndpointFilter echoes the offending parameter NAME (not value) back — and the list of allowed param names IS already in the OpenAPI spec, so no new fingerprinting surface.
Debug endpoints in production Swagger is gated by app.Environment.IsDevelopment() (unchanged).
Secrets in version control .env* files are gitignored. The 4 new probe scripts (probe_*_validation.sh) all use ${JWT:?…}-style env-var reads with explicit "set JWT env var" error guards; no embedded credentials.

Insecure Deserialization

Pattern Result
Pickle / BinaryFormatter / unsafe XML / JsonConvert.DeserializeObject<T> with TypeNameHandling.All None in cycle 8. UavUploadValidationFilter.cs:73 uses JsonSerializer.Deserialize<UavTileBatchMetadataPayload>(...) with the global JsonSerializerOptions from IOptions<JsonOptions> — which has UnmappedMemberHandling.Disallow set by cycle-7 ConfigureHttpJsonOptions. ✓
Unbounded collection sizes F-AZ809-1 abovegeofences.polygons lacks a cap. Other cycle-8 list fields ARE capped: Points.Count <= 500 (CreateRouteRequestValidator:60-61), Items.Count <= MaxBatchSize=100 (UavTileBatchMetadataPayloadValidator:27-28). Cycle-7 Tiles.Count <= 5000 / LocationHashes.Count <= 5000 unchanged. The framework-level MaxRequestBodySize bound is in force for all paths — but it's set at 500 MiB to accommodate the UAV upload endpoint, which makes per-endpoint count caps the primary defence.

Integer Overflow / Bounded Math

The cycle-8 validators do not perform any integer arithmetic beyond Math.Max(options.ValueLengthLimit, uavQuality.MaxBatchSize * 512) (Program.cs:61100 * 512 = 51_200, no overflow possible). No left-shifts, no power-of-two computations on caller-controlled values. ✓

ReDoS / Algorithmic Complexity

Cycle-8 validation rules are all O(1) per entry × N entries with bounded N (with the F-AZ809-1 exception above):

Validator Per-entry cost Total cost (worst case)
RegionRequestValidator O(1) — 5 range checks on scalar fields O(1)
CreateRouteRequestValidator (root + cross-field invariant) O(1) base + O(N points) + O(P polygons unbounded) O(N × P) with unbounded P — see F-AZ809-1
RoutePointValidator (via RuleForEach) O(1) — 2 range checks O(500) bounded by parent MaxPoints
GeofencePolygonValidator (via RuleForEach) O(1) — 2 corner null + 4 range + 2 cross-field O(P) where P is unbounded — see F-AZ809-1
UavTileMetadataValidator (via RuleForEach) O(1) — 4 range checks + 2 freshness O(100) bounded by MaxBatchSize
UavTileBatchMetadataPayloadValidator (root) O(1) O(1)
GetTileByLatLonQueryValidator O(1) — 3 cascaded NotNull + range O(1)
RejectUnknownQueryParamsEndpointFilter O(K log K) where K = caller's query-param count O(K) bounded by Kestrel's per-request header/query limits (default 32 KB of query string)

No regex, no recursion, no nested loops with caller-controlled bounds (except F-AZ809-1).

UavUploadValidationFilter Defence-in-Depth Verification

The filter intercepts the primary multipart-upload code path. The pre-existing UavTileUploadHandler.HandleAsync retains the SAME envelope checks (SatelliteProvider.Services.TileDownloader/UavTileUploadHandler.cs:64-141) — confirming the filter is defence-in-depth on top of the handler's checks, not a replacement:

Check UavUploadValidationFilter (filter layer) UavTileUploadHandler (handler layer — defence-in-depth)
metadata form field present + non-empty Line 62-68 Line 68-71 (string.IsNullOrWhiteSpace)
metadata parses as JSON Line 71-84 (catches JsonException) Line 74-81 (catches JsonException) — same pattern, same ex.Message echo (pre-existing parallel to F-AZ810-1)
metadata.items non-null + non-empty Line 86-92 (payload is null) + UavTileBatchMetadataPayloadValidator:25-26 (NotNull/NotEmpty) Line 83-86 (Items.Count == 0)
metadata.items.Count == files.Count Line 105-118 Line 88-91
metadata.items.Count <= MaxBatchSize UavTileBatchMetadataPayloadValidator:27-28 Line 93-96

The handler's envelope checks are still reachable by any caller invoking IUavTileUploadHandler.HandleAsync(...) directly (e.g. unit tests, or a future programmatic flow). Cycle 8 left them intact — correct ✓. The duplicated JsonException.Message echo in the handler (line 80) is a pre-existing-class instance of the same Low finding as F-AZ810-1; the remediation MUST sanitise both call sites in lock-step or the defence-in-depth path will continue to leak.

Pre-existing-Surface Inconsistency Noted (NOT a cycle-8 regression)

Program.cs:387-399 — the CreateRoute handler still wraps its body in try { ... } catch (ArgumentException ex) { return Results.BadRequest(new { error = ex.Message }); }. This is a pre-cycle-8 inconsistency: the response shape is the unstructured {error: string} object, NOT the ValidationProblemDetails shape mandated by error-shape.md v1.0.0. Cycle 8 added WithValidation<CreateRouteRequest> at line 268 which intercepts most ArgumentException cases — so the catch block is now largely dead code, but it WILL fire if IRouteService.CreateRouteAsync itself throws ArgumentException (e.g. a future internal validation rule). When it fires, the response leaks ex.Message in a non-conformant shape.

  • Severity: Low / Informational. Pre-existing inconsistency; cycle 8 reduced its reachable surface but did not eliminate it.
  • Remediation: Drop the try/catch block now that the validator covers the same cases at the filter layer; let unexpected ArgumentException propagate to GlobalExceptionHandler for uniform 400 + sanitised ValidationProblemDetails (or 500 with a correlationId if the cause is internal). One-line edit; recommend folding into the same cycle-9 follow-up as F-AZ795-1 / F-AZ810-1 since all three converge on the same error-shape consistency improvement.

Test Code Review

SatelliteProvider.Tests/Validators/*ValidatorTests.cs (8 new files)

  • Pure CPU; no I/O, no network, no file system, no DB.
  • All inputs constructed inline. No fixture-file reads, no hardcoded JWTs.
  • All test files share the cycle-7 ValidatorTestModuleInitializer.cs ([ModuleInitializer]GlobalValidatorConfig.ApplyOnce() at test-assembly load) — single source of truth for the camelCase property resolver. No test drift risk.
  • ✓ No findings.

SatelliteProvider.IntegrationTests/{CreateRouteValidationTests,GetTileByLatLonValidationTests,RegionFieldRenameTests,RegionRequestValidationTests,UavUploadValidationTests}.cs + modified helpers

  • All five new files use the runner-side JwtTestHelpers.MintAuthenticated(...) to attach a Bearer token, mirroring cycle 7's pattern. No hardcoded secret material; the token's signing secret comes from the JWT_SECRET env var (32+ bytes, dev-only in docker-compose.tests.yml).
  • Test inputs use raw HttpRequestMessage with hand-built JSON strings — exercises the exact wire shape the validator + deserializer see in production. Each file covers the cycle-8 negative cases for its endpoint.
  • ProblemDetailsAssertions.cs was extended (additive) — no removed assertions. The cycle-7 error-shape.md Inv-2 / Inv-4 contract assertions all still apply.
  • UavUploadTests.cs + UavUploadValidationTests.cs both clamp their fixture-generated coordinates into non-overlapping OSM-valid sub-ranges (cycle-8 hotfix commit b763da3 per _docs/03_implementation/batch_04_cycle8_report.md § AC-9) — a test-data correctness fix, not a security finding.
  • ✓ No findings.

scripts/probe_{latlon,region,route,upload}_validation.sh (4 new files)

  • All four scripts begin with set -euo pipefail — fail-fast on undefined vars, broken pipes, command failures.
  • All four read ${API_URL:-https://localhost:8080} (default to localhost) and ${JWT:-} with an explicit "ERROR: set JWT env var" guard that exit 2s if $JWT is empty. No embedded credentials.
  • curl -k is used (justified — the dev cert is self-signed; the scripts target localhost in dev/test only — documented in each script's header).
  • ✓ No findings — same posture as cycle-7's probe_inventory_validation.sh.

scripts/run-performance-tests.sh (modified, wire-rename only)

  • Diff is exclusively the AZ-812 wire-format rename: ?Latitude=…&Longitude=…&ZoomLevel=…?lat=…&lon=…&zoom=… and {"latitude":…,"longitude":…}{"lat":…,"lon":…} across PT-01 through PT-08 invocations. No new code, no new credentials, no shell-injection surface introduced.
  • ✓ No findings.

Cycle-7 Carry-overs (still open at cycle-8 tip)

Finding Source cycle Carry-over status Cycle-8 interaction
F-AZ795-1JsonException.Message propagated in GlobalExceptionHandler.cs:108-117 cycle 7 open Unchanged. Cycle 8 introduced F-AZ810-1 which is the SAME pattern in a NEW code path; both need lock-step sanitiser remediation.
F-AZ795-2 — Generic BadHttpRequestException.Message propagated in GlobalExceptionHandler.cs:88-93 cycle 7 open Unchanged. Cycle 8 routes more endpoint failures through WithValidation<T>() which builds ValidationProblemDetails directly — reducing the practical reachability of this path on the 4 newly-validated endpoints, but not eliminating it (model-binding failures pre-validator can still hit the path).

Verdict (Phase 2)

PASS_WITH_WARNINGS — 1 Medium (F-AZ809-1) + 2 new Lows (F-AZ810-1, F-AZ810-2) + 2 cycle-7 Low carry-overs (F-AZ795-1, F-AZ795-2). No Critical or High findings.

Per the skill's verdict-logic, Medium severity yields PASS_WITH_WARNINGS — not FAIL (FAIL is reserved for Critical or High). F-AZ809-1 is exploitable only by an authenticated tenant operator with a valid JWT (the route endpoint requires RequireAuthorization() without a permission scope, so any tenant with API access reaches it). The Medium is contained within the cycle-8 threat model — every cycle-8 endpoint is auth-gated — but should be the highest-priority cycle-9 follow-up: pre-existing-class Mediums tend to be the entry vector for higher-severity issues when adjacent threat-model assumptions shift (e.g. if a future feature exposes the route endpoint to an untrusted-tenant audience).

The 2 new Lows + 2 carry-over Lows are not release-blockers in isolation; they are filed for the next cycle's follow-up batch.