mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-23 02:11:13 +00:00
[AZ-436] [AZ-437] [AZ-438] [AZ-439] Add NFT-SEC-01..05 security scenarios
Batch 87: 6 NFT-SEC blackbox scenarios + 5 helper evaluators + 75 unit tests + cumulative review batches 85-87. * AZ-436 NFT-SEC-01: cache-poisoning safety budget (AC-NEW-9); aggregate false_trust_count ≤ N×1e-6; zero-tolerance default. Canonical-only by default; E2E_NFT_SEC_01_RELEASE_GATE=1 unlocks full matrix. * AZ-437 NFT-SEC-02 + NFT-SEC-05: shared egress-observation evaluator (AC-NEW-10); SEC-02 = 0 packets to non-e2e-net over 5min replay; SEC-05 = DNS-blackhole sidecar healthy + lookup fails + UDP-53 silent. * AZ-438 NFT-SEC-03: AP-only signing rejection (AC-NEW-11); 3 sub-cases (unsigned/wrong-key/replayed) each reject ≤500ms + no position drift. * AZ-439 NFT-SEC-04: probe (always-run) = no-crash + deterministic decode outcome; ASan-fuzz (release-gate) = 0 findings ≥4h; AC-3 corpus floor informational only per spec. Verdict per-batch: PASS_WITH_WARNINGS (5 Low). Cumulative review for batches 85-87 (K=3 window) also PASS_WITH_WARNINGS with 5 cross-batch findings — recommends hygiene PBIs for write_csv_evidence duplication (13 helpers) and _resolve_fixture_path duplication (13 scenarios), plus new tickets for AZ-595 fixture builder + DNS-blackhole sidecar service. Also adds _docs/LESSONS.md documenting the Jira transition-ID lesson (always call getTransitionsForJiraIssue first, never memorize numeric IDs across sessions). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,262 @@
|
||||
"""NFT-SEC-03 — AP rejects unsigned / wrong-key / replayed messages (AZ-438 / AC-NEW-11).
|
||||
|
||||
AP-only. Three sub-cases (sent in order; the runner pauses between
|
||||
each):
|
||||
|
||||
* (a) unsigned ``GPS_INPUT``;
|
||||
* (b) signed-with-wrong-key ``GPS_INPUT``;
|
||||
* (c) replayed-from-tlog signed ``GPS_INPUT`` (counter-replay attack).
|
||||
|
||||
For each: AP MUST emit ``BAD_SIGNATURE`` (or one of the documented
|
||||
equivalent rejection STATUSTEXTs) within ≤500 ms; AP's
|
||||
``GLOBAL_POSITION_INT`` must NOT update from the injected message
|
||||
(``position_drift_m ≤ 1 m`` tolerance).
|
||||
|
||||
iNav is N/A — MSP has no signing layer; the test skips when
|
||||
``fc_adapter == 'inav'`` (AC-1). vio_strategy parameterization (AC-5)
|
||||
runs the AP probe under each strategy because the conftest matrix
|
||||
already enforces it; the SUT's VIO is irrelevant to the AP-side
|
||||
rejection but the parameterization keeps evidence symmetric across the
|
||||
test matrix.
|
||||
|
||||
Production dependencies surfaced to AZ-595 / SUT:
|
||||
|
||||
* fixture JSON shape (below) is sourced from a ``ap-only`` SITL replay
|
||||
with the three injection timestamps + AP STATUSTEXT capture + AP
|
||||
``GLOBAL_POSITION_INT`` capture;
|
||||
* AP build MUST have MAVLink 2.0 signing enabled (per FT-P-09-AP /
|
||||
AZ-416 handshake); otherwise the rejection STATUSTEXT is never
|
||||
emitted and every sub-case fails on AC-2 — a fail-safe outcome,
|
||||
but the test will be noisy until the handshake fixture is wired.
|
||||
|
||||
Fixture JSON shape::
|
||||
|
||||
{
|
||||
"injections": [
|
||||
{"sub_case": "unsigned"|"wrong_key"|"replayed",
|
||||
"injected_at_ms": <int>}, ...
|
||||
],
|
||||
"statustexts": [{"monotonic_ms": <int>, "text": <str>}, ...],
|
||||
"positions": [{"monotonic_ms": <int>,
|
||||
"lat_e7": <int>, "lon_e7": <int>}, ...]
|
||||
}
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from runner.helpers import mavlink_signing_evaluator as mse
|
||||
|
||||
NFT_SEC_03_FIXTURE_ENV_VAR = "E2E_NFT_SEC_03_FIXTURE"
|
||||
NFT_SEC_03_DEFAULT_FIXTURE_NAME = "nft_sec_03_mavlink_signing.json"
|
||||
|
||||
|
||||
@pytest.mark.scenario_id("nft-sec-03")
|
||||
@pytest.mark.traces_to("AC-NEW-11,AC-1,AC-2,AC-3,AC-4,AC-5")
|
||||
def test_nft_sec_03_mavlink_signing(
|
||||
fc_adapter: str,
|
||||
vio_strategy: str,
|
||||
evidence_dir, # type: ignore[no-untyped-def]
|
||||
run_id: str,
|
||||
nfr_recorder, # type: ignore[no-untyped-def]
|
||||
sitl_replay_ready: bool,
|
||||
) -> None:
|
||||
"""AP rejects all three injection sub-cases within ≤500 ms; no position drift."""
|
||||
if fc_adapter == "inav":
|
||||
pytest.skip(
|
||||
"AC-1: NFT-SEC-03 is AP-only; iNav (MSP) has no signing layer."
|
||||
)
|
||||
if not sitl_replay_ready:
|
||||
pytest.skip(
|
||||
"NFT-SEC-03 requires `E2E_SITL_REPLAY_DIR` to point at a "
|
||||
"prepared SITL replay fixture (AZ-595) carrying the three "
|
||||
"injection timestamps + AP STATUSTEXT + GLOBAL_POSITION_INT "
|
||||
"captures. Pure rejection-logic covered by "
|
||||
"e2e/_unit_tests/helpers/test_mavlink_signing_evaluator.py."
|
||||
)
|
||||
|
||||
fixture_path = _resolve_fixture_path()
|
||||
if not fixture_path.is_file():
|
||||
pytest.fail(
|
||||
f"NFT-SEC-03: fixture not found at {fixture_path}. "
|
||||
f"`{NFT_SEC_03_FIXTURE_ENV_VAR}` env var must point at a JSON "
|
||||
"file with the schema documented in the scenario docstring. "
|
||||
"Production dependency: AZ-595 + FT-P-09-AP signing handshake "
|
||||
"(AZ-416)."
|
||||
)
|
||||
|
||||
payload = json.loads(fixture_path.read_text())
|
||||
injections, statustexts, positions = _parse_payload(payload, fixture_path)
|
||||
if len(injections) != 3:
|
||||
pytest.fail(
|
||||
f"NFT-SEC-03 AC-2..AC-4: fixture must contain exactly 3 "
|
||||
f"injections (unsigned + wrong_key + replayed); got "
|
||||
f"{len(injections)} in {fixture_path}."
|
||||
)
|
||||
sub_cases_seen = {inj.sub_case for inj in injections}
|
||||
expected = {mse.SubCase.UNSIGNED, mse.SubCase.WRONG_KEY, mse.SubCase.REPLAYED}
|
||||
if sub_cases_seen != expected:
|
||||
pytest.fail(
|
||||
f"NFT-SEC-03: fixture missing sub-cases {sorted(s.value for s in expected - sub_cases_seen)} "
|
||||
f"in {fixture_path}."
|
||||
)
|
||||
|
||||
report = mse.evaluate(
|
||||
injections, statustexts=statustexts, positions=positions
|
||||
)
|
||||
out_csv = (
|
||||
evidence_dir
|
||||
/ "nft-sec-03"
|
||||
/ f"{fc_adapter}-{vio_strategy}.csv"
|
||||
)
|
||||
mse.write_csv_evidence(out_csv, report)
|
||||
|
||||
for sub in report.sub_cases:
|
||||
if sub.rejection_latency_ms is not None:
|
||||
nfr_recorder.record_metric(
|
||||
f"nft_sec_03.{sub.sub_case.value}.rejection_latency_ms",
|
||||
float(sub.rejection_latency_ms),
|
||||
ac_id=_ac_for(sub.sub_case),
|
||||
)
|
||||
nfr_recorder.record_metric(
|
||||
f"nft_sec_03.{sub.sub_case.value}.position_drift_m",
|
||||
sub.position_drift_m,
|
||||
ac_id=_ac_for(sub.sub_case),
|
||||
)
|
||||
|
||||
for sub in report.sub_cases:
|
||||
ac = _ac_for(sub.sub_case)
|
||||
assert sub.passes_rejection, (
|
||||
f"{ac}: AP did not reject {sub.sub_case.value} GPS_INPUT within "
|
||||
f"{sub.budget_ms} ms — rejection_at_ms={sub.rejection_at_ms}, "
|
||||
f"rejection_text={sub.rejection_text!r}, "
|
||||
f"latency_ms={sub.rejection_latency_ms}."
|
||||
)
|
||||
assert sub.passes_no_position_update, (
|
||||
f"{ac}: AP GLOBAL_POSITION_INT drifted "
|
||||
f"{sub.position_drift_m:.2f} m around injection (tolerance "
|
||||
f"{mse.POSITION_DRIFT_TOLERANCE_M} m) — the rejection STATUSTEXT "
|
||||
f"fired but the position update was accepted. This is a "
|
||||
f"defense-bypass bug (signaling-only rejection without state "
|
||||
f"enforcement)."
|
||||
)
|
||||
|
||||
|
||||
def _ac_for(sub_case: mse.SubCase) -> str:
|
||||
return {
|
||||
mse.SubCase.UNSIGNED: "AC-2",
|
||||
mse.SubCase.WRONG_KEY: "AC-3",
|
||||
mse.SubCase.REPLAYED: "AC-4",
|
||||
}[sub_case]
|
||||
|
||||
|
||||
def _resolve_fixture_path() -> Path:
|
||||
raw = os.environ.get(NFT_SEC_03_FIXTURE_ENV_VAR, "").strip()
|
||||
from runner.helpers import sitl_observer
|
||||
|
||||
root = sitl_observer.replay_dir()
|
||||
if not raw:
|
||||
if root is None:
|
||||
return Path(f"<{NFT_SEC_03_FIXTURE_ENV_VAR}-unset>")
|
||||
return root / NFT_SEC_03_DEFAULT_FIXTURE_NAME
|
||||
path = Path(raw)
|
||||
if not path.is_absolute() and root is not None:
|
||||
path = root / path
|
||||
return path
|
||||
|
||||
|
||||
def _parse_payload(
|
||||
payload: object, fixture_path: Path
|
||||
) -> tuple[
|
||||
list[mse.InjectionEvent],
|
||||
list[mse.StatustextSample],
|
||||
list[mse.PositionSample],
|
||||
]:
|
||||
if not isinstance(payload, dict):
|
||||
pytest.fail(
|
||||
f"NFT-SEC-03: fixture {fixture_path} must be a JSON object; "
|
||||
f"got top-level type={type(payload).__name__}"
|
||||
)
|
||||
raw_inj = payload.get("injections")
|
||||
if not isinstance(raw_inj, list):
|
||||
pytest.fail(
|
||||
f"NFT-SEC-03: fixture {fixture_path} 'injections' must be a list"
|
||||
)
|
||||
injections: list[mse.InjectionEvent] = []
|
||||
for idx, entry in enumerate(raw_inj):
|
||||
if not isinstance(entry, dict):
|
||||
pytest.fail(
|
||||
f"NFT-SEC-03: injections[{idx}] in {fixture_path} must be an object"
|
||||
)
|
||||
try:
|
||||
sub_case = mse.SubCase(str(entry["sub_case"]))
|
||||
except (KeyError, ValueError) as exc:
|
||||
pytest.fail(
|
||||
f"NFT-SEC-03: injections[{idx}] in {fixture_path} 'sub_case' "
|
||||
f"must be one of {sorted(s.value for s in mse.SubCase)}; got {exc}"
|
||||
)
|
||||
try:
|
||||
injections.append(
|
||||
mse.InjectionEvent(
|
||||
sub_case=sub_case,
|
||||
injected_at_ms=int(entry["injected_at_ms"]),
|
||||
)
|
||||
)
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
pytest.fail(
|
||||
f"NFT-SEC-03: injections[{idx}] in {fixture_path} shape invalid: {exc}"
|
||||
)
|
||||
|
||||
raw_st = payload.get("statustexts", [])
|
||||
if not isinstance(raw_st, list):
|
||||
pytest.fail(
|
||||
f"NFT-SEC-03: fixture {fixture_path} 'statustexts' must be a list"
|
||||
)
|
||||
statustexts: list[mse.StatustextSample] = []
|
||||
for idx, entry in enumerate(raw_st):
|
||||
if not isinstance(entry, dict):
|
||||
pytest.fail(
|
||||
f"NFT-SEC-03: statustexts[{idx}] in {fixture_path} must be an object"
|
||||
)
|
||||
try:
|
||||
statustexts.append(
|
||||
mse.StatustextSample(
|
||||
monotonic_ms=int(entry["monotonic_ms"]),
|
||||
text=str(entry["text"]),
|
||||
)
|
||||
)
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
pytest.fail(
|
||||
f"NFT-SEC-03: statustexts[{idx}] in {fixture_path} shape invalid: {exc}"
|
||||
)
|
||||
|
||||
raw_pos = payload.get("positions", [])
|
||||
if not isinstance(raw_pos, list):
|
||||
pytest.fail(
|
||||
f"NFT-SEC-03: fixture {fixture_path} 'positions' must be a list"
|
||||
)
|
||||
positions: list[mse.PositionSample] = []
|
||||
for idx, entry in enumerate(raw_pos):
|
||||
if not isinstance(entry, dict):
|
||||
pytest.fail(
|
||||
f"NFT-SEC-03: positions[{idx}] in {fixture_path} must be an object"
|
||||
)
|
||||
try:
|
||||
positions.append(
|
||||
mse.PositionSample(
|
||||
monotonic_ms=int(entry["monotonic_ms"]),
|
||||
lat_e7=int(entry["lat_e7"]),
|
||||
lon_e7=int(entry["lon_e7"]),
|
||||
)
|
||||
)
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
pytest.fail(
|
||||
f"NFT-SEC-03: positions[{idx}] in {fixture_path} shape invalid: {exc}"
|
||||
)
|
||||
|
||||
return injections, statustexts, positions
|
||||
Reference in New Issue
Block a user