fix: close evidence credential disclosure gaps
This commit is contained in:
@@ -199,5 +199,5 @@ export function resolveRuntimeBindings(
|
|||||||
export function supportsSessionRuntime(bindings: RuntimeBindings): boolean {
|
export function supportsSessionRuntime(bindings: RuntimeBindings): boolean {
|
||||||
return bindings.dwh.transport !== "ssh_tunnel"
|
return bindings.dwh.transport !== "ssh_tunnel"
|
||||||
&& bindings.vector.transport !== "ssh_tunnel"
|
&& bindings.vector.transport !== "ssh_tunnel"
|
||||||
&& (bindings.evidence?.missing.length ?? 0) === 0;
|
&& bindings.evidence.missing.length === 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2800,15 +2800,19 @@ test("POST /sessions bootstrap failure emits only a fixed recovery message", asy
|
|||||||
|
|
||||||
|
|
||||||
test.each([
|
test.each([
|
||||||
{ mode: "missing signed Evidence file", evidence: true, safe: false, expectedStatus: 409, reachesReadiness: false },
|
{ mode: "missing signed Evidence file", evidence: true, binding: "missing", expectedStatus: 409, reachesReadiness: false },
|
||||||
{ mode: "safe signed Evidence file", evidence: true, safe: true, expectedStatus: 503, reachesReadiness: true },
|
{ mode: "unsafe signed Evidence file", evidence: true, binding: "unsafe", expectedStatus: 409, reachesReadiness: false },
|
||||||
{ mode: "no Evidence descriptor", evidence: false, safe: false, expectedStatus: 503, reachesReadiness: true },
|
{ mode: "safe signed Evidence file", evidence: true, binding: "safe", expectedStatus: 503, reachesReadiness: true },
|
||||||
|
{ mode: "no Evidence descriptor", evidence: false, binding: "missing", expectedStatus: 503, reachesReadiness: true },
|
||||||
])("real buildApp admission handles $mode before Pi spawn", async ({
|
])("real buildApp admission handles $mode before Pi spawn", async ({
|
||||||
evidence, safe, expectedStatus, reachesReadiness,
|
evidence, binding, expectedStatus, reachesReadiness,
|
||||||
}) => {
|
}) => {
|
||||||
const root = mkdtempSync(path.join(tmpdir(), "thoth-evidence-admission-"));
|
const root = mkdtempSync(path.join(tmpdir(), "thoth-evidence-admission-"));
|
||||||
|
const unsafeRoot = mkdtempSync(path.join(tmpdir(), "thoth-evidence-unsafe-"));
|
||||||
const signedFile = path.join(root, "signed-urls.json");
|
const signedFile = path.join(root, "signed-urls.json");
|
||||||
|
const unsafeFile = path.join(unsafeRoot, "signed-urls.json");
|
||||||
writeFileSync(signedFile, '["CANARY-SIGNED-QUERY"]');
|
writeFileSync(signedFile, '["CANARY-SIGNED-QUERY"]');
|
||||||
|
writeFileSync(unsafeFile, '["CANARY-UNSAFE-SIGNED-QUERY"]');
|
||||||
const variable = "THT_WS_PSD_CLINICAL_EVIDENCE_SIGNED_URLS_FILE";
|
const variable = "THT_WS_PSD_CLINICAL_EVIDENCE_SIGNED_URLS_FILE";
|
||||||
const previous = {
|
const previous = {
|
||||||
transport: process.env.THT_WS_PSD_CLINICAL_DWH_TRANSPORT,
|
transport: process.env.THT_WS_PSD_CLINICAL_DWH_TRANSPORT,
|
||||||
@@ -2817,7 +2821,8 @@ test.each([
|
|||||||
};
|
};
|
||||||
process.env.THT_WS_PSD_CLINICAL_DWH_TRANSPORT = "rest_api";
|
process.env.THT_WS_PSD_CLINICAL_DWH_TRANSPORT = "rest_api";
|
||||||
process.env.THT_WS_PSD_CLINICAL_DWH_BASE_URL = "https://dwh.example.test";
|
process.env.THT_WS_PSD_CLINICAL_DWH_BASE_URL = "https://dwh.example.test";
|
||||||
if (safe) process.env[variable] = signedFile;
|
if (binding === "safe") process.env[variable] = signedFile;
|
||||||
|
else if (binding === "unsafe") process.env[variable] = unsafeFile;
|
||||||
else delete process.env[variable];
|
else delete process.env[variable];
|
||||||
|
|
||||||
const descriptor = {
|
const descriptor = {
|
||||||
@@ -2882,6 +2887,7 @@ test.each([
|
|||||||
expect(JSON.stringify(descriptor)).toBe(canonicalBefore);
|
expect(JSON.stringify(descriptor)).toBe(canonicalBefore);
|
||||||
expect(revision.commit).toBe("a".repeat(40));
|
expect(revision.commit).toBe("a".repeat(40));
|
||||||
expect(response.body).not.toContain("CANARY-SIGNED-QUERY");
|
expect(response.body).not.toContain("CANARY-SIGNED-QUERY");
|
||||||
|
expect(response.body).not.toContain("CANARY-UNSAFE-SIGNED-QUERY");
|
||||||
} finally {
|
} finally {
|
||||||
const restore = (name: string, value: string | undefined) => {
|
const restore = (name: string, value: string | undefined) => {
|
||||||
if (value === undefined) delete process.env[name];
|
if (value === undefined) delete process.env[name];
|
||||||
@@ -2891,5 +2897,6 @@ test.each([
|
|||||||
restore("THT_WS_PSD_CLINICAL_DWH_BASE_URL", previous.baseUrl);
|
restore("THT_WS_PSD_CLINICAL_DWH_BASE_URL", previous.baseUrl);
|
||||||
restore(variable, previous.signed);
|
restore(variable, previous.signed);
|
||||||
rmSync(root, { recursive: true, force: true });
|
rmSync(root, { recursive: true, force: true });
|
||||||
|
rmSync(unsafeRoot, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { expect, test, vi } from "vitest";
|
|||||||
import yazl from "yazl";
|
import yazl from "yazl";
|
||||||
import { buildApp } from "../src/app.js";
|
import { buildApp } from "../src/app.js";
|
||||||
import { loadConfig } from "../src/config.js";
|
import { loadConfig } from "../src/config.js";
|
||||||
|
import { createProductionWorkspaceDiagnoser } from "../src/workspaces/diagnostics.js";
|
||||||
import { WorkspaceRegistryError } from "../src/workspaces/git-repository.js";
|
import { WorkspaceRegistryError } from "../src/workspaces/git-repository.js";
|
||||||
import type { WorkspaceRegistry, WorkspaceRevision } from "../src/workspaces/registry.js";
|
import type { WorkspaceRegistry, WorkspaceRevision } from "../src/workspaces/registry.js";
|
||||||
import { renderWorkspaceDocs, serializeWorkspaceYaml, type CanonicalWorkspace, type WorkspaceV2 } from "../src/workspaces/schema.js";
|
import { renderWorkspaceDocs, serializeWorkspaceYaml, type CanonicalWorkspace, type WorkspaceV2 } from "../src/workspaces/schema.js";
|
||||||
@@ -283,6 +284,51 @@ test("runs diagnostics for a schema v3 workspace without external semantic bindi
|
|||||||
}), { writeProbe: false });
|
}), { writeProbe: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("reports missing Evidence binding through the real test route without changing registry revision", async () => {
|
||||||
|
const evidenceWorkspace: CanonicalWorkspace = {
|
||||||
|
...workspace,
|
||||||
|
evidence: {
|
||||||
|
source: {
|
||||||
|
type: "http",
|
||||||
|
uris: ["https://evidence.example.test/guide.md"],
|
||||||
|
authentication: "signed_urls_file",
|
||||||
|
connect_timeout_ms: 5_000,
|
||||||
|
read_timeout_ms: 30_000,
|
||||||
|
max_bytes: 10 * 1024 * 1024,
|
||||||
|
max_redirects: 5,
|
||||||
|
allow_private_hosts: false,
|
||||||
|
max_cache_bytes: 64 * 1024 * 1024,
|
||||||
|
},
|
||||||
|
policy: { max_chunk_chars: 4_000, retain_published_generations: 3 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const read = vi.fn(async () => ({ workspace: evidenceWorkspace, revision }));
|
||||||
|
const registry = registryFake({ read });
|
||||||
|
const app = appFor(registry, createProductionWorkspaceDiagnoser(100));
|
||||||
|
const variable = "THT_WS_PSD_CLINICAL_EVIDENCE_SIGNED_URLS_FILE";
|
||||||
|
const previous = process.env[variable];
|
||||||
|
delete process.env[variable];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await app.inject({ method: "POST", url: "/workspaces/psd-clinical/test", payload: {} });
|
||||||
|
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.activatable).toBe(false);
|
||||||
|
expect(body.diagnostics).toEqual(expect.arrayContaining([expect.objectContaining({
|
||||||
|
code: "binding_missing",
|
||||||
|
field: "evidence.source.authentication",
|
||||||
|
variable,
|
||||||
|
})]));
|
||||||
|
expect(read).toHaveBeenCalledTimes(1);
|
||||||
|
expect(registry.publish).not.toHaveBeenCalled();
|
||||||
|
expect(revision).toMatchObject({ commit: "a".repeat(40), blob: "b".repeat(40) });
|
||||||
|
} finally {
|
||||||
|
if (previous === undefined) delete process.env[variable];
|
||||||
|
else process.env[variable] = previous;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("returns a 409 field conflict instead of overwriting a changed workspace", async () => {
|
test("returns a 409 field conflict instead of overwriting a changed workspace", async () => {
|
||||||
const conflict = Object.assign(
|
const conflict = Object.assign(
|
||||||
new WorkspaceRegistryError("workspace_conflict", "Workspace has changed"),
|
new WorkspaceRegistryError("workspace_conflict", "Workspace has changed"),
|
||||||
|
|||||||
@@ -123,6 +123,7 @@ const directBindings: RuntimeBindings = {
|
|||||||
missing: [],
|
missing: [],
|
||||||
values: { THT_WS_PSD_CLINICAL_EMBEDDING_BASE_URL: "http://embedding.internal:11434" },
|
values: { THT_WS_PSD_CLINICAL_EMBEDDING_BASE_URL: "http://embedding.internal:11434" },
|
||||||
},
|
},
|
||||||
|
evidence: { missing: [], values: {} },
|
||||||
};
|
};
|
||||||
|
|
||||||
test("runtime support stays fail-closed for either SSH connector", () => {
|
test("runtime support stays fail-closed for either SSH connector", () => {
|
||||||
|
|||||||
@@ -363,16 +363,16 @@ evidence:
|
|||||||
root: {tmp_path}
|
root: {tmp_path}
|
||||||
max_bytes: 123
|
max_bytes: 123
|
||||||
- type: http
|
- type: http
|
||||||
urls: ['https://example.test/doc.md?token=transport-only']
|
urls: ['https://example.test/doc.md']
|
||||||
""")
|
""")
|
||||||
cfg = load_config(modern)
|
cfg = load_config(modern)
|
||||||
assert "transport-only" not in repr(cfg.evidence)
|
assert "example.test" not in repr(cfg.evidence)
|
||||||
assert "transport-only" not in cfg.evidence.model_dump_json()
|
assert "example.test" not in cfg.evidence.model_dump_json()
|
||||||
assert cfg.evidence.sources[1].allow_private_hosts is False
|
assert cfg.evidence.sources[1].allow_private_hosts is False
|
||||||
sources = build_evidence_sources(cfg)
|
sources = build_evidence_sources(cfg)
|
||||||
assert isinstance(sources[0], FilesystemEvidenceSource)
|
assert isinstance(sources[0], FilesystemEvidenceSource)
|
||||||
assert isinstance(sources[1], HttpManifestEvidenceSource)
|
assert isinstance(sources[1], HttpManifestEvidenceSource)
|
||||||
assert "transport-only" not in repr(sources[1])
|
assert "example.test" not in repr(sources[1])
|
||||||
|
|
||||||
legacy = tmp_path / "legacy.yaml"
|
legacy = tmp_path / "legacy.yaml"
|
||||||
(tmp_path / "curated").mkdir()
|
(tmp_path / "curated").mkdir()
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
import traceback
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import yaml
|
import yaml
|
||||||
@@ -112,6 +113,33 @@ def test_signed_http_file_resolves_in_memory_and_preserves_provenance_order(tmp_
|
|||||||
assert_no_canaries(repr(adapter))
|
assert_no_canaries(repr(adapter))
|
||||||
|
|
||||||
|
|
||||||
|
def test_signed_http_file_requires_explicit_provenance_urls(tmp_path):
|
||||||
|
secret_file = tmp_path / "signed-urls.json"
|
||||||
|
secret_file.write_text(json.dumps([
|
||||||
|
f"https://evidence.example.test/guide.md?token={SIGNED_CANARY}",
|
||||||
|
]))
|
||||||
|
path = write_config(tmp_path, {
|
||||||
|
"type": "http", "signed_urls_file": str(secret_file),
|
||||||
|
})
|
||||||
|
|
||||||
|
with pytest.raises(ConfigError) as caught:
|
||||||
|
load_config(path)
|
||||||
|
assert "provenance" in str(caught.value).lower()
|
||||||
|
assert_no_canaries(caught.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_public_http_rejects_inline_query_bearing_transport_urls(tmp_path):
|
||||||
|
path = write_config(tmp_path, {
|
||||||
|
"type": "http",
|
||||||
|
"urls": [f"https://evidence.example.test/guide.md?token={SIGNED_CANARY}"],
|
||||||
|
})
|
||||||
|
|
||||||
|
with pytest.raises(ConfigError) as caught:
|
||||||
|
load_config(path)
|
||||||
|
assert "evidence.sources.0" in str(caught.value)
|
||||||
|
assert_no_canaries(caught.value)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("contents", [
|
@pytest.mark.parametrize("contents", [
|
||||||
"{malformed", json.dumps({"url": "https://evidence.example.test/guide.md"}),
|
"{malformed", json.dumps({"url": "https://evidence.example.test/guide.md"}),
|
||||||
json.dumps([]), json.dumps(["https://evidence.example.test/guide.md", 3]),
|
json.dumps([]), json.dumps(["https://evidence.example.test/guide.md", 3]),
|
||||||
@@ -264,6 +292,7 @@ def test_validation_repr_cli_and_exception_output_never_disclose_transport_secre
|
|||||||
with pytest.raises(ConfigError) as caught:
|
with pytest.raises(ConfigError) as caught:
|
||||||
load_config(path)
|
load_config(path)
|
||||||
assert_no_canaries(caught.value)
|
assert_no_canaries(caught.value)
|
||||||
|
assert_no_canaries("".join(traceback.format_exception(caught.value)))
|
||||||
|
|
||||||
valid_file = tmp_path / "valid-signed-urls.json"
|
valid_file = tmp_path / "valid-signed-urls.json"
|
||||||
valid_file.write_text(json.dumps([
|
valid_file.write_text(json.dumps([
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ def _resolve_http_signed_url_files(value: Any) -> Any:
|
|||||||
return resolved
|
return resolved
|
||||||
if "urls" in resolved:
|
if "urls" in resolved:
|
||||||
raise ConfigError("HTTP signed URL file cannot be combined with urls")
|
raise ConfigError("HTTP signed URL file cannot be combined with urls")
|
||||||
|
if "provenance_urls" not in resolved:
|
||||||
|
raise ConfigError("HTTP signed URL file requires provenance_urls")
|
||||||
path_value = resolved.pop("signed_urls_file")
|
path_value = resolved.pop("signed_urls_file")
|
||||||
if not isinstance(path_value, str):
|
if not isinstance(path_value, str):
|
||||||
raise ConfigError("Invalid signed URL file reference")
|
raise ConfigError("Invalid signed URL file reference")
|
||||||
@@ -76,8 +78,8 @@ def _resolve_http_signed_url_files(value: Any) -> Any:
|
|||||||
if len(payload) > _MAX_SIGNED_URL_FILE_BYTES:
|
if len(payload) > _MAX_SIGNED_URL_FILE_BYTES:
|
||||||
raise OSError
|
raise OSError
|
||||||
parsed = json.loads(payload.decode("utf-8"))
|
parsed = json.loads(payload.decode("utf-8"))
|
||||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||||
raise ConfigError("Cannot read signed URL file") from exc
|
raise ConfigError("Cannot read signed URL file") from None
|
||||||
if (
|
if (
|
||||||
not isinstance(parsed, list)
|
not isinstance(parsed, list)
|
||||||
or not parsed
|
or not parsed
|
||||||
@@ -307,6 +309,8 @@ class HttpEvidenceSourceConfig(BaseModel):
|
|||||||
):
|
):
|
||||||
raise ValueError("HTTP transport URL must use http or https")
|
raise ValueError("HTTP transport URL must use http or https")
|
||||||
if self.provenance_urls is None:
|
if self.provenance_urls is None:
|
||||||
|
if canonical != transport_urls or len(set(canonical)) != len(canonical):
|
||||||
|
raise ValueError("Public HTTP URLs must be canonical query-free identities")
|
||||||
return self
|
return self
|
||||||
try:
|
try:
|
||||||
provenance = [canonical_provenance_uri(url) for url in self.provenance_urls]
|
provenance = [canonical_provenance_uri(url) for url in self.provenance_urls]
|
||||||
@@ -532,7 +536,7 @@ def load_config(path: Path) -> Config:
|
|||||||
cfg = Config.model_validate(translated)
|
cfg = Config.model_validate(translated)
|
||||||
except ValidationError as e:
|
except ValidationError as e:
|
||||||
details = _format_validation_error(e)
|
details = _format_validation_error(e)
|
||||||
raise ConfigError(f"Configurazione non valida in {path}:\n{details}") from e
|
raise ConfigError(f"Configurazione non valida in {path}:\n{details}") from None
|
||||||
env_profile = os.environ.get("THT_PROFILE")
|
env_profile = os.environ.get("THT_PROFILE")
|
||||||
if env_profile is not None:
|
if env_profile is not None:
|
||||||
if env_profile not in ("server", "workstation"):
|
if env_profile not in ("server", "workstation"):
|
||||||
|
|||||||
Reference in New Issue
Block a user