Files
wehub-resource-sync 7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

249 lines
7.9 KiB
Python

"""Truth-table tests for the two-axis egress classification core (ADR-0007).
Stage A: the core is pure and not yet wired into any PEP. These tests pin the
four-quadrant combination rule so later wiring can't silently change it.
"""
from __future__ import annotations
import pytest
from local_deep_research.security.egress.classification import (
Component,
Exposure,
Label,
Mode,
Role,
Sensitivity,
evaluate_run,
)
S = Sensitivity.SENSITIVE
NS = Sensitivity.NON_SENSITIVE
EXP = Exposure.EXPOSING
CON = Exposure.CONTAINED
# ---------------------------------------------------------------------------
# Helpers — build the components a realistic run is made of
# ---------------------------------------------------------------------------
def source(name: str, sens: Sensitivity, exp: Exposure = CON) -> Component:
return Component(name, Role.SOURCE, Label(sens, exp))
def search_sink(name: str, exp: Exposure, sens: Sensitivity = NS) -> Component:
return Component(name, Role.SEARCH_SINK, Label(sens, exp))
def inference(name: str, exp: Exposure) -> Component:
return Component(name, Role.INFERENCE_SINK, Label(NS, exp))
def web_engine(name: str) -> list[Component]:
"""A public web engine: a non-sensitive source AND an exposing search sink."""
return [source(name, NS), search_sink(name, EXP)]
def dual_risk_store(name: str) -> list[Component]:
"""Quadrant 4: a sensitive source that is also an exposing search sink."""
return [source(name, S, EXP), search_sink(name, EXP, S)]
LOCAL_LLM = inference("llm:ollama", CON)
CLOUD_LLM = inference("llm:anthropic", EXP)
# ---------------------------------------------------------------------------
# Quadrant 1 — non-sensitive + contained: combines with anything
# ---------------------------------------------------------------------------
def test_all_non_sensitive_allows_any_sink():
run = [
source("collection_public", NS),
*web_engine("google"),
CLOUD_LLM,
]
d = evaluate_run(run)
assert d.allowed
assert d.reason == "no_sensitive_source"
# ---------------------------------------------------------------------------
# Quadrant 2 — sensitive + contained: only with other contained sources
# ---------------------------------------------------------------------------
def test_sensitive_collection_with_local_llm_allowed():
run = [source("collection_private", S), LOCAL_LLM]
d = evaluate_run(run)
assert d.allowed
assert d.reason == "sensitive_contained"
def test_two_sensitive_collections_with_local_llm_allowed():
run = [source("collection_a", S), source("collection_b", S), LOCAL_LLM]
assert evaluate_run(run).allowed
def test_sensitive_collection_with_cloud_llm_denied():
run = [source("collection_private", S), CLOUD_LLM]
d = evaluate_run(run)
assert not d.allowed
assert d.reason == "sensitive_to_exposing_inference"
assert d.offending == ("llm:anthropic",)
def test_sensitive_collection_with_public_engine_denied():
run = [source("collection_private", S), *web_engine("arxiv"), LOCAL_LLM]
d = evaluate_run(run)
assert not d.allowed
assert d.reason == "sensitive_to_exposing_search"
assert d.offending == ("arxiv",)
# ---------------------------------------------------------------------------
# Quadrant 3 — non-sensitive + exposing: only with non-sensitive sources
# ---------------------------------------------------------------------------
def test_public_engine_with_public_collection_and_cloud_llm_allowed():
run = [
source("collection_public", NS),
*web_engine("google"),
CLOUD_LLM,
]
assert evaluate_run(run).allowed
# ---------------------------------------------------------------------------
# Quadrant 4 — sensitive + exposing: solo, with contained inference
# ---------------------------------------------------------------------------
def test_dual_risk_store_solo_with_local_inference_allowed():
run = [*dual_risk_store("elasticsearch"), LOCAL_LLM]
d = evaluate_run(run)
assert d.allowed
assert d.reason == "sensitive_contained"
def test_dual_risk_store_with_cloud_inference_denied():
run = [*dual_risk_store("elasticsearch"), CLOUD_LLM]
d = evaluate_run(run)
assert not d.allowed
assert d.reason == "sensitive_to_exposing_inference"
def test_dual_risk_store_with_other_sensitive_source_denied():
run = [
*dual_risk_store("elasticsearch"),
source("collection_private", S),
LOCAL_LLM,
]
d = evaluate_run(run)
assert not d.allowed
assert d.reason == "sensitive_to_exposing_search"
assert d.offending == ("elasticsearch",)
def test_two_dual_risk_stores_denied():
run = [
*dual_risk_store("es_a"),
*dual_risk_store("es_b"),
LOCAL_LLM,
]
assert not evaluate_run(run).allowed
# ---------------------------------------------------------------------------
# Exposing embeddings count as an exposing inference sink
# ---------------------------------------------------------------------------
def test_sensitive_with_cloud_embeddings_denied():
run = [
source("collection_private", S),
LOCAL_LLM,
inference("embeddings:openai", EXP),
]
d = evaluate_run(run)
assert not d.allowed
assert d.reason == "sensitive_to_exposing_inference"
assert d.offending == ("embeddings:openai",)
# ---------------------------------------------------------------------------
# Permissive mode suspends the invariant (caller still warns)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
"run",
[
[source("collection_private", S), CLOUD_LLM],
[source("collection_private", S), *web_engine("arxiv"), LOCAL_LLM],
[*dual_risk_store("es"), CLOUD_LLM],
],
)
def test_permissive_mode_always_allows(run):
enforced = evaluate_run(run)
assert not enforced.allowed # denied when enforcing
d = evaluate_run(run, mode=Mode.PERMISSIVE)
assert d.allowed
# The would-be-enforced diagnostic is preserved for the stage-C banner.
assert d.reason == f"permissive:{enforced.reason}"
assert d.offending == enforced.offending
def test_quadrant4_store_plus_second_exposing_engine_denied():
# A dual-risk store is only allowed SOLO. Add a public web engine and the
# store's sensitive data can now reach that foreign exposing sink -> deny.
# The store's OWN sink is not offending (returning its data to itself is
# not a new leak); only the foreign "arxiv" sink is reported.
run = [*dual_risk_store("es"), *web_engine("arxiv"), LOCAL_LLM]
d = evaluate_run(run)
assert not d.allowed
assert d.reason == "sensitive_to_exposing_search"
assert d.offending == ("arxiv",)
def test_name_collision_does_not_exempt_non_sensitive_sink():
# A NON-sensitive exposing search sink that merely shares a name with an
# unrelated sensitive source must NOT be treated as the solo dual-risk case.
run = [
Component("x", Role.SOURCE, Label(S, CON)), # sensitive source "x"
Component("x", Role.SEARCH_SINK, Label(NS, EXP)), # unrelated sink "x"
LOCAL_LLM,
]
d = evaluate_run(run)
assert not d.allowed
assert d.reason == "sensitive_to_exposing_search"
def test_multi_violation_inference_takes_precedence():
run = [
source("collection_private", S),
*web_engine("google"),
CLOUD_LLM,
]
# Inference leak is checked first; the exposing search engine is masked.
d = evaluate_run(run)
assert not d.allowed
assert d.reason == "sensitive_to_exposing_inference"
# ---------------------------------------------------------------------------
# Degenerate inputs
# ---------------------------------------------------------------------------
def test_empty_run_allowed():
assert evaluate_run([]).allowed
def test_only_contained_inference_no_sources_allowed():
assert evaluate_run([LOCAL_LLM]).allowed