Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
896 changes: 884 additions & 12 deletions drift v3/crates/drift-engine/src/candidate_command.rs

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions drift v3/crates/drift-engine/src/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,8 @@ pub struct EngineCandidate {
pub rationale: String,
pub scope: Value,
pub matcher: Value,
#[serde(skip_serializing_if = "Option::is_none")]
pub requires: Option<Value>,
pub suggested_severity: String,
pub suggested_enforcement_mode: String,
pub enforcement_capability: String,
Expand All @@ -287,6 +289,8 @@ pub struct EngineCandidate {
pub required_capabilities: Vec<String>,
pub evidence_refs: Vec<EngineCandidateEvidenceRef>,
pub counterexample_refs: Vec<EngineCandidateEvidenceRef>,
pub reason_not_blocking: String,
pub evidence_fingerprint: String,
}

#[derive(Debug, Clone, Serialize)]
Expand Down
253 changes: 252 additions & 1 deletion drift v3/crates/drift-engine/tests/candidate_inference.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
use std::{
fs,
io::Write,
path::Path,
process::{Command, Stdio},
time::{SystemTime, UNIX_EPOCH},
};

use serde_json::{Value, json};
Expand Down Expand Up @@ -56,7 +59,7 @@ fn infer_candidates_emits_governance_free_candidate_proposals() {
assert!(candidates.iter().any(|candidate| {
candidate["kind"] == "api_route_no_direct_data_access"
&& candidate["enforcement_capability"] == "deterministic_check"
&& candidate["suggested_enforcement_mode"] == "block"
&& candidate["suggested_enforcement_mode"] == "warn"
&& candidate.get("status").is_none()
}));
assert!(candidates.iter().any(|candidate| {
Expand Down Expand Up @@ -281,6 +284,165 @@ fn infer_candidates_ignores_repo_fixture_routes_when_repo_root_is_not_the_fixtur
);
}

#[test]
fn infer_candidates_emits_security_phase_candidates_as_non_blocking_elections() {
let route_a = "app/api/users/route.ts";
let route_b = "app/api/projects/route.ts";
let facts = json!([
{ "kind": "file_role_detected", "file_path": route_a, "name": "api_route", "start_line": 1, "end_line": 5 },
{ "kind": "file_role_detected", "file_path": route_b, "name": "api_route", "start_line": 1, "end_line": 5 },
{ "kind": "import_used", "file_path": route_a, "name": "requireUser", "value": "@/auth", "start_line": 1, "end_line": 1 },
{ "kind": "import_used", "file_path": route_b, "name": "requireUser", "value": "@/auth", "start_line": 1, "end_line": 1 },
{ "kind": "symbol_called", "file_path": route_a, "name": "requireUser", "start_line": 4, "end_line": 4 },
{ "kind": "symbol_called", "file_path": route_b, "name": "requireUser", "start_line": 4, "end_line": 4 },
{ "kind": "request_validation_called", "file_path": route_a, "name": "validateBody", "start_line": 5, "end_line": 5 },
{ "kind": "request_validation_called", "file_path": route_b, "name": "validateBody", "start_line": 5, "end_line": 5 },
{ "kind": "authorization_guard_called", "file_path": route_a, "name": "requireRole", "start_line": 6, "end_line": 6 },
{ "kind": "authorization_guard_called", "file_path": route_b, "name": "requireRole", "start_line": 6, "end_line": 6 },
{ "kind": "tenant_guard_called", "file_path": route_a, "name": "scopeTenant", "start_line": 7, "end_line": 7 },
{ "kind": "tenant_guard_called", "file_path": route_b, "name": "scopeTenant", "start_line": 7, "end_line": 7 },
{ "kind": "serializer_called", "file_path": route_a, "name": "serializeUser", "start_line": 8, "end_line": 8 },
{ "kind": "serializer_called", "file_path": route_b, "name": "serializeUser", "start_line": 8, "end_line": 8 },
{ "kind": "parameterized_sql_used", "file_path": route_a, "name": "safeQuery", "start_line": 9, "end_line": 9 },
{ "kind": "parameterized_sql_used", "file_path": route_b, "name": "safeQuery", "start_line": 9, "end_line": 9 },
{ "kind": "symbol_called", "file_path": route_a, "name": "allowlistedUrl", "start_line": 9, "end_line": 9 },
{ "kind": "symbol_called", "file_path": route_b, "name": "allowlistedUrl", "start_line": 9, "end_line": 9 },
{ "kind": "csrf_guard_called", "file_path": route_a, "name": "requireCsrf", "start_line": 10, "end_line": 10 },
{ "kind": "csrf_guard_called", "file_path": route_b, "name": "requireCsrf", "start_line": 10, "end_line": 10 },
{ "kind": "rate_limit_guard_called", "file_path": route_a, "name": "rateLimit", "start_line": 11, "end_line": 11 },
{ "kind": "rate_limit_guard_called", "file_path": route_b, "name": "rateLimit", "start_line": 11, "end_line": 11 },
{ "kind": "cors_policy_declared", "file_path": route_a, "name": "cors", "value": "{\"origin\":\"https://app.example.com\",\"allow_credentials\":true}", "start_line": 12, "end_line": 12 },
{ "kind": "sensitive_field_declared", "file_path": route_a, "name": "password", "value": "{\"field_path\":\"password\",\"classification\":\"credential\"}", "start_line": 13, "end_line": 13 }
]);
let request = json!({
"repo": { "repo_id": "repo_abc" },
"graph": { "graph_nodes": [], "graph_edges": [], "graph_evidence": [] },
"scan": {
"scan_id": "scan_abc",
"file_snapshots": [
{ "file_path": route_a, "content_hash": "a".repeat(64), "byte_size": 120, "indexed": true },
{ "file_path": route_b, "content_hash": "b".repeat(64), "byte_size": 120, "indexed": true }
],
"facts": facts
}
});

let payload = run_infer_candidates(request);
let candidates = payload["candidates"].as_array().expect("candidates");
for expected in [
"api_route_requires_auth_helper",
"api_route_requires_request_validation",
"api_route_requires_authorization",
"api_route_requires_tenant_scope",
"api_route_forbids_sensitive_response_fields",
"api_route_forbids_raw_sql_without_params",
"api_route_forbids_untrusted_ssrf",
"api_route_requires_csrf_for_mutation",
"api_route_requires_rate_limit",
"api_route_cors_must_match_policy",
] {
let candidate = candidates
.iter()
.find(|candidate| candidate["kind"] == expected)
.unwrap_or_else(|| panic!("missing {expected}: {payload:#?}"));
assert_eq!(candidate["suggested_enforcement_mode"], "warn");
assert_eq!(candidate["reason_not_blocking"], "candidate_not_accepted");
assert!(
candidate["requires"].is_object(),
"missing requires for {expected}"
);
assert!(
candidate["evidence_fingerprint"]
.as_str()
.is_some_and(|value| !value.is_empty()),
"missing evidence fingerprint for {expected}: {candidate:#?}"
);
}
}

#[test]
fn scan_repo_then_infer_candidates_covers_phase7_security_candidate_families() {
let repo_root = temp_repo_root("phase7-candidate-coverage");
write_phase7_candidate_fixture(&repo_root);

let scan = run_scan_repo(&repo_root);
let request = json!({
"repo": { "repo_id": "repo_phase7" },
"graph": { "graph_nodes": [], "graph_edges": [], "graph_evidence": [] },
"scan": {
"scan_id": "scan_phase7",
"file_snapshots": scan["file_snapshots"].clone(),
"facts": scan["facts"].clone(),
}
});
let payload = run_infer_candidates(request);
let candidates = payload["candidates"].as_array().expect("candidates");

for expected in [
"api_route_requires_auth_helper",
"middleware_must_cover_routes",
"api_route_requires_request_validation",
"api_route_requires_authorization",
"api_route_requires_tenant_scope",
"api_route_forbids_sensitive_response_fields",
"api_route_forbids_raw_sql_without_params",
"api_route_forbids_untrusted_ssrf",
"api_route_requires_csrf_for_mutation",
"api_route_requires_rate_limit",
"api_route_cors_must_match_policy",
] {
let candidate = candidates
.iter()
.find(|candidate| candidate["kind"] == expected)
.unwrap_or_else(|| panic!("missing {expected}: {payload:#?}"));
assert_eq!(candidate["suggested_enforcement_mode"], "warn");
assert_eq!(candidate["reason_not_blocking"], "candidate_not_accepted");
assert!(
candidate["requires"].is_object(),
"missing requires for {expected}"
);
}

let cors = candidates
.iter()
.find(|candidate| candidate["kind"] == "api_route_cors_must_match_policy")
.expect("cors candidate");
assert_eq!(
cors["requires"]["allowed_origins"],
json!(["https://app.example.com"])
);

let ssrf = candidates
.iter()
.find(|candidate| candidate["kind"] == "api_route_forbids_untrusted_ssrf")
.expect("ssrf candidate");
assert_eq!(
ssrf["requires"]["outbound_url_allowlist_helpers"][0]["module"],
"@/server/url"
);

let csrf = candidates
.iter()
.find(|candidate| candidate["kind"] == "api_route_requires_csrf_for_mutation")
.expect("csrf candidate");
assert_eq!(
csrf["requires"]["csrf_helpers"][0]["module"],
"@/server/csrf"
);

let serializer = candidates
.iter()
.find(|candidate| candidate["kind"] == "api_route_forbids_sensitive_response_fields")
.and_then(|candidate| candidate["requires"]["response_serializers"].as_array())
.and_then(|serializers| serializers.first())
.expect("serializer candidate");
assert_eq!(serializer["import_source"], "@/server/serializers");
assert_eq!(serializer["imported_name"], "serializeUser");
assert_eq!(serializer["policy"], "denylist");

fs::remove_dir_all(repo_root).ok();
}

fn run_infer_candidates(request: Value) -> Value {
let mut child = Command::new(env!("CARGO_BIN_EXE_drift-engine"))
.arg("infer-candidates")
Expand All @@ -303,6 +465,95 @@ fn run_infer_candidates(request: Value) -> Value {
serde_json::from_slice(&output.stdout).expect("json output")
}

fn run_scan_repo(repo_root: &Path) -> Value {
let output = Command::new(env!("CARGO_BIN_EXE_drift-engine"))
.arg("scan-repo")
.arg(repo_root)
.arg("--format")
.arg("json")
.arg("--repo-id")
.arg("repo_phase7")
.arg("--scan-id")
.arg("scan_phase7")
.output()
.expect("run scan-repo");
assert!(
output.status.success(),
"scan failed: {}",
String::from_utf8_lossy(&output.stderr)
);
serde_json::from_slice(&output.stdout).expect("scan json")
}

fn temp_repo_root(prefix: &str) -> std::path::PathBuf {
let suffix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("time")
.as_nanos();
let path = std::env::temp_dir().join(format!("{prefix}-{}-{suffix}", std::process::id()));
fs::create_dir_all(&path).expect("create temp repo");
path
}

fn write_phase7_candidate_fixture(repo_root: &Path) {
fs::write(
repo_root.join("middleware.ts"),
r#"
import { NextResponse } from "next/server";

export async function middleware() {
return NextResponse.next();
}

export const config = {
matcher: ["/api/:path*"],
};
"#,
)
.expect("write middleware");

for name in ["users", "projects"] {
let dir = repo_root.join(format!("app/api/{name}"));
fs::create_dir_all(&dir).expect("create route dir");
fs::write(
dir.join("route.ts"),
r#"
import { requireUser } from "@/server/auth";
import { validateBody } from "@/server/validation";
import { requireRole } from "@/server/authorization";
import { scopeTenant } from "@/server/tenant";
import { serializeUser } from "@/server/serializers";
import { safeUrl } from "@/server/url";
import { requireCsrf } from "@/server/csrf";
import { rateLimit } from "@/server/rate-limit";
import { db } from "@/server/db";

export async function POST(request: Request) {
const body = await request.json();
const user = await requireUser();
const input = validateBody(body);
requireRole(user, "admin");
const tenantId = scopeTenant(user);
await requireCsrf(request);
await rateLimit(request);
const target = safeUrl(input.callbackUrl);
await fetch(target);
await db.query("select * from users where id = $1", [input.id]);
const row = { id: input.id, password: "redacted", tenantId };
const safe = serializeUser(row);
return Response.json(safe, {
headers: {
"Access-Control-Allow-Origin": "https://app.example.com",
"Access-Control-Allow-Credentials": "true"
}
});
}
"#,
)
.expect("write route");
}
}

fn graph_node(id: &str, kind: &str, label: &str, metadata: Value) -> Value {
json!({
"id": id,
Expand Down
59 changes: 59 additions & 0 deletions drift v3/docs/architecture/security-phase7-coverage-ledger.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Security Phase 7 Coverage Ledger

Scope: Phase 7 of `docs/architecture/security-boundary-enforcement-100-tdd.md`.

Phase 7 is complete only when candidate inference is useful without becoming enforcement. Coverage here means spec coverage: every required candidate family, election rule, and validation guard has a live test or gate.

## Requirement Coverage

| Requirement | Proof |
| --- | --- |
| Auth helper candidate | `crates/drift-engine/tests/candidate_inference.rs::infer_candidates_emits_security_phase_candidates_as_non_blocking_elections`; `scan_repo_then_infer_candidates_covers_phase7_security_candidate_families` |
| Middleware protection candidate | `infer_candidates_emits_security_phase_candidates_as_non_blocking_elections`; `scan_repo_then_infer_candidates_covers_phase7_security_candidate_families` |
| Validation helper candidate | `scan_repo_then_infer_candidates_covers_phase7_security_candidate_families` |
| Tenant helper candidate | `infer_candidates_emits_security_phase_candidates_as_non_blocking_elections`; `scan_repo_then_infer_candidates_covers_phase7_security_candidate_families` |
| Serializer candidate | `scan_repo_then_infer_candidates_covers_phase7_security_candidate_families` |
| Sensitive field candidate | `infer_candidates_emits_security_phase_candidates_as_non_blocking_elections`; `scan_repo_then_infer_candidates_covers_phase7_security_candidate_families` |
| SQL safe wrapper candidate | `infer_candidates_emits_security_phase_candidates_as_non_blocking_elections`; `scan_repo_then_infer_candidates_covers_phase7_security_candidate_families` |
| SSRF allowlist/sanitizer candidate | `infer_candidates_emits_security_phase_candidates_as_non_blocking_elections`; `scan_repo_then_infer_candidates_covers_phase7_security_candidate_families` |
| CSRF helper candidate | `infer_candidates_emits_security_phase_candidates_as_non_blocking_elections`; `scan_repo_then_infer_candidates_covers_phase7_security_candidate_families` |
| Rate-limit helper candidate | `infer_candidates_emits_security_phase_candidates_as_non_blocking_elections`; `scan_repo_then_infer_candidates_covers_phase7_security_candidate_families` |
| CORS policy candidate | `infer_candidates_emits_security_phase_candidates_as_non_blocking_elections`; `scan_repo_then_infer_candidates_covers_phase7_security_candidate_families` |
| Candidates default to non-blocking mode | Rust candidate tests assert `suggested_enforcement_mode = warn` and `reason_not_blocking = candidate_not_accepted` for every Phase 7 security family. |
| Candidate cannot produce blocking finding until accepted | `crates/drift-engine/tests/candidate_inference.rs::infer_candidates_emits_security_phase_candidates_as_non_blocking_elections`; Phase 1-6 `candidate_only_*_does_not_block` Rust tests remain part of the gate. |
| Accepted candidate becomes Rust contract input | `packages/cli/test/cli.test.ts::accepts a candidate, materializes a repo contract, and audits the action`; Phase 1-6 check tests validate accepted contracts through Rust proof. |
| Rejected candidate is not re-proposed without new evidence | `packages/cli/test/cli.test.ts::does not re-propose a rejected candidate without new evidence` |
| Blocking heuristic contracts are rejected | `packages/cli/test/cli.test.ts::rejects contract validate when a blocking convention is not deterministic`; `rejects imported blocking security contracts backed by candidate sensitive fields` |
| Output includes evidence counts, confidence, suggested contract/mode, and reason not blocking | `crates/drift-engine/tests/candidate_inference.rs` candidate payload assertions; `pnpm --filter @drift/cli test` covers CLI rendering and import/accept surfaces. |

## Gate Commands

Run these from the repository root before calling Phase 7 covered:

```bash
cargo test -p drift-engine --test candidate_inference
cargo test -p drift-engine security_
cargo test -p drift-engine --test security_phase6
cargo test -p drift-engine
pnpm --filter @drift/core test
pnpm --filter @drift/engine-contract test
pnpm --filter @drift/storage test
pnpm --filter @drift/query test
pnpm --filter @drift/cli test
pnpm --filter @drift/mcp test
pnpm test:e2e
pnpm typecheck
cargo fmt --all -- --check
cargo clippy -p drift-engine --all-targets -- -D warnings
git diff --check
```

## Boundary

Phase 7 does not make heuristic evidence enforceable. The expected lifecycle remains:

```text
scan facts -> infer candidate -> human/agent election -> accepted repo contract -> Rust proof/check
```

Phase 8 output and MCP UX are intentionally out of scope except where existing CLI tests prove candidates can be listed, accepted, rejected, imported, and validated without bypassing elections.
Loading
Loading