git-oops 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- git_oops-1.0.0.dist-info/METADATA +198 -0
- git_oops-1.0.0.dist-info/RECORD +32 -0
- git_oops-1.0.0.dist-info/WHEEL +5 -0
- git_oops-1.0.0.dist-info/entry_points.txt +2 -0
- git_oops-1.0.0.dist-info/licenses/LICENSE +21 -0
- git_oops-1.0.0.dist-info/top_level.txt +1 -0
- src/__init__.py +16 -0
- src/blackbox.py +119 -0
- src/circuit_breaker.py +129 -0
- src/cli.py +556 -0
- src/consensus.py +58 -0
- src/engine.py +222 -0
- src/firewall.py +48 -0
- src/guardian.py +589 -0
- src/hardware.py +137 -0
- src/hooks.py +525 -0
- src/hud.py +493 -0
- src/local_llm.py +116 -0
- src/mcp_server.py +124 -0
- src/replay.py +100 -0
- src/reviewer.py +157 -0
- src/rules.py +291 -0
- src/salvage.py +92 -0
- src/sentinel.py +79 -0
- src/shadow.py +70 -0
- src/solver.py +158 -0
- src/squeezer.py +64 -0
- src/supply_chain.py +142 -0
- src/tester.py +54 -0
- src/ui.py +124 -0
- src/updater.py +49 -0
- src/watchdog.py +96 -0
src/mcp_server.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Git-Oops Native Model Context Protocol (MCP) Server
|
|
3
|
+
Allows Claude Desktop, Cursor, Windsurf, and Antigravity to attach to git-oops.
|
|
4
|
+
Speaks standard JSON-RPC 2.0 over stdio without any external networking baggage.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import sys
|
|
8
|
+
import json
|
|
9
|
+
from src.firewall import check_command_safety
|
|
10
|
+
from src.squeezer import compress_traceback, redact_secrets
|
|
11
|
+
from src.guardian import check_ghost_imports
|
|
12
|
+
from src.engine import diagnose_and_patch, apply_patch_to_file
|
|
13
|
+
|
|
14
|
+
MCP_TOOLS = [
|
|
15
|
+
{
|
|
16
|
+
"name": "git_oops_diagnose",
|
|
17
|
+
"description": "Analyzes a terminal crash traceback, roasts the code, and generates a surgical git diff patch.",
|
|
18
|
+
"inputSchema": {
|
|
19
|
+
"type": "object",
|
|
20
|
+
"properties": {
|
|
21
|
+
"filepath": {"type": "string", "description": "Target file path"},
|
|
22
|
+
"error": {"type": "string", "description": "Crash traceback or exception message"}
|
|
23
|
+
},
|
|
24
|
+
"required": ["filepath", "error"]
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"name": "git_oops_safe_exec",
|
|
29
|
+
"description": "Runs a shell command through the Blast-Radius firewall, preventing destructive deletions.",
|
|
30
|
+
"inputSchema": {
|
|
31
|
+
"type": "object",
|
|
32
|
+
"properties": {
|
|
33
|
+
"command": {"type": "string", "description": "Command line to execute"}
|
|
34
|
+
},
|
|
35
|
+
"required": ["command"]
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"name": "git_oops_squeezer",
|
|
40
|
+
"description": "Compresses noisy multi-thousand line terminal output into high-density context and redacts secrets.",
|
|
41
|
+
"inputSchema": {
|
|
42
|
+
"type": "object",
|
|
43
|
+
"properties": {
|
|
44
|
+
"raw_logs": {"type": "string", "description": "Raw noisy logs"}
|
|
45
|
+
},
|
|
46
|
+
"required": ["raw_logs"]
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def run_mcp_server():
|
|
53
|
+
"""Stdio JSON-RPC loop for Model Context Protocol agents."""
|
|
54
|
+
sys.stderr.write("⚡ [GIT-OOPS MCP SERVER] Active and listening on stdio (JSON-RPC 2.0)...\n")
|
|
55
|
+
sys.stderr.flush()
|
|
56
|
+
|
|
57
|
+
for line in sys.stdin:
|
|
58
|
+
line = line.strip()
|
|
59
|
+
if not line:
|
|
60
|
+
continue
|
|
61
|
+
try:
|
|
62
|
+
req = json.loads(line)
|
|
63
|
+
except Exception:
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
req_id = req.get("id")
|
|
67
|
+
method = req.get("method")
|
|
68
|
+
params = req.get("params", {})
|
|
69
|
+
|
|
70
|
+
if method == "initialize":
|
|
71
|
+
res = {
|
|
72
|
+
"jsonrpc": "2.0",
|
|
73
|
+
"id": req_id,
|
|
74
|
+
"result": {
|
|
75
|
+
"protocolVersion": "2024-11-05",
|
|
76
|
+
"capabilities": {"tools": {}},
|
|
77
|
+
"serverInfo": {"name": "git-oops", "version": "0.2.0"}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
elif method == "tools/list":
|
|
81
|
+
res = {
|
|
82
|
+
"jsonrpc": "2.0",
|
|
83
|
+
"id": req_id,
|
|
84
|
+
"result": {"tools": MCP_TOOLS}
|
|
85
|
+
}
|
|
86
|
+
elif method == "tools/call":
|
|
87
|
+
tool_name = params.get("name")
|
|
88
|
+
args = params.get("arguments", {})
|
|
89
|
+
|
|
90
|
+
if tool_name == "git_oops_safe_exec":
|
|
91
|
+
cmd = args.get("command", "")
|
|
92
|
+
safe, reason = check_command_safety(cmd)
|
|
93
|
+
content = [{"type": "text", "text": json.dumps({"allowed": safe, "reason": reason})}]
|
|
94
|
+
elif tool_name == "git_oops_squeezer":
|
|
95
|
+
logs = args.get("raw_logs", "")
|
|
96
|
+
compressed = compress_traceback(logs)
|
|
97
|
+
content = [{"type": "text", "text": compressed}]
|
|
98
|
+
elif tool_name == "git_oops_diagnose":
|
|
99
|
+
fp = args.get("filepath", "")
|
|
100
|
+
err = args.get("error", "")
|
|
101
|
+
try:
|
|
102
|
+
with open(fp, "r", encoding="utf-8") as f:
|
|
103
|
+
code_ctx = f.read()
|
|
104
|
+
diag = diagnose_and_patch(err, code_ctx, fp)
|
|
105
|
+
content = [{"type": "text", "text": json.dumps(diag)}]
|
|
106
|
+
except Exception as ex:
|
|
107
|
+
content = [{"type": "text", "text": json.dumps({"error": str(ex)})}]
|
|
108
|
+
else:
|
|
109
|
+
content = [{"type": "text", "text": f"Unknown tool: {tool_name}"}]
|
|
110
|
+
|
|
111
|
+
res = {
|
|
112
|
+
"jsonrpc": "2.0",
|
|
113
|
+
"id": req_id,
|
|
114
|
+
"result": {"content": content}
|
|
115
|
+
}
|
|
116
|
+
else:
|
|
117
|
+
res = {
|
|
118
|
+
"jsonrpc": "2.0",
|
|
119
|
+
"id": req_id,
|
|
120
|
+
"result": {}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
sys.stdout.write(json.dumps(res) + "\n")
|
|
124
|
+
sys.stdout.flush()
|
src/replay.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Git-Oops Post-Mortem Crash Replay Time-Machine:
|
|
3
|
+
Reconstructs terminal variables, locals, and scope right at the moment of the crash.
|
|
4
|
+
Zero dependencies: Uses native traceback inspections and saved snapshot states.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import json
|
|
10
|
+
import traceback
|
|
11
|
+
from typing import Dict, Any, Optional
|
|
12
|
+
|
|
13
|
+
CRASH_SNAPSHOT_DIR = os.path.expanduser("~/.git_oops/crashes")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def record_crash_scope(filepath: str, error_log: str, local_vars: Optional[Dict[str, Any]] = None):
|
|
17
|
+
"""Saves post-mortem scope variables to local storage."""
|
|
18
|
+
os.makedirs(CRASH_SNAPSHOT_DIR, exist_ok=True)
|
|
19
|
+
clean_locals = {}
|
|
20
|
+
if local_vars:
|
|
21
|
+
for k, v in local_vars.items():
|
|
22
|
+
if not k.startswith("__"):
|
|
23
|
+
clean_locals[k] = str(v)[:150]
|
|
24
|
+
|
|
25
|
+
record = {
|
|
26
|
+
"timestamp": os.path.getmtime(filepath) if os.path.exists(filepath) else 0,
|
|
27
|
+
"filepath": filepath,
|
|
28
|
+
"error_log": error_log.strip()[:1000],
|
|
29
|
+
"locals": clean_locals
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
snap_file = os.path.join(CRASH_SNAPSHOT_DIR, "last_crash.json")
|
|
33
|
+
with open(snap_file, "w", encoding="utf-8") as f:
|
|
34
|
+
json.dump(record, f, indent=2)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_last_crash_replay() -> Optional[Dict[str, Any]]:
|
|
38
|
+
"""Loads the last recorded post-mortem crash state."""
|
|
39
|
+
snap_file = os.path.join(CRASH_SNAPSHOT_DIR, "last_crash.json")
|
|
40
|
+
if not os.path.exists(snap_file):
|
|
41
|
+
return None
|
|
42
|
+
try:
|
|
43
|
+
with open(snap_file, "r", encoding="utf-8") as f:
|
|
44
|
+
return json.load(f)
|
|
45
|
+
except Exception:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def record_crash_snapshot(
|
|
50
|
+
target_file: str,
|
|
51
|
+
exception_type: str,
|
|
52
|
+
exception_message: str,
|
|
53
|
+
stack_frames: list = None,
|
|
54
|
+
local_vars: Optional[Dict[str, Any]] = None
|
|
55
|
+
) -> str:
|
|
56
|
+
"""Records detailed crash snapshot into ~/.git_oops/crashes/<id>.json."""
|
|
57
|
+
os.makedirs(CRASH_SNAPSHOT_DIR, exist_ok=True)
|
|
58
|
+
import time
|
|
59
|
+
snap_id = f"crash_{int(time.time() * 1000)}.json"
|
|
60
|
+
clean_locals = {}
|
|
61
|
+
if local_vars:
|
|
62
|
+
for k, v in local_vars.items():
|
|
63
|
+
if not k.startswith("__"):
|
|
64
|
+
clean_locals[k] = v
|
|
65
|
+
|
|
66
|
+
data = {
|
|
67
|
+
"snapshot_id": snap_id,
|
|
68
|
+
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
69
|
+
"target_file": target_file,
|
|
70
|
+
"exception_type": exception_type,
|
|
71
|
+
"exception_message": exception_message,
|
|
72
|
+
"stack_frames": stack_frames or [],
|
|
73
|
+
"local_variables": clean_locals
|
|
74
|
+
}
|
|
75
|
+
with open(os.path.join(CRASH_SNAPSHOT_DIR, snap_id), "w", encoding="utf-8") as f:
|
|
76
|
+
json.dump(data, f, indent=2)
|
|
77
|
+
# Also link as last_crash.json
|
|
78
|
+
with open(os.path.join(CRASH_SNAPSHOT_DIR, "last_crash.json"), "w", encoding="utf-8") as f:
|
|
79
|
+
json.dump(data, f, indent=2)
|
|
80
|
+
return snap_id
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def inspect_crash_snapshot(snap_id: str = "last_crash.json") -> Dict[str, Any]:
|
|
84
|
+
"""Inspects a recorded crash snapshot."""
|
|
85
|
+
target_path = os.path.join(CRASH_SNAPSHOT_DIR, snap_id) if not os.path.isabs(snap_id) else snap_id
|
|
86
|
+
if not os.path.exists(target_path):
|
|
87
|
+
return {"status": "NOT_FOUND", "path": target_path}
|
|
88
|
+
try:
|
|
89
|
+
with open(target_path, "r", encoding="utf-8") as f:
|
|
90
|
+
return json.load(f)
|
|
91
|
+
except Exception as e:
|
|
92
|
+
return {"status": "ERROR", "details": str(e)}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def list_crash_snapshots(limit: int = 10) -> list:
|
|
96
|
+
"""Lists recent crash snapshots."""
|
|
97
|
+
if not os.path.exists(CRASH_SNAPSHOT_DIR):
|
|
98
|
+
return []
|
|
99
|
+
files = sorted([f for f in os.listdir(CRASH_SNAPSHOT_DIR) if f.startswith("crash_") and f.endswith(".json")], reverse=True)
|
|
100
|
+
return files[:limit]
|
src/reviewer.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Git-Oops Pre-PR Gatekeeper & Staff Engineer Audit Engine
|
|
3
|
+
Audits outgoing branch changes against base branch before Pull Request creation.
|
|
4
|
+
Verifies L3 security, complexity drift, test coverage parity, and secret leaks.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
import json
|
|
10
|
+
import subprocess
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Dict, Any, List, Tuple
|
|
13
|
+
|
|
14
|
+
from src.hooks import scan_diff_for_secrets, scan_file_ast
|
|
15
|
+
from src.sentinel import check_code_complexity
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_default_base_branch() -> str:
|
|
19
|
+
"""Detects default remote base branch (origin/main or main or master)."""
|
|
20
|
+
for candidate in ("origin/main", "main", "origin/master", "master"):
|
|
21
|
+
res = subprocess.run(
|
|
22
|
+
["git", "rev-parse", "--verify", candidate],
|
|
23
|
+
capture_output=True, text=True, errors="replace"
|
|
24
|
+
)
|
|
25
|
+
if res.returncode == 0:
|
|
26
|
+
return candidate
|
|
27
|
+
return "HEAD~1"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def review_pull_request(base_branch: str = None) -> Dict[str, Any]:
|
|
31
|
+
"""
|
|
32
|
+
Performs comprehensive Pre-PR audit:
|
|
33
|
+
- Diff analysis vs base_branch
|
|
34
|
+
- Secret & token leak inspection
|
|
35
|
+
- AST security (CodeQL CWE mirrors)
|
|
36
|
+
- Code bloat & complexity check
|
|
37
|
+
- Test parity verification
|
|
38
|
+
"""
|
|
39
|
+
base = base_branch or get_default_base_branch()
|
|
40
|
+
|
|
41
|
+
# Get diff against base
|
|
42
|
+
diff_proc = subprocess.run(
|
|
43
|
+
["git", "diff", f"{base}...HEAD"],
|
|
44
|
+
capture_output=True, text=True, errors="replace"
|
|
45
|
+
)
|
|
46
|
+
diff_text = diff_proc.stdout
|
|
47
|
+
|
|
48
|
+
# Get changed files
|
|
49
|
+
files_proc = subprocess.run(
|
|
50
|
+
["git", "diff", "--name-only", f"{base}...HEAD"],
|
|
51
|
+
capture_output=True, text=True, errors="replace"
|
|
52
|
+
)
|
|
53
|
+
changed_files = [f.strip() for f in files_proc.stdout.splitlines() if f.strip()]
|
|
54
|
+
|
|
55
|
+
# 1. Secret scanning
|
|
56
|
+
secret_findings = scan_diff_for_secrets(diff_text)
|
|
57
|
+
|
|
58
|
+
# 2. AST security & complexity on modified Python files
|
|
59
|
+
ast_findings: List[Dict[str, Any]] = []
|
|
60
|
+
complexity_warnings: List[Dict[str, Any]] = []
|
|
61
|
+
|
|
62
|
+
src_changed = False
|
|
63
|
+
tests_changed = False
|
|
64
|
+
|
|
65
|
+
for fpath in changed_files:
|
|
66
|
+
if fpath.startswith("tests/") or "test" in fpath.lower():
|
|
67
|
+
tests_changed = True
|
|
68
|
+
elif fpath.startswith("src/") or fpath.endswith(".py"):
|
|
69
|
+
src_changed = True
|
|
70
|
+
|
|
71
|
+
if fpath.endswith(".py") and os.path.exists(fpath):
|
|
72
|
+
findings = scan_file_ast(fpath)
|
|
73
|
+
ast_findings.extend(findings)
|
|
74
|
+
|
|
75
|
+
# Check complexity
|
|
76
|
+
try:
|
|
77
|
+
with open(fpath, "r", encoding="utf-8", errors="replace") as f:
|
|
78
|
+
content = f.read()
|
|
79
|
+
ok, err = check_code_complexity(content, max_allowed_complexity=12)
|
|
80
|
+
if not ok:
|
|
81
|
+
complexity_warnings.append({"file": fpath, "details": err})
|
|
82
|
+
except Exception:
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
# 3. Test parity warning
|
|
86
|
+
test_parity_warning = None
|
|
87
|
+
if src_changed and not tests_changed:
|
|
88
|
+
test_parity_warning = "Application code modified without accompanying test updates in tests/."
|
|
89
|
+
|
|
90
|
+
# Determine verdict
|
|
91
|
+
critical_issues = len(secret_findings) + len([f for f in ast_findings if f.get("severity") in ("CRITICAL", "HIGH")])
|
|
92
|
+
if critical_issues > 0:
|
|
93
|
+
verdict = "BLOCKED"
|
|
94
|
+
elif complexity_warnings or test_parity_warning:
|
|
95
|
+
verdict = "WARNINGS"
|
|
96
|
+
else:
|
|
97
|
+
verdict = "APPROVED"
|
|
98
|
+
|
|
99
|
+
# Churn stats
|
|
100
|
+
added_lines = sum(1 for line in diff_text.splitlines() if line.startswith("+") and not line.startswith("+++"))
|
|
101
|
+
deleted_lines = sum(1 for line in diff_text.splitlines() if line.startswith("-") and not line.startswith("---"))
|
|
102
|
+
|
|
103
|
+
report = {
|
|
104
|
+
"verdict": verdict,
|
|
105
|
+
"base_branch": base,
|
|
106
|
+
"changed_files_count": len(changed_files),
|
|
107
|
+
"changed_files": changed_files,
|
|
108
|
+
"lines_added": added_lines,
|
|
109
|
+
"lines_deleted": deleted_lines,
|
|
110
|
+
"secret_findings": secret_findings,
|
|
111
|
+
"ast_findings": ast_findings,
|
|
112
|
+
"complexity_warnings": complexity_warnings,
|
|
113
|
+
"test_parity_warning": test_parity_warning
|
|
114
|
+
}
|
|
115
|
+
return report
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def format_pr_review_markdown(report: Dict[str, Any]) -> str:
|
|
119
|
+
"""Formats PR audit report as clean GitHub Flavored Markdown."""
|
|
120
|
+
verdict = report["verdict"]
|
|
121
|
+
badge = "🟢 **APPROVED**" if verdict == "APPROVED" else ("🟡 **WARNINGS**" if verdict == "WARNINGS" else "🔴 **BLOCKED**")
|
|
122
|
+
|
|
123
|
+
lines = [
|
|
124
|
+
"## 🛡️ Git-Oops Pre-PR Gatekeeper Audit Report",
|
|
125
|
+
"",
|
|
126
|
+
f"**Verdict**: {badge} (Base branch: `{report['base_branch']}`)",
|
|
127
|
+
f"**Diff Churn**: `{report['changed_files_count']}` files modified (+{report['lines_added']} / -{report['lines_deleted']} lines)",
|
|
128
|
+
""
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
if report["secret_findings"]:
|
|
132
|
+
lines.append("### 🚨 Critical Secret Leaks Detected")
|
|
133
|
+
for s in report["secret_findings"]:
|
|
134
|
+
lines.append(f"- **{s['label']}** at line {s['line']}: `{s['snippet']}`")
|
|
135
|
+
lines.append("")
|
|
136
|
+
|
|
137
|
+
if report["ast_findings"]:
|
|
138
|
+
lines.append("### ⚠️ AST Security & CodeQL Violations")
|
|
139
|
+
for a in report["ast_findings"]:
|
|
140
|
+
lines.append(f"- **[{a.get('severity', 'HIGH')}] {a.get('rule', 'AST')}** in `{a.get('file')}:{a.get('line')}`: {a.get('msg')}")
|
|
141
|
+
lines.append("")
|
|
142
|
+
|
|
143
|
+
if report["test_parity_warning"]:
|
|
144
|
+
lines.append("### 🧪 Test Parity Notice")
|
|
145
|
+
lines.append(f"- ⚠️ {report['test_parity_warning']}")
|
|
146
|
+
lines.append("")
|
|
147
|
+
|
|
148
|
+
if report["complexity_warnings"]:
|
|
149
|
+
lines.append("### 📈 Complexity Drift")
|
|
150
|
+
for c in report["complexity_warnings"]:
|
|
151
|
+
lines.append(f"- `{c['file']}`: {c['details']}")
|
|
152
|
+
lines.append("")
|
|
153
|
+
|
|
154
|
+
if verdict == "APPROVED":
|
|
155
|
+
lines.append("✨ All L3 security checks passed cleanly. Ready for production merge!")
|
|
156
|
+
|
|
157
|
+
return "\n".join(lines)
|
src/rules.py
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Git-Oops Dynamic OTA Security Rules Engine
|
|
3
|
+
Decouples security signatures from Python source code.
|
|
4
|
+
Automatically syncs latest CVEs, malicious packages, and secret regex patterns
|
|
5
|
+
in the background when internet is available, with pure offline baseline fallback.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
import json
|
|
11
|
+
import time
|
|
12
|
+
import urllib.request
|
|
13
|
+
import urllib.error
|
|
14
|
+
import threading
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Dict, Any, List, Tuple, Optional
|
|
17
|
+
|
|
18
|
+
RULES_DIR = Path.home() / ".git_oops" / "rules"
|
|
19
|
+
RULES_FILE = RULES_DIR / "security_rules.json"
|
|
20
|
+
RULES_CACHE_META = RULES_DIR / "rules_meta.json"
|
|
21
|
+
|
|
22
|
+
# 24-hour cache TTL
|
|
23
|
+
SYNC_INTERVAL_SECONDS = 86400
|
|
24
|
+
|
|
25
|
+
# Built-in offline baseline rules (Hardened fallback - zero network required)
|
|
26
|
+
OFFLINE_BASELINE_RULES: Dict[str, Any] = {
|
|
27
|
+
"version": "1.0.0-baseline",
|
|
28
|
+
"last_updated": 1731550000,
|
|
29
|
+
"secret_patterns": [
|
|
30
|
+
{"pattern": r"(?i)(?:api[_-]?key|secret|password|passwd|auth[_-]?token)\s*[:=]\s*['\"]([a-zA-Z0-9_\-\.]{16,})['\"]", "label": "Exposed Secret Literal", "severity": "CRITICAL"},
|
|
31
|
+
{"pattern": r"(?i)sk-[a-zA-Z0-9]{32,}", "label": "OpenAI API Key", "severity": "CRITICAL"},
|
|
32
|
+
{"pattern": r"(?i)AKIA[0-9A-Z]{16}", "label": "AWS Access Key ID", "severity": "CRITICAL"},
|
|
33
|
+
{"pattern": r"(?i)ghp_[a-zA-Z0-9]{36}", "label": "GitHub Personal Access Token", "severity": "CRITICAL"},
|
|
34
|
+
{"pattern": r"(?i)xox[baprs]-[0-9a-zA-Z]{10,48}", "label": "Slack OAuth Token", "severity": "CRITICAL"},
|
|
35
|
+
{"pattern": r"-----BEGIN (?:RSA|OPENSSH|DSA|EC|PGP)? PRIVATE KEY-----", "label": "Cryptographic Private Key", "severity": "CRITICAL"}
|
|
36
|
+
],
|
|
37
|
+
"malicious_packages": [
|
|
38
|
+
"reqeusts", "colorma", "djagno", "falsk", "beautiflsoup",
|
|
39
|
+
"cryptograpy", "numpay", "scipyy", "pandass", "tensoflow",
|
|
40
|
+
"pytocrh", "opencvv", "selenuim", "urlib3", "certifii"
|
|
41
|
+
],
|
|
42
|
+
"cwe_thresholds": {
|
|
43
|
+
"max_cyclomatic_complexity": 10,
|
|
44
|
+
"shannon_entropy_threshold": 4.6
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# Upstream OTA rule endpoints (Primary GitHub Raw feed + fallback)
|
|
49
|
+
OTA_FEED_URLS = [
|
|
50
|
+
"https://raw.githubusercontent.com/Hao610/git-oops/main/rules/security_rules.json",
|
|
51
|
+
"https://api.github.com/repos/Hao610/git-oops/contents/rules/security_rules.json"
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ---------------------------------------------------------------------------
|
|
56
|
+
# Additive User-Custom Policy Support (Immutable Baseline + Additive Custom)
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
CUSTOM_GLOBAL_RULES_FILE = RULES_DIR / "custom_rules.json"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def find_repo_custom_rules_file() -> Optional[Path]:
|
|
63
|
+
"""Finds repo-level custom rules file (.oops/rules.json or .oops-rules.json)."""
|
|
64
|
+
cwd = Path.cwd()
|
|
65
|
+
for cand in (cwd / ".oops" / "rules.json", cwd / ".oops-rules.json", cwd / "oops-rules.json"):
|
|
66
|
+
if cand.exists():
|
|
67
|
+
return cand
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def load_user_custom_rules() -> Dict[str, Any]:
|
|
72
|
+
"""
|
|
73
|
+
Loads user-defined custom security rules from:
|
|
74
|
+
1. Repo root (.oops/rules.json or .oops-rules.json)
|
|
75
|
+
2. Global custom rules (~/.git_oops/rules/custom_rules.json)
|
|
76
|
+
Users can ONLY ADD custom rules; they CANNOT delete or bypass system baselines.
|
|
77
|
+
"""
|
|
78
|
+
custom = {
|
|
79
|
+
"extra_secret_patterns": [],
|
|
80
|
+
"extra_banned_packages": [],
|
|
81
|
+
"extra_forbidden_calls": []
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
# 1. Global custom rules
|
|
85
|
+
if CUSTOM_GLOBAL_RULES_FILE.exists():
|
|
86
|
+
try:
|
|
87
|
+
with open(CUSTOM_GLOBAL_RULES_FILE, "r", encoding="utf-8") as f:
|
|
88
|
+
data = json.load(f)
|
|
89
|
+
custom["extra_secret_patterns"].extend(data.get("extra_secret_patterns", []))
|
|
90
|
+
custom["extra_banned_packages"].extend(data.get("extra_banned_packages", []))
|
|
91
|
+
custom["extra_forbidden_calls"].extend(data.get("extra_forbidden_calls", []))
|
|
92
|
+
except Exception:
|
|
93
|
+
pass
|
|
94
|
+
|
|
95
|
+
# 2. Repo-level custom rules (takes precedence for repo-specific policies)
|
|
96
|
+
repo_rules = find_repo_custom_rules_file()
|
|
97
|
+
if repo_rules:
|
|
98
|
+
try:
|
|
99
|
+
with open(repo_rules, "r", encoding="utf-8") as f:
|
|
100
|
+
data = json.load(f)
|
|
101
|
+
custom["extra_secret_patterns"].extend(data.get("extra_secret_patterns", []))
|
|
102
|
+
custom["extra_banned_packages"].extend(data.get("extra_banned_packages", []))
|
|
103
|
+
custom["extra_forbidden_calls"].extend(data.get("extra_forbidden_calls", []))
|
|
104
|
+
except Exception:
|
|
105
|
+
pass
|
|
106
|
+
|
|
107
|
+
return custom
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def is_valid_regex(pattern_str: str) -> bool:
|
|
111
|
+
"""Checks if regex compiles without syntax error."""
|
|
112
|
+
import re
|
|
113
|
+
if not pattern_str or not isinstance(pattern_str, str):
|
|
114
|
+
return False
|
|
115
|
+
try:
|
|
116
|
+
re.compile(pattern_str)
|
|
117
|
+
return True
|
|
118
|
+
except Exception:
|
|
119
|
+
return False
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def load_security_rules() -> Dict[str, Any]:
|
|
123
|
+
"""
|
|
124
|
+
Loads merged security rules with cryptographic integrity and tamper resistance:
|
|
125
|
+
[Hardcoded Immutable Baseline] + [OTA Live Dynamic Signatures] + [User Custom Additions]
|
|
126
|
+
Baseline checks CANNOT be removed, overridden, or hollowed out by tampering.
|
|
127
|
+
"""
|
|
128
|
+
# 1. Start with immutable baseline constant directly from Python memory
|
|
129
|
+
merged = json.loads(json.dumps(OFFLINE_BASELINE_RULES))
|
|
130
|
+
|
|
131
|
+
# 2. Layer OTA dynamic signatures if available (with tamper & corruption check)
|
|
132
|
+
tamper_detected = False
|
|
133
|
+
try:
|
|
134
|
+
if RULES_FILE.exists():
|
|
135
|
+
with open(RULES_FILE, "r", encoding="utf-8") as f:
|
|
136
|
+
ota_data = json.load(f)
|
|
137
|
+
if isinstance(ota_data, dict):
|
|
138
|
+
# Union malicious packages
|
|
139
|
+
incoming_pkgs = ota_data.get("malicious_packages", [])
|
|
140
|
+
if isinstance(incoming_pkgs, list):
|
|
141
|
+
pkgs = set(merged.get("malicious_packages", [])) | set(incoming_pkgs)
|
|
142
|
+
merged["malicious_packages"] = sorted(list(pkgs))
|
|
143
|
+
|
|
144
|
+
# Union secret patterns (discarding any invalid or tampered regexes)
|
|
145
|
+
curr_patts = {p["pattern"]: p for p in merged.get("secret_patterns", [])}
|
|
146
|
+
for p in ota_data.get("secret_patterns", []):
|
|
147
|
+
if isinstance(p, dict) and is_valid_regex(p.get("pattern", "")):
|
|
148
|
+
curr_patts[p["pattern"]] = p
|
|
149
|
+
merged["secret_patterns"] = list(curr_patts.values())
|
|
150
|
+
else:
|
|
151
|
+
tamper_detected = True
|
|
152
|
+
except Exception:
|
|
153
|
+
tamper_detected = True
|
|
154
|
+
|
|
155
|
+
# 3. Layer User's Custom Additions (Additive-Only)
|
|
156
|
+
user_custom = load_user_custom_rules()
|
|
157
|
+
if user_custom["extra_banned_packages"]:
|
|
158
|
+
merged["malicious_packages"] = sorted(list(set(merged.get("malicious_packages", [])) | set(user_custom["extra_banned_packages"])))
|
|
159
|
+
|
|
160
|
+
if user_custom["extra_secret_patterns"]:
|
|
161
|
+
curr_patts = {p["pattern"]: p for p in merged.get("secret_patterns", [])}
|
|
162
|
+
for p in user_custom["extra_secret_patterns"]:
|
|
163
|
+
if isinstance(p, dict) and is_valid_regex(p.get("pattern", "")):
|
|
164
|
+
curr_patts[p["pattern"]] = p
|
|
165
|
+
merged["secret_patterns"] = list(curr_patts.values())
|
|
166
|
+
|
|
167
|
+
merged["custom_forbidden_calls"] = user_custom.get("extra_forbidden_calls", [])
|
|
168
|
+
|
|
169
|
+
# 4. Anti-Tamper Invariant Check:
|
|
170
|
+
# Baseline packages and secret patterns must be strictly preserved.
|
|
171
|
+
# If file was deleted or corrupted, instantly self-heal and re-serialize to disk.
|
|
172
|
+
baseline_pkgs = set(OFFLINE_BASELINE_RULES["malicious_packages"])
|
|
173
|
+
if not baseline_pkgs.issubset(set(merged["malicious_packages"])) or tamper_detected or not RULES_FILE.exists():
|
|
174
|
+
save_security_rules(merged)
|
|
175
|
+
|
|
176
|
+
return merged
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def save_security_rules(rules: Dict[str, Any]) -> bool:
|
|
180
|
+
"""Saves updated dynamic security rules to ~/.git_oops/rules/security_rules.json."""
|
|
181
|
+
try:
|
|
182
|
+
RULES_DIR.mkdir(parents=True, exist_ok=True)
|
|
183
|
+
with open(RULES_FILE, "w", encoding="utf-8") as f:
|
|
184
|
+
json.dump(rules, f, indent=2, ensure_ascii=False)
|
|
185
|
+
return True
|
|
186
|
+
except Exception:
|
|
187
|
+
return False
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def is_sync_due() -> bool:
|
|
191
|
+
"""Checks if 24 hours have elapsed since the last OTA check."""
|
|
192
|
+
try:
|
|
193
|
+
if not RULES_CACHE_META.exists():
|
|
194
|
+
return True
|
|
195
|
+
with open(RULES_CACHE_META, "r", encoding="utf-8") as f:
|
|
196
|
+
meta = json.load(f)
|
|
197
|
+
last_sync = meta.get("last_sync_timestamp", 0)
|
|
198
|
+
return (time.time() - last_sync) > SYNC_INTERVAL_SECONDS
|
|
199
|
+
except Exception:
|
|
200
|
+
return True
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def record_sync_timestamp(status: str = "success"):
|
|
204
|
+
"""Records the timestamp of the last OTA rule check."""
|
|
205
|
+
try:
|
|
206
|
+
RULES_DIR.mkdir(parents=True, exist_ok=True)
|
|
207
|
+
with open(RULES_CACHE_META, "w", encoding="utf-8") as f:
|
|
208
|
+
json.dump({"last_sync_timestamp": time.time(), "status": status}, f)
|
|
209
|
+
except Exception:
|
|
210
|
+
pass
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def fetch_remote_rules(timeout: float = 2.0) -> Tuple[bool, Dict[str, Any]]:
|
|
214
|
+
"""
|
|
215
|
+
Attempts to download latest dynamic security rules from remote OTA feed.
|
|
216
|
+
Times out in 2.0 seconds so it never hangs.
|
|
217
|
+
"""
|
|
218
|
+
for url in OTA_FEED_URLS:
|
|
219
|
+
try:
|
|
220
|
+
req = urllib.request.Request(
|
|
221
|
+
url,
|
|
222
|
+
headers={"User-Agent": "git-oops-ota-client/1.0.0"}
|
|
223
|
+
)
|
|
224
|
+
with urllib.request.urlopen(req, timeout=timeout) as response:
|
|
225
|
+
if response.status == 200:
|
|
226
|
+
raw_data = response.read().decode("utf-8")
|
|
227
|
+
parsed = json.loads(raw_data)
|
|
228
|
+
# If returned from GitHub API base64
|
|
229
|
+
if isinstance(parsed, dict) and "content" in parsed and "encoding" in parsed:
|
|
230
|
+
import base64
|
|
231
|
+
decoded = base64.b64decode(parsed["content"]).decode("utf-8")
|
|
232
|
+
parsed = json.loads(decoded)
|
|
233
|
+
if isinstance(parsed, dict) and "secret_patterns" in parsed:
|
|
234
|
+
return True, parsed
|
|
235
|
+
except Exception:
|
|
236
|
+
continue
|
|
237
|
+
return False, {}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def sync_security_rules_foreground(force: bool = False) -> Tuple[bool, str]:
|
|
241
|
+
"""
|
|
242
|
+
Synchronous rule update function.
|
|
243
|
+
Downloads, validates, merges and saves updated security definitions.
|
|
244
|
+
"""
|
|
245
|
+
if not force and not is_sync_due():
|
|
246
|
+
return True, "Dynamic security rules are up to date (cached within 24h)."
|
|
247
|
+
|
|
248
|
+
ok, remote_rules = fetch_remote_rules(timeout=2.5)
|
|
249
|
+
record_sync_timestamp(status="success" if ok else "offline")
|
|
250
|
+
|
|
251
|
+
if not ok:
|
|
252
|
+
return False, "Offline or remote rules feed unreachable. Using verified local baseline."
|
|
253
|
+
|
|
254
|
+
# Merge remote rules with local rules safely
|
|
255
|
+
current_rules = load_security_rules()
|
|
256
|
+
|
|
257
|
+
# Merge malicious packages (deduplicated set)
|
|
258
|
+
current_pkgs = set(current_rules.get("malicious_packages", []))
|
|
259
|
+
remote_pkgs = set(remote_rules.get("malicious_packages", []))
|
|
260
|
+
merged_pkgs = sorted(list(current_pkgs | remote_pkgs))
|
|
261
|
+
|
|
262
|
+
# Merge secret patterns
|
|
263
|
+
curr_patterns = {p["pattern"]: p for p in current_rules.get("secret_patterns", [])}
|
|
264
|
+
for p in remote_rules.get("secret_patterns", []):
|
|
265
|
+
curr_patterns[p["pattern"]] = p
|
|
266
|
+
|
|
267
|
+
current_rules["malicious_packages"] = merged_pkgs
|
|
268
|
+
current_rules["secret_patterns"] = list(curr_patterns.values())
|
|
269
|
+
current_rules["version"] = remote_rules.get("version", "1.0.0-ota")
|
|
270
|
+
current_rules["last_updated"] = int(time.time())
|
|
271
|
+
|
|
272
|
+
save_security_rules(current_rules)
|
|
273
|
+
return True, f"Successfully synced security rules (v{current_rules['version']}): {len(merged_pkgs)} malicious packages, {len(current_rules['secret_patterns'])} secret patterns active."
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def trigger_ota_update_background():
|
|
277
|
+
"""
|
|
278
|
+
Fires a non-blocking background daemon thread to silently refresh security rules.
|
|
279
|
+
Takes 0ms on the calling thread.
|
|
280
|
+
"""
|
|
281
|
+
if not is_sync_due():
|
|
282
|
+
return
|
|
283
|
+
|
|
284
|
+
def _worker():
|
|
285
|
+
try:
|
|
286
|
+
sync_security_rules_foreground(force=False)
|
|
287
|
+
except Exception:
|
|
288
|
+
pass
|
|
289
|
+
|
|
290
|
+
t = threading.Thread(target=_worker, daemon=True)
|
|
291
|
+
t.start()
|