|
|
@@ -249,3 +249,149 @@ def test_cross_tenant_list_does_not_include_other_tenant(client, db, tenant_and_
|
|
|
assert not leaked, (
|
|
|
f"Tenant A's list leaked tenant B's agent! Got: {leaked}"
|
|
|
)
|
|
|
+
|
|
|
+
|
|
|
+# ─────────────────────────────────────────────────────────────────────────────
|
|
|
+# audit #26 — /analyze/* router auth + body size cap
|
|
|
+# ─────────────────────────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+def test_analyze_lint_requires_auth(client):
|
|
|
+ """audit #26: was `(req: AnalyzeRequest)` — no Depends(get_tenant).
|
|
|
+ Free-compute / DoS amplifier. Now must 401 without a Bearer."""
|
|
|
+ r = client.post("/api/v1/analyze/lint", json={"config": "type: react"})
|
|
|
+ assert r.status_code == 401, (
|
|
|
+ f"/analyze/lint accepted unauthenticated request! got {r.status_code}"
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def test_analyze_lint_accepts_valid_key(client, tenant_and_key):
|
|
|
+ _, key = tenant_and_key
|
|
|
+ r = client.post("/api/v1/analyze/lint",
|
|
|
+ json={"config": "type: react\nname: test"},
|
|
|
+ headers=_auth(key))
|
|
|
+ assert r.status_code == 200, r.text[:300]
|
|
|
+ body = r.json()
|
|
|
+ assert "framework" in body or "errors" in body
|
|
|
+
|
|
|
+
|
|
|
+def test_analyze_lint_rejects_oversize_yaml(client, tenant_and_key):
|
|
|
+ """audit #26 + low #90: cap YAML body before yaml.safe_load to neuter
|
|
|
+ billion-laughs alias amplification. Sending >256KB should 413."""
|
|
|
+ _, key = tenant_and_key
|
|
|
+ huge = "type: react\nname: " + ("x" * (260 * 1024))
|
|
|
+ r = client.post("/api/v1/analyze/lint",
|
|
|
+ json={"config": huge},
|
|
|
+ headers=_auth(key))
|
|
|
+ assert r.status_code == 413, (
|
|
|
+ f"expected 413 CONFIG_TOO_LARGE, got {r.status_code}: {r.text[:200]}"
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+# ─────────────────────────────────────────────────────────────────────────────
|
|
|
+# audit #14 — rollback_agent RBAC + tenant scope
|
|
|
+# ─────────────────────────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+def test_rollback_cross_tenant_returns_404(client, db, tenant_and_key):
|
|
|
+ """Tenant A asks to rollback tenant B's agent → 404 (id oracle defense).
|
|
|
+ Even if A guessed B's agent_id correctly, response is identical to
|
|
|
+ 'agent does not exist'."""
|
|
|
+ _, key_a = tenant_and_key
|
|
|
+ _, aid_b = _insert_tenant_with_agent(db, "tenant-b", "tenant-b-private")
|
|
|
+
|
|
|
+ r = client.post(f"/api/v1/agents/{aid_b}/rollback",
|
|
|
+ json={"target_version": 1},
|
|
|
+ headers=_auth(key_a))
|
|
|
+ assert r.status_code == 404, (
|
|
|
+ f"rollback across tenant boundary leaked! status={r.status_code}, "
|
|
|
+ f"body={r.text[:300]}"
|
|
|
+ )
|
|
|
+
|
|
|
+
|
|
|
+def test_rollback_unknown_version_returns_404(client, db, tenant_and_key):
|
|
|
+ """Tenant A asks to rollback their own agent to a version that
|
|
|
+ doesn't exist → 404 VERSION_NOT_FOUND. Anchors the version-scoping
|
|
|
+ query so we don't accidentally leak via a 5xx."""
|
|
|
+ tid_a, key_a = tenant_and_key
|
|
|
+ aid_a = gen_id("ag_")
|
|
|
+ now = now_utc()
|
|
|
+ db.execute(
|
|
|
+ "INSERT INTO agents "
|
|
|
+ "(id, tenant_id, name, description, status, created_at, updated_at) "
|
|
|
+ "VALUES (?, ?, ?, '', 'active', ?, ?)",
|
|
|
+ (aid_a, tid_a, "my-agent", now, now),
|
|
|
+ )
|
|
|
+ db.commit()
|
|
|
+
|
|
|
+ r = client.post(f"/api/v1/agents/{aid_a}/rollback",
|
|
|
+ json={"target_version": 99},
|
|
|
+ headers=_auth(key_a))
|
|
|
+ assert r.status_code == 404
|
|
|
+
|
|
|
+
|
|
|
+# ─────────────────────────────────────────────────────────────────────────────
|
|
|
+# audit #27 — knowledge/{kb_id}/files path containment
|
|
|
+# ─────────────────────────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+def test_add_files_rejects_path_outside_kb_root(client, db, tenant_and_key, tmp_path):
|
|
|
+ """audit #27 fix: `/etc/passwd`-style host paths must be rejected
|
|
|
+ even if the caller owns the KB. Containment check uses resolved
|
|
|
+ absolute path + os.sep prefix."""
|
|
|
+ tid, key = tenant_and_key
|
|
|
+
|
|
|
+ # Set up a KB whose root_dir is inside tmp_path so we can also test
|
|
|
+ # the positive case.
|
|
|
+ kb_id = gen_id("kb_")
|
|
|
+ kb_root = tmp_path / "kb"
|
|
|
+ kb_root.mkdir()
|
|
|
+ (kb_root / "ok.txt").write_text("inside kb_root")
|
|
|
+ # Build a file OUTSIDE the kb_root to attempt to register.
|
|
|
+ outside = tmp_path / "outside.txt"
|
|
|
+ outside.write_text("attacker would love to chunk this")
|
|
|
+
|
|
|
+ now = now_utc()
|
|
|
+ # knowledge_bases schema: id, tenant_id, name, root_dir, description,
|
|
|
+ # created_at, updated_at (no `status` column)
|
|
|
+ db.execute(
|
|
|
+ "INSERT INTO knowledge_bases "
|
|
|
+ "(id, tenant_id, name, root_dir, description, created_at, updated_at) "
|
|
|
+ "VALUES (?, ?, ?, ?, '', ?, ?)",
|
|
|
+ (kb_id, tid, "test-kb", str(kb_root), now, now),
|
|
|
+ )
|
|
|
+ db.commit()
|
|
|
+
|
|
|
+ # Try to add the outside file — should be rejected.
|
|
|
+ r = client.post(f"/api/v1/knowledge/{kb_id}/files",
|
|
|
+ json={"file_paths": [str(outside)]},
|
|
|
+ headers=_auth(key))
|
|
|
+ # Endpoint returns 201 Created on successful POST regardless of how
|
|
|
+ # many files were ultimately registered; the rejection shows up in
|
|
|
+ # the body's `rejected` array.
|
|
|
+ assert r.status_code in (200, 201), r.text[:300]
|
|
|
+ body = r.json()
|
|
|
+ assert body["added"] == 0, f"escaped file was registered! body={body}"
|
|
|
+ assert "rejected" in body, "expected `rejected` array listing the refusal"
|
|
|
+
|
|
|
+ # Adding the inside file works (positive case).
|
|
|
+ r2 = client.post(f"/api/v1/knowledge/{kb_id}/files",
|
|
|
+ json={"file_paths": [str(kb_root / "ok.txt")]},
|
|
|
+ headers=_auth(key))
|
|
|
+ assert r2.status_code in (200, 201), r2.text[:300]
|
|
|
+ assert r2.json()["added"] == 1
|
|
|
+
|
|
|
+
|
|
|
+# ─────────────────────────────────────────────────────────────────────────────
|
|
|
+# audit #28 — /jobs/{job_id} IDOR
|
|
|
+# ─────────────────────────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+def test_get_job_nonexistent_returns_404(client, tenant_and_key):
|
|
|
+ """Sanity: unknown job_id → 404 (no 5xx, no leak)."""
|
|
|
+ _, key = tenant_and_key
|
|
|
+ r = client.get("/api/v1/jobs/job_doesnotexist", headers=_auth(key))
|
|
|
+ assert r.status_code == 404
|
|
|
+
|
|
|
+
|
|
|
+def test_cancel_job_nonexistent_returns_404(client, tenant_and_key):
|
|
|
+ """Same for cancel — used to silently 200 even for nonexistent IDs."""
|
|
|
+ _, key = tenant_and_key
|
|
|
+ r = client.post("/api/v1/jobs/job_doesnotexist/cancel", headers=_auth(key))
|
|
|
+ assert r.status_code == 404
|