Initial SQL-only 1C adapter baseline
This commit is contained in:
@@ -0,0 +1,384 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DEFAULT_BASE_URL = "http://docker-gpu.cin.su:8011"
|
||||
DEFAULT_REPORT_ROOT = ROOT / "reports" / "1c-access"
|
||||
|
||||
|
||||
def slugify(value: str, *, max_length: int = 80) -> str:
|
||||
slug = re.sub(r"[^0-9A-Za-zА-Яа-яЁё._-]+", "-", value.strip())
|
||||
slug = re.sub(r"-+", "-", slug).strip("-._")
|
||||
return (slug or "role-audit")[:max_length]
|
||||
|
||||
|
||||
def rpc(base_url: str, method: str, payload: dict[str, Any], *, timeout: int) -> dict[str, Any]:
|
||||
body = json.dumps({"method": method, "payload": payload}, ensure_ascii=False).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
base_url.rstrip("/") + "/rpc",
|
||||
data=body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=timeout) as response:
|
||||
data = json.loads(response.read().decode("utf-8"))
|
||||
if not isinstance(data, dict):
|
||||
return {"status": "error", "error": "response_not_object", "response": data}
|
||||
return data
|
||||
|
||||
|
||||
def write_json(path: Path, data: dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def build_artifact_paths(report_root: Path, base_id: str, role: str, timestamp: str) -> dict[str, Path]:
|
||||
digest = hashlib.sha1(role.encode("utf-8")).hexdigest()[:10]
|
||||
stem = f"{timestamp}-{slugify(role)}-{digest}"
|
||||
folder = report_root / slugify(base_id, max_length=60)
|
||||
return {
|
||||
"csv": folder / f"{stem}.csv",
|
||||
"json": folder / f"{stem}.json",
|
||||
"analysis": folder / f"{stem}.analysis.json",
|
||||
"summary": folder / f"{stem}.summary.json",
|
||||
"html": folder / f"{stem}.html",
|
||||
"index": folder / "index.html",
|
||||
}
|
||||
|
||||
|
||||
def esc(value: Any) -> str:
|
||||
return html.escape("" if value is None else str(value), quote=True)
|
||||
|
||||
|
||||
def render_html_report(summary: dict[str, Any], export: dict[str, Any], analysis: dict[str, Any] | None) -> str:
|
||||
rows = export.get("rows") if isinstance(export.get("rows"), list) else []
|
||||
findings = summary.get("findings") if isinstance(summary.get("findings"), list) else []
|
||||
counts = summary.get("counts") if isinstance(summary.get("counts"), dict) else {}
|
||||
artifacts = summary.get("artifacts") if isinstance(summary.get("artifacts"), dict) else {}
|
||||
risk = str(summary.get("risk_level") or "unknown")
|
||||
role = ((summary.get("query") if isinstance(summary.get("query"), dict) else {}) or {}).get("role")
|
||||
finding_items = "\n".join(
|
||||
f"<li><strong>{esc(item.get('severity'))}: {esc(item.get('code'))}</strong><br>{esc(item.get('message'))}</li>"
|
||||
for item in findings
|
||||
if isinstance(item, dict)
|
||||
) or "<li>Findings absent.</li>"
|
||||
table_rows = "\n".join(
|
||||
"<tr>"
|
||||
f"<td>{esc(row.get('matched_role_name'))}</td>"
|
||||
f"<td>{esc(row.get('profile_name'))}</td>"
|
||||
f"<td>{esc(row.get('group_name'))}</td>"
|
||||
f"<td>{esc(row.get('user_name'))}</td>"
|
||||
f"<td>{esc(row.get('user_type'))}</td>"
|
||||
f"<td>{esc(row.get('user_active'))}</td>"
|
||||
f"<td>{esc(row.get('access_path'))}</td>"
|
||||
"</tr>"
|
||||
for row in rows
|
||||
if isinstance(row, dict)
|
||||
)
|
||||
artifact_links = "\n".join(
|
||||
f"<a href=\"{esc(Path(path).name)}\">{esc(name)}</a>"
|
||||
for name, path in artifacts.items()
|
||||
)
|
||||
return f"""<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>1C Access Role Audit</title>
|
||||
<style>
|
||||
body {{ font-family: Segoe UI, Arial, sans-serif; margin: 24px; color: #1f2933; }}
|
||||
h1 {{ margin-bottom: 4px; }}
|
||||
.meta, .artifacts {{ color: #52616b; margin: 8px 0 18px; }}
|
||||
.badge {{ display: inline-block; padding: 4px 10px; border-radius: 999px; font-weight: 700; }}
|
||||
.low {{ background: #e3f8e5; color: #176b32; }}
|
||||
.medium {{ background: #fff2cc; color: #875a00; }}
|
||||
.high {{ background: #ffe0e0; color: #9f1d1d; }}
|
||||
.cards {{ display: flex; flex-wrap: wrap; gap: 12px; margin: 18px 0; }}
|
||||
.card {{ border: 1px solid #d9e2ec; border-radius: 8px; padding: 12px 14px; min-width: 120px; }}
|
||||
.card b {{ display: block; font-size: 20px; }}
|
||||
input {{ width: 100%; max-width: 520px; padding: 9px 10px; margin: 12px 0; border: 1px solid #bcccdc; border-radius: 6px; }}
|
||||
table {{ border-collapse: collapse; width: 100%; margin-top: 12px; }}
|
||||
th, td {{ border: 1px solid #d9e2ec; padding: 8px; vertical-align: top; }}
|
||||
th {{ background: #f0f4f8; text-align: left; position: sticky; top: 0; }}
|
||||
tr:nth-child(even) {{ background: #fbfcfd; }}
|
||||
.artifacts a {{ margin-right: 12px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>1C Access Role Audit</h1>
|
||||
<div class="meta">Base: <b>{esc(summary.get('base_id'))}</b> | Role query: <b>{esc(role)}</b> | Generated: {esc(summary.get('generated_at'))}</div>
|
||||
<div>Risk: <span class="badge {esc(risk)}">{esc(risk)}</span></div>
|
||||
<div class="cards">
|
||||
<div class="card"><b>{esc(counts.get('users'))}</b>users</div>
|
||||
<div class="card"><b>{esc(counts.get('groups'))}</b>groups</div>
|
||||
<div class="card"><b>{esc(counts.get('profiles'))}</b>profiles</div>
|
||||
<div class="card"><b>{esc(counts.get('rows'))}</b>rows</div>
|
||||
<div class="card"><b>{esc(len(findings))}</b>findings</div>
|
||||
</div>
|
||||
<h2>Findings</h2>
|
||||
<ul>{finding_items}</ul>
|
||||
<div class="artifacts">Artifacts: {artifact_links}</div>
|
||||
<h2>Rows</h2>
|
||||
<input id="filter" placeholder="Filter by user, group, profile, role..." oninput="filterRows()">
|
||||
<table id="rows">
|
||||
<thead><tr><th>Role</th><th>Profile</th><th>Group</th><th>User</th><th>Type</th><th>Active</th><th>Access path</th></tr></thead>
|
||||
<tbody>{table_rows}</tbody>
|
||||
</table>
|
||||
<script>
|
||||
function filterRows() {{
|
||||
const q = document.getElementById('filter').value.toLowerCase();
|
||||
document.querySelectorAll('#rows tbody tr').forEach(tr => {{
|
||||
tr.style.display = tr.innerText.toLowerCase().includes(q) ? '' : 'none';
|
||||
}});
|
||||
}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def load_audit_summaries(folder: Path, *, limit: int = 100) -> list[dict[str, Any]]:
|
||||
summaries: list[dict[str, Any]] = []
|
||||
for path in folder.glob("*.summary.json"):
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
if isinstance(data, dict):
|
||||
data.setdefault("summary_path", str(path))
|
||||
summaries.append(data)
|
||||
summaries.sort(key=lambda item: str(item.get("generated_at") or ""), reverse=True)
|
||||
return summaries[:limit]
|
||||
|
||||
|
||||
def summary_role(summary: dict[str, Any]) -> str | None:
|
||||
query = summary.get("query") if isinstance(summary.get("query"), dict) else {}
|
||||
role = query.get("role") if isinstance(query, dict) else None
|
||||
return str(role) if role is not None else None
|
||||
|
||||
|
||||
def compare_filename_for_pair(old_summary: dict[str, Any], new_summary: dict[str, Any], *, suffix: str) -> str | None:
|
||||
old_path = old_summary.get("summary_path")
|
||||
new_path = new_summary.get("summary_path")
|
||||
if not old_path or not new_path:
|
||||
return None
|
||||
old_stem = Path(str(old_path)).stem.replace(".summary", "")
|
||||
new_stem = Path(str(new_path)).stem.replace(".summary", "")
|
||||
return f"compare-{old_stem}-{new_stem}.{suffix}"
|
||||
|
||||
|
||||
def render_index_html(base_id: str, summaries: list[dict[str, Any]]) -> str:
|
||||
rows: list[str] = []
|
||||
previous_by_role: dict[str, dict[str, Any]] = {}
|
||||
for item in reversed(summaries):
|
||||
role_key = (summary_role(item) or "").casefold()
|
||||
item["previous_summary_path"] = previous_by_role.get(role_key, {}).get("summary_path")
|
||||
previous_by_role[role_key] = item
|
||||
for item in summaries:
|
||||
query = item.get("query") if isinstance(item.get("query"), dict) else {}
|
||||
counts = item.get("counts") if isinstance(item.get("counts"), dict) else {}
|
||||
findings = item.get("findings") if isinstance(item.get("findings"), list) else []
|
||||
artifacts = item.get("artifacts") if isinstance(item.get("artifacts"), dict) else {}
|
||||
previous = next((candidate for candidate in summaries if candidate.get("summary_path") == item.get("previous_summary_path")), None)
|
||||
compare_name = compare_filename_for_pair(previous, item, suffix="html") if isinstance(previous, dict) else None
|
||||
compare_link = f"<a href=\"{esc(compare_name)}\">compare_previous</a>" if compare_name and (Path(str(item.get("summary_path"))).parent / compare_name).exists() else ""
|
||||
links = " ".join(
|
||||
f"<a href=\"{esc(Path(path).name)}\">{esc(name)}</a>"
|
||||
for name, path in artifacts.items()
|
||||
if name != "index"
|
||||
) + (f" {compare_link}" if compare_link else "")
|
||||
rows.append(
|
||||
"<tr>"
|
||||
f"<td>{esc(item.get('generated_at'))}</td>"
|
||||
f"<td>{esc(query.get('role') if isinstance(query, dict) else None)}</td>"
|
||||
f"<td><span class=\"badge {esc(item.get('risk_level') or 'unknown')}\">{esc(item.get('risk_level') or 'unknown')}</span></td>"
|
||||
f"<td>{esc(counts.get('users'))}</td>"
|
||||
f"<td>{esc(counts.get('rows'))}</td>"
|
||||
f"<td>{esc(len(findings))}</td>"
|
||||
f"<td>{links}</td>"
|
||||
"</tr>"
|
||||
)
|
||||
table_rows = "\n".join(rows) or "<tr><td colspan=\"7\">Reports absent.</td></tr>"
|
||||
generated_at = datetime.now(timezone.utc).isoformat()
|
||||
return f"""<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>1C Access Audit Index</title>
|
||||
<style>
|
||||
body {{ font-family: Segoe UI, Arial, sans-serif; margin: 24px; color: #1f2933; }}
|
||||
h1 {{ margin-bottom: 4px; }}
|
||||
.meta {{ color: #52616b; margin: 8px 0 18px; }}
|
||||
input {{ width: 100%; max-width: 560px; padding: 9px 10px; margin: 12px 0; border: 1px solid #bcccdc; border-radius: 6px; }}
|
||||
table {{ border-collapse: collapse; width: 100%; margin-top: 12px; }}
|
||||
th, td {{ border: 1px solid #d9e2ec; padding: 8px; vertical-align: top; }}
|
||||
th {{ background: #f0f4f8; text-align: left; position: sticky; top: 0; }}
|
||||
tr:nth-child(even) {{ background: #fbfcfd; }}
|
||||
a {{ margin-right: 10px; }}
|
||||
.badge {{ display: inline-block; padding: 3px 9px; border-radius: 999px; font-weight: 700; }}
|
||||
.low {{ background: #e3f8e5; color: #176b32; }}
|
||||
.medium {{ background: #fff2cc; color: #875a00; }}
|
||||
.high {{ background: #ffe0e0; color: #9f1d1d; }}
|
||||
.unknown {{ background: #edf2f7; color: #52616b; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>1C Access Audit Index</h1>
|
||||
<div class="meta">Base: <b>{esc(base_id)}</b> | Reports: <b>{esc(len(summaries))}</b> | Updated: {esc(generated_at)}</div>
|
||||
<input id="filter" placeholder="Filter by role, risk, artifact..." oninput="filterRows()">
|
||||
<table id="reports">
|
||||
<thead><tr><th>Generated</th><th>Role query</th><th>Risk</th><th>Users</th><th>Rows</th><th>Findings</th><th>Artifacts</th></tr></thead>
|
||||
<tbody>{table_rows}</tbody>
|
||||
</table>
|
||||
<script>
|
||||
function filterRows() {{
|
||||
const q = document.getElementById('filter').value.toLowerCase();
|
||||
document.querySelectorAll('#reports tbody tr').forEach(tr => {{
|
||||
tr.style.display = tr.innerText.toLowerCase().includes(q) ? '' : 'none';
|
||||
}});
|
||||
}}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
def update_index(report_root: Path, base_id: str, *, limit: int = 100) -> Path:
|
||||
folder = report_root / slugify(base_id, max_length=60)
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
index_path = folder / "index.html"
|
||||
index_path.write_text(render_index_html(base_id, load_audit_summaries(folder, limit=limit)), encoding="utf-8")
|
||||
return index_path
|
||||
|
||||
|
||||
def export_role_audit(
|
||||
*,
|
||||
adapter_url: str,
|
||||
base_id: str,
|
||||
role: str,
|
||||
report_root: Path,
|
||||
timeout: int,
|
||||
user_threshold: int,
|
||||
include_analysis: bool,
|
||||
limit: int,
|
||||
include_html: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
export = rpc(
|
||||
adapter_url,
|
||||
"access.role.audit_export",
|
||||
{
|
||||
"base_id": base_id,
|
||||
"role": role,
|
||||
"format": "csv",
|
||||
"limit": limit,
|
||||
"timeout_seconds": timeout,
|
||||
},
|
||||
timeout=timeout + 30,
|
||||
)
|
||||
analysis: dict[str, Any] | None = None
|
||||
if include_analysis:
|
||||
analysis = rpc(
|
||||
adapter_url,
|
||||
"access.role.audit_analyze",
|
||||
{
|
||||
"base_id": base_id,
|
||||
"role": role,
|
||||
"user_threshold": user_threshold,
|
||||
"timeout_seconds": timeout,
|
||||
},
|
||||
timeout=timeout + 30,
|
||||
)
|
||||
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
paths = build_artifact_paths(report_root, base_id, role, timestamp)
|
||||
paths["csv"].parent.mkdir(parents=True, exist_ok=True)
|
||||
paths["csv"].write_text(str(export.get("csv") or ""), encoding="utf-8-sig")
|
||||
write_json(paths["json"], export)
|
||||
if analysis is not None:
|
||||
write_json(paths["analysis"], analysis)
|
||||
|
||||
summary = {
|
||||
"schema": "onec_access_role_audit_artifact.v1",
|
||||
"status": "ok" if export.get("status") == "ok" and (analysis is None or analysis.get("status") == "ok") else "error",
|
||||
"base_id": base_id,
|
||||
"query": {"role": role},
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||
"adapter_url": adapter_url,
|
||||
"artifacts": {key: str(path) for key, path in paths.items() if key != "analysis" or analysis is not None},
|
||||
"counts": export.get("counts") if isinstance(export.get("counts"), dict) else {},
|
||||
"risk_level": analysis.get("risk_level") if isinstance(analysis, dict) else None,
|
||||
"findings": analysis.get("findings") if isinstance(analysis, dict) else [],
|
||||
"export_status": export.get("status"),
|
||||
"analysis_status": analysis.get("status") if isinstance(analysis, dict) else None,
|
||||
}
|
||||
if include_html:
|
||||
paths["html"].write_text(render_html_report(summary, export, analysis), encoding="utf-8")
|
||||
else:
|
||||
summary["artifacts"].pop("html", None)
|
||||
write_json(paths["summary"], summary)
|
||||
update_index(report_root, base_id)
|
||||
return summary
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Export 1C/BSP role access audit artifacts to the local workspace.")
|
||||
parser.add_argument("--adapter-url", default=DEFAULT_BASE_URL)
|
||||
parser.add_argument("--base-id", default="upo_test")
|
||||
parser.add_argument("--role", required=True)
|
||||
parser.add_argument("--report-root", type=Path, default=DEFAULT_REPORT_ROOT)
|
||||
parser.add_argument("--timeout", type=int, default=120)
|
||||
parser.add_argument("--limit", type=int, default=20000)
|
||||
parser.add_argument("--user-threshold", type=int, default=50)
|
||||
parser.add_argument("--no-analysis", action="store_true")
|
||||
parser.add_argument("--no-html", action="store_true")
|
||||
parser.add_argument("--json", action="store_true", help="Print full artifact summary JSON.")
|
||||
args = parser.parse_args()
|
||||
|
||||
summary = export_role_audit(
|
||||
adapter_url=args.adapter_url,
|
||||
base_id=args.base_id,
|
||||
role=args.role,
|
||||
report_root=args.report_root,
|
||||
timeout=args.timeout,
|
||||
user_threshold=args.user_threshold,
|
||||
include_analysis=not args.no_analysis,
|
||||
limit=args.limit,
|
||||
include_html=not args.no_html,
|
||||
)
|
||||
if args.json:
|
||||
print(json.dumps(summary, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"status": summary.get("status"),
|
||||
"base_id": summary.get("base_id"),
|
||||
"role": role_summary(summary),
|
||||
"risk_level": summary.get("risk_level"),
|
||||
"counts": summary.get("counts"),
|
||||
"artifacts": summary.get("artifacts"),
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return 0 if summary.get("status") == "ok" else 1
|
||||
|
||||
|
||||
def role_summary(summary: dict[str, Any]) -> str | None:
|
||||
query = summary.get("query") if isinstance(summary.get("query"), dict) else {}
|
||||
return query.get("role") if isinstance(query, dict) else None
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user