-
Notifications
You must be signed in to change notification settings - Fork 1
Add auditable research candidate promotion policy #97
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -26,6 +26,7 @@ | |
| WATCH_TYPES = {"package", "namespace", "publisher", "brand", "repository", "organization"} | ||
| PRIORITIES = {"low", "normal", "high", "critical"} | ||
| STATUSES = {"active", "paused", "archived", "new", "review", "dismissed", "promoted"} | ||
| PROMOTION_MODES = {"review_only", "draft_case"} | ||
|
|
||
|
|
||
| def _id(prefix: str) -> str: | ||
|
|
@@ -516,6 +517,78 @@ def get_candidate(candidate_id: str, *, db_path: Optional[str] = None) -> Dict[s | |
| return item | ||
|
|
||
|
|
||
| def get_promotion_policy(*, ecosystem: str = "all", db_path: Optional[str] = None) -> Dict[str, Any]: | ||
| """Return the durable, auditable candidate-promotion policy.""" | ||
| soc_store.init_db(db_path) | ||
| ecosystem = normalize_identifier("", ecosystem or "all") | ||
| with closing(soc_store.connect(db_path)) as connection: | ||
| row = connection.execute("SELECT * FROM research_promotion_policies WHERE ecosystem = ?", (ecosystem,)).fetchone() | ||
| if row: | ||
| return dict(row) | ||
| return {"ecosystem": ecosystem, "enabled": 0, "score_threshold": 90.0, "minimum_evidence": 2, "require_publisher": 0, "mode": "draft_case", "updated_by": "", "created_at": None, "updated_at": None} | ||
|
|
||
|
|
||
| def set_promotion_policy(*, ecosystem: str = "all", enabled: bool = False, score_threshold: float = 90.0, minimum_evidence: int = 2, require_publisher: bool = False, mode: str = "draft_case", actor: str = "operator", db_path: Optional[str] = None) -> Dict[str, Any]: | ||
| soc_store.init_db(db_path) | ||
| ecosystem = normalize_identifier("", ecosystem or "all") | ||
| if ecosystem != "all" and ecosystem not in CAPABILITIES: | ||
| raise ValueError("unsupported promotion-policy ecosystem") | ||
| threshold = float(score_threshold) | ||
| if not 70 <= threshold <= 100: | ||
| raise ValueError("score threshold must be between 70 and 100") | ||
| evidence_count = max(1, min(int(minimum_evidence), 8)) | ||
| if mode not in PROMOTION_MODES: | ||
| raise ValueError("unsupported promotion mode") | ||
| now = _now() | ||
| with closing(soc_store.connect(db_path)) as connection: | ||
| connection.execute("""INSERT INTO research_promotion_policies | ||
| (ecosystem, enabled, score_threshold, minimum_evidence, require_publisher, mode, updated_by, created_at, updated_at) | ||
| VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) | ||
| ON CONFLICT(ecosystem) DO UPDATE SET enabled=excluded.enabled, score_threshold=excluded.score_threshold, | ||
| minimum_evidence=excluded.minimum_evidence, require_publisher=excluded.require_publisher, | ||
| mode=excluded.mode, updated_by=excluded.updated_by, updated_at=excluded.updated_at""", | ||
| (ecosystem, int(bool(enabled)), threshold, evidence_count, int(bool(require_publisher)), mode, str(actor or "operator")[:160], now, now)) | ||
| connection.commit() | ||
| return get_promotion_policy(ecosystem=ecosystem, db_path=db_path) | ||
|
|
||
|
|
||
| def run_promotion_policy(*, ecosystem: str = "all", apply: bool = False, actor: str = "operator", limit: int = 100, db_path: Optional[str] = None) -> Dict[str, Any]: | ||
| """Evaluate candidates deterministically and optionally create draft cases. | ||
|
|
||
| Promotion never records a malicious verdict. It creates an auditable draft | ||
| investigation only when the configured evidence gates pass. | ||
| """ | ||
| policy = get_promotion_policy(ecosystem=ecosystem, db_path=db_path) | ||
| candidates = list_candidates(status="new", ecosystem=None if policy["ecosystem"] == "all" else policy["ecosystem"], limit=limit, db_path=db_path) | ||
| decisions: List[Dict[str, Any]] = [] | ||
| for candidate in candidates: | ||
| evidence = candidate.get("evidence") if isinstance(candidate.get("evidence"), dict) else {} | ||
| evidence_refs = [key for key in ("metadata_url", "artifact_url", "publisher") if evidence.get(key)] | ||
| reasons = [] | ||
| if float(candidate.get("score") or 0) < float(policy["score_threshold"]): | ||
| reasons.append("score_below_threshold") | ||
| if len(evidence_refs) < int(policy["minimum_evidence"]): | ||
| reasons.append("insufficient_evidence") | ||
| if policy["require_publisher"] and not evidence.get("publisher"): | ||
| reasons.append("publisher_missing") | ||
| eligible = not reasons and bool(policy["enabled"]) | ||
| decision = {"candidate_id": candidate["candidate_id"], "eligible": eligible, "score": candidate["score"], "evidence_count": len(evidence_refs), "reasons": reasons or (["policy_disabled"] if not policy["enabled"] else ["eligible"]), "case_id": candidate.get("case_id")} | ||
| if apply and eligible and policy["mode"] == "draft_case" and not candidate.get("case_id"): | ||
| from secopsai.research_cases import add_evidence, add_subject, create_case | ||
| case = create_case(title=f"Investigate {candidate['ecosystem']} package {candidate['package']}", summary=f"Automatically promoted as a draft investigation after deterministic policy checks. Similarity score: {candidate['score']}. This is a lead, not a maliciousness verdict.", case_type="typosquatting", severity="medium", confidence=min(int(float(candidate["score"])), 80), owner=actor, metadata={"candidate_id": candidate["candidate_id"], "promotion_policy": policy}, db_path=db_path) | ||
|
Comment on lines
+576
to
+578
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When two Useful? React with 👍 / 👎. |
||
| add_subject(case["case_id"], subject_type="package", name=candidate["package"], ecosystem=candidate["ecosystem"], version=candidate.get("version") or "", publisher=evidence.get("publisher") or "", registry_state="available", metadata={"candidate_id": candidate["candidate_id"], "reference_identifier": candidate.get("reference_identifier")}, db_path=db_path, actor=actor) | ||
| if evidence.get("metadata_url"): | ||
| add_evidence(case["case_id"], evidence_type="registry_metadata", title="Registry metadata captured during candidate discovery", locator=evidence["metadata_url"], provenance="official registry monitor", notes=candidate.get("reason") or "", metadata={"candidate_id": candidate["candidate_id"]}, db_path=db_path, actor=actor) | ||
| with closing(soc_store.connect(db_path)) as connection: | ||
| connection.execute("UPDATE research_candidates SET status = 'promoted', case_id = ? WHERE candidate_id = ?", (case["case_id"], candidate["candidate_id"])) | ||
| connection.execute("INSERT OR IGNORE INTO research_promotion_events (event_id, candidate_id, policy_ecosystem, decision, reasons_json, case_id, actor, created_at) VALUES (?, ?, ?, 'draft_case_created', ?, ?, ?, ?)", (_id("RPE"), candidate["candidate_id"], policy["ecosystem"], _json(decision["reasons"]), case["case_id"], actor, _now())) | ||
| connection.commit() | ||
| decision["case_id"] = case["case_id"] | ||
| decision["applied"] = True | ||
| decisions.append(decision) | ||
| return {"schema_version": "secopsai.research.promotion-policy.v1", "policy": policy, "apply": bool(apply), "evaluated": len(decisions), "eligible": sum(bool(item["eligible"]) for item in decisions), "promoted": sum(bool(item.get("applied")) for item in decisions), "decisions": decisions} | ||
|
|
||
|
|
||
| def create_candidate_alert(candidate: Dict[str, Any], *, db_path: Optional[str] = None) -> Dict[str, Any]: | ||
| """Create a deduplicated alert for a high-confidence candidate.""" | ||
| soc_store.init_db(db_path) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| from __future__ import annotations | ||
|
|
||
| import json | ||
| import tempfile | ||
| from pathlib import Path | ||
|
|
||
| import soc_store | ||
| from secopsai.research_discovery import get_promotion_policy, run_promotion_policy, set_promotion_policy | ||
|
|
||
|
|
||
| def _candidate(db_path: str, *, score: float = 96.0) -> str: | ||
| soc_store.init_db(db_path) | ||
| candidate_id = "CAN-POLICYTEST0001" | ||
| with soc_store.connect(db_path) as connection: | ||
| connection.execute( | ||
| """INSERT INTO research_candidates | ||
| (candidate_id, event_id, watchlist_id, ecosystem, package, version, reference_identifier, | ||
| score, score_components_json, reason, status, case_id, evidence_json, first_seen, | ||
| last_seen, algorithm_version) | ||
| VALUES (?, NULL, NULL, 'nuget', 'Braintree.Net.Test', '1.0.0', 'Braintree.Net', ?, '{}', | ||
| 'test similarity candidate', 'new', NULL, ?, '2026-07-29T00:00:00Z', | ||
| '2026-07-29T00:00:00Z', 'similarity-1')""", | ||
| (candidate_id, score, json.dumps({"metadata_url": "https://api.nuget.org/v3/registration5-gz-semver2/braintree.net.test/index.json", "artifact_url": "https://api.nuget.org/v3-flatcontainer/braintree.net.test/1.0.0/braintree.net.test.1.0.0.nupkg", "publisher": "unexpected"})), | ||
| ) | ||
| connection.commit() | ||
| return candidate_id | ||
|
|
||
|
|
||
| def test_promotion_policy_is_disabled_and_preview_only_by_default() -> None: | ||
| with tempfile.TemporaryDirectory() as temp_dir: | ||
| db_path = str(Path(temp_dir) / "soc.db") | ||
| _candidate(db_path) | ||
| policy = get_promotion_policy(ecosystem="nuget", db_path=db_path) | ||
| assert policy["enabled"] == 0 | ||
| preview = run_promotion_policy(ecosystem="nuget", db_path=db_path) | ||
| assert preview["promoted"] == 0 | ||
| assert preview["decisions"][0]["eligible"] is False | ||
|
|
||
|
|
||
| def test_enabled_policy_creates_auditable_draft_case_without_a_verdict() -> None: | ||
| with tempfile.TemporaryDirectory() as temp_dir: | ||
| db_path = str(Path(temp_dir) / "soc.db") | ||
| candidate_id = _candidate(db_path) | ||
| set_promotion_policy(ecosystem="nuget", enabled=True, score_threshold=90, minimum_evidence=2, require_publisher=True, actor="test-operator", db_path=db_path) | ||
| result = run_promotion_policy(ecosystem="nuget", apply=True, actor="test-operator", db_path=db_path) | ||
| assert result["promoted"] == 1 | ||
| assert result["decisions"][0]["case_id"].startswith("RSC-") | ||
| with soc_store.connect(db_path) as connection: | ||
| candidate = connection.execute("SELECT status, case_id FROM research_candidates WHERE candidate_id = ?", (candidate_id,)).fetchone() | ||
| case = connection.execute("SELECT status, payload_json FROM research_cases WHERE case_id = ?", (candidate["case_id"],)).fetchone() | ||
| event = connection.execute("SELECT decision, actor FROM research_promotion_events WHERE candidate_id = ?", (candidate_id,)).fetchone() | ||
| assert candidate["status"] == "promoted" | ||
| assert case["status"] == "draft" | ||
| assert "promotion_policy" in case["payload_json"] | ||
| assert event["decision"] == "draft_case_created" | ||
| assert event["actor"] == "test-operator" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When a scored feed event has a metadata URL and an author/publisher but no artifact URL, the default
minimum_evidence=2gate passes becausepublisheris counted as a reference.score_pending_events()commonly creates exactly this shape when the registry leaf URL is metadata rather than an artifact, so candidates can be promoted with only one actual evidence reference; publisher presence should remain the separaterequire_publishergate described by the policy.Useful? React with 👍 / 👎.