Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fed7cc2eaa | ||
|
|
cd61a530d6 | ||
|
|
58535fa5c6 | ||
|
|
7f1abdb9a7 | ||
|
|
5ca325034f | ||
|
|
5312da175d | ||
|
|
fa1ddf64b4 | ||
|
|
643d5d2d69 | ||
|
|
b629879958 | ||
|
|
78a560dc3e | ||
|
|
803c3169d7 | ||
|
|
03f10435ce | ||
|
|
2f8193e7b3 | ||
|
|
e76fa5a5c2 | ||
|
|
68c0fd1a37 | ||
|
|
d19b3d478f | ||
|
|
0cd6e4f856 | ||
|
|
acf767360f | ||
|
|
9ad5b2fc34 | ||
|
|
8596cd057b | ||
|
|
42b0fdb718 |
@@ -13,6 +13,9 @@ on:
|
|||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-linux-x86_64:
|
build-linux-x86_64:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -39,7 +42,10 @@ jobs:
|
|||||||
--release-dir adk-rust/target/release \
|
--release-dir adk-rust/target/release \
|
||||||
--out-dir dist/awatch-rus-linux-x86_64 \
|
--out-dir dist/awatch-rus-linux-x86_64 \
|
||||||
--archive dist/awatch-rus-linux-x86_64-release-binaries.tar.gz \
|
--archive dist/awatch-rus-linux-x86_64-release-binaries.tar.gz \
|
||||||
--target linux-x86_64
|
--target linux-x86_64 \
|
||||||
|
--commit "${GITHUB_SHA}" \
|
||||||
|
--ref "${GITHUB_REF}" \
|
||||||
|
--run-id "${GITHUB_RUN_ID}"
|
||||||
|
|
||||||
- name: Upload release binaries artifact
|
- name: Upload release binaries artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
@@ -53,3 +59,16 @@ jobs:
|
|||||||
dist/awatch-rus-linux_x86_64/BUILD_MANIFEST.json
|
dist/awatch-rus-linux_x86_64/BUILD_MANIFEST.json
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
retention-days: 30
|
retention-days: 30
|
||||||
|
|
||||||
|
- name: Publish GitHub Release assets
|
||||||
|
if: startsWith(github.ref, 'refs/tags/v')
|
||||||
|
uses: softprops/action-gh-release@v2
|
||||||
|
with:
|
||||||
|
generate_release_notes: true
|
||||||
|
fail_on_unmatched_files: true
|
||||||
|
files: |
|
||||||
|
dist/awatch-rus-linux_x86_64-release-binaries.tar.gz
|
||||||
|
dist/awatch-rus-linux_x86_64-release-binaries.tar.gz.sha256
|
||||||
|
dist/awatch-rus-linux_x86_64/BINARIES.txt
|
||||||
|
dist/awatch-rus-linux_x86_64/SHA256SUMS.txt
|
||||||
|
dist/awatch-rus-linux_x86_64/BUILD_MANIFEST.json
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
//! Portal API contract summary payload.
|
||||||
|
//!
|
||||||
|
//! CONTRACT: this module describes stable public API routes exposed by the
|
||||||
|
//! current Rust HTML/HTMX portal and future clients. Keep changes additive
|
||||||
|
//! unless the OpenAPI/TypeScript contracts are updated in the same PR.
|
||||||
|
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
pub(crate) fn api_contract_summary() -> Value {
|
||||||
|
json!({
|
||||||
|
"ok": true,
|
||||||
|
"contract_version": "2026-06-06.pilot-v1",
|
||||||
|
"generated_by": "detmir-portal",
|
||||||
|
"api_base": "/api",
|
||||||
|
"compatibility": {
|
||||||
|
"policy": "additive",
|
||||||
|
"main_ui": "rust-server-rendered-html-htmx-compatible",
|
||||||
|
"unknown_fields": "clients must ignore unknown fields",
|
||||||
|
"nullable_fields": "clients must tolerate null and missing optional fields",
|
||||||
|
"forbidden_ui_stacks": ["dioxus", "react", "tauri", "electron"]
|
||||||
|
},
|
||||||
|
"targets": ["rust-html", "htmx-compatible"],
|
||||||
|
"artifacts": {
|
||||||
|
"openapi": "/api/contracts/openapi.json",
|
||||||
|
"typescript": "/api/contracts/typescript.d.ts"
|
||||||
|
},
|
||||||
|
"stable_endpoints": [
|
||||||
|
{"method": "GET", "path": "/healthz", "purpose": "process liveness without external dependency checks"},
|
||||||
|
{"method": "GET", "path": "/readyz", "purpose": "local readiness and contract-only dependency status"},
|
||||||
|
{"method": "GET", "path": "/version", "purpose": "safe build and schema version metadata"},
|
||||||
|
{"method": "GET", "path": "/metrics", "purpose": "Prometheus metrics without high-cardinality labels"},
|
||||||
|
{"method": "GET", "path": "/api/health", "purpose": "light service health"},
|
||||||
|
{"method": "GET", "path": "/api/contracts", "purpose": "contract index"},
|
||||||
|
{"method": "GET", "path": "/api/contracts/openapi.json", "purpose": "OpenAPI contract"},
|
||||||
|
{"method": "GET", "path": "/api/contracts/typescript.d.ts", "purpose": "TypeScript declarations"},
|
||||||
|
{"method": "GET", "path": "/api/operator", "purpose": "portal overview data"},
|
||||||
|
{"method": "GET", "path": "/api/reports", "purpose": "management report payload"},
|
||||||
|
{"method": "GET", "path": "/api/executive", "purpose": "executive role payload"},
|
||||||
|
{"method": "GET", "path": "/api/workforce", "purpose": "workforce role payload"},
|
||||||
|
{"method": "GET", "path": "/api/security", "purpose": "security role payload"},
|
||||||
|
{"method": "GET", "path": "/api/forensics", "purpose": "forensics role payload"},
|
||||||
|
{"method": "GET", "path": "/api/ueba", "purpose": "rule-based UEBA score v1"},
|
||||||
|
{"method": "GET", "path": "/api/pfsense", "purpose": "pfSense readiness contracts and demo fixtures"},
|
||||||
|
{"method": "GET", "path": "/api/incidents", "purpose": "incident and DLP evidence summary"},
|
||||||
|
{"method": "GET", "path": "/api/cases", "purpose": "case list"},
|
||||||
|
{"method": "POST", "path": "/api/incident-review", "purpose": "manual candidate review status"},
|
||||||
|
{"method": "POST", "path": "/api/cases", "purpose": "manual case creation"},
|
||||||
|
{"method": "GET", "path": "/api/investigation-pack/{candidate_id}", "purpose": "candidate investigation pack"},
|
||||||
|
{"method": "GET", "path": "/api/dlp/evidence", "purpose": "DLP evidence list"},
|
||||||
|
{"method": "GET", "path": "/api/readiness/latest", "purpose": "latest readiness status"},
|
||||||
|
{"method": "GET", "path": "/api/workforce/policy/explain", "purpose": "workforce policy explanation"},
|
||||||
|
{"method": "GET", "path": "/api/workforce/kpi/explain", "purpose": "rule-based Workforce KPI explanation"},
|
||||||
|
{"method": "GET", "path": "/api/risk/narrative", "purpose": "rule-based executive risk narrative"},
|
||||||
|
{"method": "GET", "path": "/api/actions", "purpose": "rule-based executive action center"}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
//! External command execution helpers for the portal.
|
||||||
|
//!
|
||||||
|
//! CONTRACT: these helpers are intentionally small and side-effect explicit.
|
||||||
|
//! They preserve stdout/stderr error text because readiness verification APIs
|
||||||
|
//! expose command failure diagnostics to operators.
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
pub(crate) fn run_in_dir(dir: &Path, command: &mut Command) -> std::result::Result<(), String> {
|
||||||
|
let output = command
|
||||||
|
.current_dir(dir)
|
||||||
|
.output()
|
||||||
|
.map_err(|err| format!("run command in {}: {err}", dir.display()))?;
|
||||||
|
if output.status.success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!(
|
||||||
|
"{}{}",
|
||||||
|
String::from_utf8_lossy(&output.stdout),
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
)
|
||||||
|
.trim()
|
||||||
|
.to_string())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
//! HTTP response helpers for the portal.
|
||||||
|
//!
|
||||||
|
//! CONTRACT: this module owns response serialization, headers, request-id /
|
||||||
|
//! correlation-id propagation and response metrics logging. It must not change
|
||||||
|
//! routes, payload schemas, MIME types or UI contents.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result, anyhow};
|
||||||
|
use serde::Serialize;
|
||||||
|
use tiny_http::{Header, Request, Response, StatusCode};
|
||||||
|
|
||||||
|
use crate::production::{http_request_metadata, log_http_request, record_http_metric};
|
||||||
|
use crate::screenshot_basename;
|
||||||
|
|
||||||
|
pub(crate) fn respond_json<T: Serialize>(request: Request, value: &T) -> Result<()> {
|
||||||
|
let body = serde_json::to_string_pretty(value)?;
|
||||||
|
respond_text(
|
||||||
|
request,
|
||||||
|
StatusCode(200),
|
||||||
|
&body,
|
||||||
|
"application/json; charset=utf-8",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn respond_json_status<T: Serialize>(
|
||||||
|
request: Request,
|
||||||
|
status: StatusCode,
|
||||||
|
value: &T,
|
||||||
|
) -> Result<()> {
|
||||||
|
let body = serde_json::to_string_pretty(value)?;
|
||||||
|
respond_text(request, status, &body, "application/json; charset=utf-8")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn respond_text(
|
||||||
|
request: Request,
|
||||||
|
status: StatusCode,
|
||||||
|
body: &str,
|
||||||
|
content_type: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
let metadata = http_request_metadata(&request);
|
||||||
|
record_http_metric(&metadata, status);
|
||||||
|
log_http_request(&metadata, status, body.len());
|
||||||
|
let response = Response::from_string(body.to_string())
|
||||||
|
.with_status_code(status)
|
||||||
|
.with_header(header("Content-Type", content_type)?)
|
||||||
|
.with_header(header("Cache-Control", "no-store")?)
|
||||||
|
.with_header(header("X-Request-Id", &metadata.request_id)?)
|
||||||
|
.with_header(header("X-Correlation-Id", &metadata.correlation_id)?);
|
||||||
|
request.respond(response).map_err(|err| anyhow!("{err}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn respond_text_download(
|
||||||
|
request: Request,
|
||||||
|
status: StatusCode,
|
||||||
|
body: &str,
|
||||||
|
content_type: &str,
|
||||||
|
download_name: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
let metadata = http_request_metadata(&request);
|
||||||
|
record_http_metric(&metadata, status);
|
||||||
|
log_http_request(&metadata, status, body.len());
|
||||||
|
let response = Response::from_string(body.to_string())
|
||||||
|
.with_status_code(status)
|
||||||
|
.with_header(header("Content-Type", content_type)?)
|
||||||
|
.with_header(header("Cache-Control", "no-store")?)
|
||||||
|
.with_header(header("X-Request-Id", &metadata.request_id)?)
|
||||||
|
.with_header(header("X-Correlation-Id", &metadata.correlation_id)?)
|
||||||
|
.with_header(header(
|
||||||
|
"Content-Disposition",
|
||||||
|
&format!(
|
||||||
|
"attachment; filename=\"{}\"",
|
||||||
|
download_name.replace('"', "")
|
||||||
|
),
|
||||||
|
)?);
|
||||||
|
request.respond(response).map_err(|err| anyhow!("{err}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn respond_file(
|
||||||
|
request: Request,
|
||||||
|
path: &Path,
|
||||||
|
content_type: &str,
|
||||||
|
download_name: Option<&str>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let data = fs::read(path).with_context(|| format!("read {}", path.display()))?;
|
||||||
|
let metadata = http_request_metadata(&request);
|
||||||
|
record_http_metric(&metadata, StatusCode(200));
|
||||||
|
log_http_request(&metadata, StatusCode(200), data.len());
|
||||||
|
let mut response = Response::from_data(data)
|
||||||
|
.with_status_code(StatusCode(200))
|
||||||
|
.with_header(header("Content-Type", content_type)?)
|
||||||
|
.with_header(header("Cache-Control", "no-store")?)
|
||||||
|
.with_header(header("X-Request-Id", &metadata.request_id)?)
|
||||||
|
.with_header(header("X-Correlation-Id", &metadata.correlation_id)?);
|
||||||
|
if let Some(name) = download_name.and_then(screenshot_basename) {
|
||||||
|
response = response.with_header(header(
|
||||||
|
"Content-Disposition",
|
||||||
|
&format!("attachment; filename=\"{}\"", name.replace('"', "")),
|
||||||
|
)?);
|
||||||
|
}
|
||||||
|
request.respond(response).map_err(|err| anyhow!("{err}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn safe_download_stem(value: &str) -> String {
|
||||||
|
let stem = value
|
||||||
|
.chars()
|
||||||
|
.map(|ch| {
|
||||||
|
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
|
||||||
|
ch
|
||||||
|
} else {
|
||||||
|
'_'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.take(96)
|
||||||
|
.collect::<String>();
|
||||||
|
if stem.is_empty() {
|
||||||
|
"candidate".to_string()
|
||||||
|
} else {
|
||||||
|
stem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn header(name: &str, value: &str) -> Result<Header> {
|
||||||
|
Header::from_bytes(name.as_bytes(), value.as_bytes())
|
||||||
|
.map_err(|_| anyhow!("invalid header {name}: {value}"))
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ use std::fs::{self, File, OpenOptions};
|
|||||||
use std::io::{Read, Write};
|
use std::io::{Read, Write};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::{Command, Stdio};
|
use std::process::{Command, Stdio};
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::thread;
|
use std::thread;
|
||||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
@@ -21,35 +20,54 @@ use rusqlite::{Connection, OptionalExtension, params};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
|
use tiny_http::{Method, Request, Server, StatusCode};
|
||||||
|
|
||||||
|
mod api_contracts;
|
||||||
|
mod command_runner;
|
||||||
mod executive_actions;
|
mod executive_actions;
|
||||||
|
mod http_response;
|
||||||
|
mod path_query;
|
||||||
|
mod portal_roles;
|
||||||
mod production;
|
mod production;
|
||||||
|
mod readiness_api;
|
||||||
mod risk_narrative;
|
mod risk_narrative;
|
||||||
|
mod role_access;
|
||||||
|
mod snapshot_cache;
|
||||||
|
mod static_assets;
|
||||||
mod workforce_kpi_explain;
|
mod workforce_kpi_explain;
|
||||||
|
|
||||||
|
use api_contracts::api_contract_summary;
|
||||||
use executive_actions::{
|
use executive_actions::{
|
||||||
actions_from_center, build_action_center_from_report, filter_actions_for_role,
|
actions_from_center, build_action_center_from_report, filter_actions_for_role,
|
||||||
};
|
};
|
||||||
use production::{
|
pub(crate) use http_response::{
|
||||||
build_healthz, build_readyz, build_version, http_request_metadata, is_limited_api_route,
|
respond_file, respond_json, respond_json_status, respond_text, respond_text_download,
|
||||||
log_http_request, mark_request_started, record_http_metric, record_ingestion_accepted,
|
safe_download_stem,
|
||||||
record_ingestion_rejected, record_report_generated, render_prometheus_metrics,
|
|
||||||
validate_api_query_limits, validate_portal_config,
|
|
||||||
};
|
};
|
||||||
|
use path_query::{
|
||||||
|
normalize_path, parse_case_path, parse_case_status_path, parse_investigation_pack_path,
|
||||||
|
query_flag, query_param,
|
||||||
|
};
|
||||||
|
use portal_roles::PortalRole;
|
||||||
|
use production::{
|
||||||
|
build_healthz, build_readyz, build_version, is_limited_api_route, mark_request_started,
|
||||||
|
record_ingestion_accepted, record_ingestion_rejected, record_report_generated,
|
||||||
|
render_prometheus_metrics, validate_api_query_limits, validate_portal_config,
|
||||||
|
};
|
||||||
|
use readiness_api::{readiness_bundle, readiness_latest, readiness_verify};
|
||||||
use risk_narrative::{
|
use risk_narrative::{
|
||||||
RiskNarrativeInputs, RiskNarrativeQuery, build_risk_narrative, build_risk_narrative_from_report,
|
RiskNarrativeInputs, RiskNarrativeQuery, build_risk_narrative, build_risk_narrative_from_report,
|
||||||
};
|
};
|
||||||
|
use role_access::{portal_role_from_request, respond_forbidden, role_envelope};
|
||||||
|
use snapshot_cache::{
|
||||||
|
SnapshotCache, build_fast_health, cached_snapshot, clone_snapshot_cache, new_snapshot_cache,
|
||||||
|
};
|
||||||
|
use static_assets::{
|
||||||
|
API_CONTRACT_OPENAPI, API_CONTRACT_TYPESCRIPT, APP_CSS, APP_JS, ARCHITECTURE_HTML, INDEX_HTML,
|
||||||
|
};
|
||||||
use workforce_kpi_explain::{KpiExplainQuery, build_workforce_kpi_explain};
|
use workforce_kpi_explain::{KpiExplainQuery, build_workforce_kpi_explain};
|
||||||
|
|
||||||
const INDEX_HTML: &str = include_str!("static/index.html");
|
|
||||||
const ARCHITECTURE_HTML: &str = include_str!("static/architecture.html");
|
|
||||||
const APP_CSS: &str = include_str!("static/app.css");
|
|
||||||
const APP_JS: &str = include_str!("static/app.js");
|
|
||||||
const API_CONTRACT_OPENAPI: &str = include_str!("contracts/openapi.json");
|
|
||||||
const API_CONTRACT_TYPESCRIPT: &str = include_str!("contracts/typescript.d.ts");
|
|
||||||
const UEBA_BASELINE_MIN_SAMPLES: usize = 3;
|
const UEBA_BASELINE_MIN_SAMPLES: usize = 3;
|
||||||
const SNAPSHOT_CACHE_TTL: Duration = Duration::from_secs(120);
|
|
||||||
const DEFAULT_DEPARTMENT_LABEL: &str = "Не привязано к подразделению";
|
const DEFAULT_DEPARTMENT_LABEL: &str = "Не привязано к подразделению";
|
||||||
const LEGACY_UNASSIGNED_DEPARTMENT_LABEL: &str = "Без подразделения";
|
const LEGACY_UNASSIGNED_DEPARTMENT_LABEL: &str = "Без подразделения";
|
||||||
const PORTAL_SCHEMA_VERSION: &str = "pilot-v1";
|
const PORTAL_SCHEMA_VERSION: &str = "pilot-v1";
|
||||||
@@ -73,84 +91,6 @@ unsafe extern "C" {
|
|||||||
fn kill(pid: i32, sig: i32) -> i32;
|
fn kill(pid: i32, sig: i32) -> i32;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SnapshotCache = Arc<Mutex<Option<CachedSnapshot>>>;
|
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
|
||||||
#[serde(rename_all = "snake_case")]
|
|
||||||
enum PortalRole {
|
|
||||||
Executive,
|
|
||||||
Manager,
|
|
||||||
Security,
|
|
||||||
Forensics,
|
|
||||||
Admin,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl PortalRole {
|
|
||||||
fn parse(value: &str) -> Option<Self> {
|
|
||||||
match value.trim().to_ascii_lowercase().as_str() {
|
|
||||||
"executive" | "owner" | "rukovoditel" | "руководитель" => {
|
|
||||||
Some(Self::Executive)
|
|
||||||
}
|
|
||||||
"manager" | "workforce" | "руководитель_подразделения" => {
|
|
||||||
Some(Self::Manager)
|
|
||||||
}
|
|
||||||
"security" | "ib" | "soc" | "безопасность" => Some(Self::Security),
|
|
||||||
"forensics" | "investigation" | "расследования" => Some(Self::Forensics),
|
|
||||||
"admin" | "operations" | "operator" | "эксплуатация" => Some(Self::Admin),
|
|
||||||
_ => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn as_str(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Executive => "executive",
|
|
||||||
Self::Manager => "manager",
|
|
||||||
Self::Security => "security",
|
|
||||||
Self::Forensics => "forensics",
|
|
||||||
Self::Admin => "admin",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn label_ru(self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::Executive => "Руководитель",
|
|
||||||
Self::Manager => "Руководитель подразделения",
|
|
||||||
Self::Security => "Безопасность",
|
|
||||||
Self::Forensics => "Расследования",
|
|
||||||
Self::Admin => "Администратор",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn allowed_scopes(self) -> &'static [&'static str] {
|
|
||||||
match self {
|
|
||||||
Self::Executive => &["executive", "workforce"],
|
|
||||||
Self::Manager => &["executive", "workforce"],
|
|
||||||
Self::Security => &["security", "incidents", "ueba", "pfsense"],
|
|
||||||
Self::Forensics => &["forensics", "incidents", "ueba"],
|
|
||||||
Self::Admin => &[
|
|
||||||
"executive",
|
|
||||||
"workforce",
|
|
||||||
"security",
|
|
||||||
"forensics",
|
|
||||||
"incidents",
|
|
||||||
"ueba",
|
|
||||||
"pfsense",
|
|
||||||
"admin",
|
|
||||||
],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn can_access(self, scope: &str) -> bool {
|
|
||||||
self.allowed_scopes().contains(&scope)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
|
||||||
struct CachedSnapshot {
|
|
||||||
created: Instant,
|
|
||||||
snapshot: Snapshot,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, Debug, Parser)]
|
#[derive(Clone, Debug, Parser)]
|
||||||
#[command(about = "Read-only AWatch-rus operator/manager/owner web portal")]
|
#[command(about = "Read-only AWatch-rus operator/manager/owner web portal")]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
@@ -1383,11 +1323,11 @@ fn run() -> Result<i32> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let server = Server::http(&args.bind).map_err(|err| anyhow!("bind {}: {err}", args.bind))?;
|
let server = Server::http(&args.bind).map_err(|err| anyhow!("bind {}: {err}", args.bind))?;
|
||||||
let snapshot_cache: SnapshotCache = Arc::new(Mutex::new(None));
|
let snapshot_cache: SnapshotCache = new_snapshot_cache();
|
||||||
eprintln!("detmir-portal listening on http://{}", args.bind);
|
eprintln!("detmir-portal listening on http://{}", args.bind);
|
||||||
for request in server.incoming_requests() {
|
for request in server.incoming_requests() {
|
||||||
let args = args.clone();
|
let args = args.clone();
|
||||||
let snapshot_cache = Arc::clone(&snapshot_cache);
|
let snapshot_cache = clone_snapshot_cache(&snapshot_cache);
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
let result = if args.evidence_only {
|
let result = if args.evidence_only {
|
||||||
handle_evidence_only_request(request, &args)
|
handle_evidence_only_request(request, &args)
|
||||||
@@ -1721,296 +1661,6 @@ fn handle_evidence_only_request(request: Request, args: &Cli) -> Result<()> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn normalize_path(url: &str) -> String {
|
|
||||||
let path = url.split('?').next().unwrap_or("/");
|
|
||||||
let path = path.strip_prefix("/portal").unwrap_or(path);
|
|
||||||
if path.is_empty() {
|
|
||||||
"/".to_string()
|
|
||||||
} else {
|
|
||||||
path.to_string()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn api_contract_summary() -> Value {
|
|
||||||
json!({
|
|
||||||
"ok": true,
|
|
||||||
"contract_version": "2026-06-06.pilot-v1",
|
|
||||||
"generated_by": "detmir-portal",
|
|
||||||
"api_base": "/api",
|
|
||||||
"compatibility": {
|
|
||||||
"policy": "additive",
|
|
||||||
"main_ui": "rust-server-rendered-html-htmx-compatible",
|
|
||||||
"unknown_fields": "clients must ignore unknown fields",
|
|
||||||
"nullable_fields": "clients must tolerate null and missing optional fields",
|
|
||||||
"forbidden_ui_stacks": ["dioxus", "react", "tauri", "electron"]
|
|
||||||
},
|
|
||||||
"targets": ["rust-html", "htmx-compatible"],
|
|
||||||
"artifacts": {
|
|
||||||
"openapi": "/api/contracts/openapi.json",
|
|
||||||
"typescript": "/api/contracts/typescript.d.ts"
|
|
||||||
},
|
|
||||||
"stable_endpoints": [
|
|
||||||
{"method": "GET", "path": "/healthz", "purpose": "process liveness without external dependency checks"},
|
|
||||||
{"method": "GET", "path": "/readyz", "purpose": "local readiness and contract-only dependency status"},
|
|
||||||
{"method": "GET", "path": "/version", "purpose": "safe build and schema version metadata"},
|
|
||||||
{"method": "GET", "path": "/metrics", "purpose": "Prometheus metrics without high-cardinality labels"},
|
|
||||||
{"method": "GET", "path": "/api/health", "purpose": "light service health"},
|
|
||||||
{"method": "GET", "path": "/api/contracts", "purpose": "contract index"},
|
|
||||||
{"method": "GET", "path": "/api/contracts/openapi.json", "purpose": "OpenAPI contract"},
|
|
||||||
{"method": "GET", "path": "/api/contracts/typescript.d.ts", "purpose": "TypeScript declarations"},
|
|
||||||
{"method": "GET", "path": "/api/operator", "purpose": "portal overview data"},
|
|
||||||
{"method": "GET", "path": "/api/reports", "purpose": "management report payload"},
|
|
||||||
{"method": "GET", "path": "/api/executive", "purpose": "executive role payload"},
|
|
||||||
{"method": "GET", "path": "/api/workforce", "purpose": "workforce role payload"},
|
|
||||||
{"method": "GET", "path": "/api/security", "purpose": "security role payload"},
|
|
||||||
{"method": "GET", "path": "/api/forensics", "purpose": "forensics role payload"},
|
|
||||||
{"method": "GET", "path": "/api/ueba", "purpose": "rule-based UEBA score v1"},
|
|
||||||
{"method": "GET", "path": "/api/pfsense", "purpose": "pfSense readiness contracts and demo fixtures"},
|
|
||||||
{"method": "GET", "path": "/api/incidents", "purpose": "incident and DLP evidence summary"},
|
|
||||||
{"method": "GET", "path": "/api/cases", "purpose": "case list"},
|
|
||||||
{"method": "POST", "path": "/api/incident-review", "purpose": "manual candidate review status"},
|
|
||||||
{"method": "POST", "path": "/api/cases", "purpose": "manual case creation"},
|
|
||||||
{"method": "GET", "path": "/api/investigation-pack/{candidate_id}", "purpose": "candidate investigation pack"},
|
|
||||||
{"method": "GET", "path": "/api/dlp/evidence", "purpose": "DLP evidence list"},
|
|
||||||
{"method": "GET", "path": "/api/readiness/latest", "purpose": "latest readiness status"},
|
|
||||||
{"method": "GET", "path": "/api/workforce/policy/explain", "purpose": "workforce policy explanation"},
|
|
||||||
{"method": "GET", "path": "/api/workforce/kpi/explain", "purpose": "rule-based Workforce KPI explanation"},
|
|
||||||
{"method": "GET", "path": "/api/risk/narrative", "purpose": "rule-based executive risk narrative"},
|
|
||||||
{"method": "GET", "path": "/api/actions", "purpose": "rule-based executive action center"}
|
|
||||||
]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn readiness_latest(args: &Cli) -> Value {
|
|
||||||
read_json_file(
|
|
||||||
&args
|
|
||||||
.readiness_bundle_dir
|
|
||||||
.join("detmir-readiness-latest.json"),
|
|
||||||
)
|
|
||||||
.unwrap_or_else(|err| {
|
|
||||||
json!({
|
|
||||||
"ok": false,
|
|
||||||
"generated_at_utc": now(),
|
|
||||||
"error": err.to_string(),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn readiness_bundle(args: &Cli) -> Value {
|
|
||||||
let dir = &args.readiness_bundle_dir;
|
|
||||||
let status = read_json_file(&dir.join("detmir-readiness-status.json")).unwrap_or_else(|err| {
|
|
||||||
json!({
|
|
||||||
"ok": false,
|
|
||||||
"error": err.to_string(),
|
|
||||||
})
|
|
||||||
});
|
|
||||||
let latest_dir = fs::read_to_string(dir.join("latest-dir.txt"))
|
|
||||||
.unwrap_or_default()
|
|
||||||
.trim()
|
|
||||||
.to_string();
|
|
||||||
let artifacts = [
|
|
||||||
"detmir-readiness-latest.json",
|
|
||||||
"detmir-readiness-act.md",
|
|
||||||
"detmir-readiness-act.html",
|
|
||||||
"sha256sums.txt",
|
|
||||||
"sha256sums.txt.sig",
|
|
||||||
"public-key.pem",
|
|
||||||
"detmir-readiness-status.json",
|
|
||||||
"detmir-readiness.prom",
|
|
||||||
]
|
|
||||||
.into_iter()
|
|
||||||
.filter_map(|name| {
|
|
||||||
let path = dir.join(name);
|
|
||||||
path.metadata().ok().map(|meta| {
|
|
||||||
json!({
|
|
||||||
"name": name,
|
|
||||||
"bytes": meta.len(),
|
|
||||||
"available": true,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect::<Vec<_>>();
|
|
||||||
json!({
|
|
||||||
"ok": status.get("ok").and_then(Value::as_bool).unwrap_or(false),
|
|
||||||
"generated_at_utc": now(),
|
|
||||||
"bundle_dir": dir.display().to_string(),
|
|
||||||
"latest_archive_dir": latest_dir,
|
|
||||||
"status": status,
|
|
||||||
"artifacts": artifacts,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn readiness_verify(args: &Cli) -> Value {
|
|
||||||
let dir = &args.readiness_bundle_dir;
|
|
||||||
let checksum = run_in_dir(
|
|
||||||
dir,
|
|
||||||
Command::new("sha256sum").arg("-c").arg("sha256sums.txt"),
|
|
||||||
);
|
|
||||||
let sig_path = dir.join("sha256sums.txt.sig");
|
|
||||||
let pub_path = dir.join("public-key.pem");
|
|
||||||
let signature = if sig_path.is_file() && pub_path.is_file() {
|
|
||||||
run_in_dir(
|
|
||||||
dir,
|
|
||||||
Command::new("openssl")
|
|
||||||
.arg("dgst")
|
|
||||||
.arg("-sha256")
|
|
||||||
.arg("-verify")
|
|
||||||
.arg("public-key.pem")
|
|
||||||
.arg("-signature")
|
|
||||||
.arg("sha256sums.txt.sig")
|
|
||||||
.arg("sha256sums.txt"),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Err("signature files are not available".to_string())
|
|
||||||
};
|
|
||||||
json!({
|
|
||||||
"ok": checksum.is_ok() && signature.is_ok(),
|
|
||||||
"generated_at_utc": now(),
|
|
||||||
"checksum_verified": checksum.is_ok(),
|
|
||||||
"signature_verified": signature.is_ok(),
|
|
||||||
"checksum_error": checksum.err(),
|
|
||||||
"signature_error": signature.err(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_json_file(path: &Path) -> Result<Value> {
|
|
||||||
let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
|
|
||||||
serde_json::from_str(&text).with_context(|| format!("parse {}", path.display()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_in_dir(dir: &Path, command: &mut Command) -> std::result::Result<(), String> {
|
|
||||||
let output = command
|
|
||||||
.current_dir(dir)
|
|
||||||
.output()
|
|
||||||
.map_err(|err| format!("run command in {}: {err}", dir.display()))?;
|
|
||||||
if output.status.success() {
|
|
||||||
Ok(())
|
|
||||||
} else {
|
|
||||||
Err(format!(
|
|
||||||
"{}{}",
|
|
||||||
String::from_utf8_lossy(&output.stdout),
|
|
||||||
String::from_utf8_lossy(&output.stderr)
|
|
||||||
)
|
|
||||||
.trim()
|
|
||||||
.to_string())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn query_flag(url: &str, key: &str) -> bool {
|
|
||||||
let Some(query) = url.split_once('?').map(|(_, query)| query) else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
query.split('&').any(|pair| {
|
|
||||||
let (name, value) = pair.split_once('=').unwrap_or((pair, "1"));
|
|
||||||
name == key && matches!(value, "1" | "true" | "yes" | "on")
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn query_param(url: &str, key: &str) -> Option<String> {
|
|
||||||
let query = url.split_once('?').map(|(_, query)| query)?;
|
|
||||||
query.split('&').find_map(|pair| {
|
|
||||||
let (name, value) = pair.split_once('=').unwrap_or((pair, ""));
|
|
||||||
(name == key && !value.is_empty()).then(|| value.to_string())
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn portal_role_from_request(request: &Request, url: &str) -> PortalRole {
|
|
||||||
query_param(url, "role")
|
|
||||||
.as_deref()
|
|
||||||
.and_then(PortalRole::parse)
|
|
||||||
.or_else(|| {
|
|
||||||
request
|
|
||||||
.headers()
|
|
||||||
.iter()
|
|
||||||
.find(|header| header.field.equiv("X-AWatch-Role"))
|
|
||||||
.and_then(|header| PortalRole::parse(header.value.as_str()))
|
|
||||||
})
|
|
||||||
.unwrap_or(PortalRole::Executive)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn role_envelope(role: PortalRole, scope: &str) -> Value {
|
|
||||||
json!({
|
|
||||||
"role": role.as_str(),
|
|
||||||
"role_label": role.label_ru(),
|
|
||||||
"scope": scope,
|
|
||||||
"allowed_scopes": role.allowed_scopes(),
|
|
||||||
"server_enforced": true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn respond_forbidden(request: Request, role: PortalRole, scope: &str) -> Result<()> {
|
|
||||||
respond_json_status(
|
|
||||||
request,
|
|
||||||
StatusCode(403),
|
|
||||||
&json!({
|
|
||||||
"ok": false,
|
|
||||||
"error": "forbidden",
|
|
||||||
"message": format!("Роль {} не имеет доступа к контуру {scope}", role.label_ru()),
|
|
||||||
"role": role.as_str(),
|
|
||||||
"scope": scope,
|
|
||||||
"server_enforced": true,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_investigation_pack_path(path: &str) -> Option<String> {
|
|
||||||
path.strip_prefix("/api/investigation-pack/")
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty() && !value.contains('/'))
|
|
||||||
.map(ToString::to_string)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_case_path(path: &str) -> Option<String> {
|
|
||||||
path.strip_prefix("/api/cases/")
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty() && !value.contains('/'))
|
|
||||||
.map(ToString::to_string)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parse_case_status_path(path: &str) -> Option<String> {
|
|
||||||
path.strip_prefix("/api/cases/")
|
|
||||||
.and_then(|value| value.strip_suffix("/status"))
|
|
||||||
.map(str::trim)
|
|
||||||
.filter(|value| !value.is_empty() && !value.contains('/'))
|
|
||||||
.map(ToString::to_string)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn cached_snapshot(args: &Cli, cache: &SnapshotCache) -> Snapshot {
|
|
||||||
let mut guard = cache.lock().expect("snapshot cache mutex poisoned");
|
|
||||||
if let Some(cached) = guard.as_ref() {
|
|
||||||
if cached.created.elapsed() <= SNAPSHOT_CACHE_TTL {
|
|
||||||
return cached.snapshot.clone();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let snapshot = build_snapshot(args);
|
|
||||||
*guard = Some(CachedSnapshot {
|
|
||||||
created: Instant::now(),
|
|
||||||
snapshot: snapshot.clone(),
|
|
||||||
});
|
|
||||||
snapshot
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_fast_health(cache: &SnapshotCache) -> HealthResponse {
|
|
||||||
match cache.try_lock() {
|
|
||||||
Ok(guard) => guard
|
|
||||||
.as_ref()
|
|
||||||
.map(|cached| build_health(&cached.snapshot))
|
|
||||||
.unwrap_or_else(lightweight_health),
|
|
||||||
Err(_) => lightweight_health(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn lightweight_health() -> HealthResponse {
|
|
||||||
let mut sources = BTreeMap::new();
|
|
||||||
sources.insert("portal".to_string(), true);
|
|
||||||
HealthResponse {
|
|
||||||
ok: true,
|
|
||||||
generated_at_utc: now(),
|
|
||||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
|
||||||
sources,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn build_snapshot(args: &Cli) -> Snapshot {
|
fn build_snapshot(args: &Cli) -> Snapshot {
|
||||||
let timeout = Duration::from_secs(args.timeout_seconds);
|
let timeout = Duration::from_secs(args.timeout_seconds);
|
||||||
let security_events_config = SecurityEventsConfig {
|
let security_events_config = SecurityEventsConfig {
|
||||||
@@ -10198,7 +9848,7 @@ fn json_i64(value: &Value, names: &[&str]) -> Option<i64> {
|
|||||||
.find_map(|name| value.get(*name).and_then(Value::as_i64))
|
.find_map(|name| value.get(*name).and_then(Value::as_i64))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn screenshot_basename(path: &str) -> Option<String> {
|
pub(crate) fn screenshot_basename(path: &str) -> Option<String> {
|
||||||
if path.split(['/', '\\']).any(|part| part == "..") {
|
if path.split(['/', '\\']).any(|part| part == "..") {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
@@ -10779,119 +10429,7 @@ fn source_summary(name: &str, payload: &Value) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn respond_json<T: Serialize>(request: Request, value: &T) -> Result<()> {
|
pub(crate) fn now() -> String {
|
||||||
let body = serde_json::to_string_pretty(value)?;
|
|
||||||
respond_text(
|
|
||||||
request,
|
|
||||||
StatusCode(200),
|
|
||||||
&body,
|
|
||||||
"application/json; charset=utf-8",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn respond_json_status<T: Serialize>(
|
|
||||||
request: Request,
|
|
||||||
status: StatusCode,
|
|
||||||
value: &T,
|
|
||||||
) -> Result<()> {
|
|
||||||
let body = serde_json::to_string_pretty(value)?;
|
|
||||||
respond_text(request, status, &body, "application/json; charset=utf-8")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn respond_text(
|
|
||||||
request: Request,
|
|
||||||
status: StatusCode,
|
|
||||||
body: &str,
|
|
||||||
content_type: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
let metadata = http_request_metadata(&request);
|
|
||||||
record_http_metric(&metadata, status);
|
|
||||||
log_http_request(&metadata, status, body.len());
|
|
||||||
let response = Response::from_string(body.to_string())
|
|
||||||
.with_status_code(status)
|
|
||||||
.with_header(header("Content-Type", content_type)?)
|
|
||||||
.with_header(header("Cache-Control", "no-store")?)
|
|
||||||
.with_header(header("X-Request-Id", &metadata.request_id)?)
|
|
||||||
.with_header(header("X-Correlation-Id", &metadata.correlation_id)?);
|
|
||||||
request.respond(response).map_err(|err| anyhow!("{err}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn respond_text_download(
|
|
||||||
request: Request,
|
|
||||||
status: StatusCode,
|
|
||||||
body: &str,
|
|
||||||
content_type: &str,
|
|
||||||
download_name: &str,
|
|
||||||
) -> Result<()> {
|
|
||||||
let metadata = http_request_metadata(&request);
|
|
||||||
record_http_metric(&metadata, status);
|
|
||||||
log_http_request(&metadata, status, body.len());
|
|
||||||
let response = Response::from_string(body.to_string())
|
|
||||||
.with_status_code(status)
|
|
||||||
.with_header(header("Content-Type", content_type)?)
|
|
||||||
.with_header(header("Cache-Control", "no-store")?)
|
|
||||||
.with_header(header("X-Request-Id", &metadata.request_id)?)
|
|
||||||
.with_header(header("X-Correlation-Id", &metadata.correlation_id)?)
|
|
||||||
.with_header(header(
|
|
||||||
"Content-Disposition",
|
|
||||||
&format!(
|
|
||||||
"attachment; filename=\"{}\"",
|
|
||||||
download_name.replace('"', "")
|
|
||||||
),
|
|
||||||
)?);
|
|
||||||
request.respond(response).map_err(|err| anyhow!("{err}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn respond_file(
|
|
||||||
request: Request,
|
|
||||||
path: &Path,
|
|
||||||
content_type: &str,
|
|
||||||
download_name: Option<&str>,
|
|
||||||
) -> Result<()> {
|
|
||||||
let data = fs::read(path).with_context(|| format!("read {}", path.display()))?;
|
|
||||||
let metadata = http_request_metadata(&request);
|
|
||||||
record_http_metric(&metadata, StatusCode(200));
|
|
||||||
log_http_request(&metadata, StatusCode(200), data.len());
|
|
||||||
let mut response = Response::from_data(data)
|
|
||||||
.with_status_code(StatusCode(200))
|
|
||||||
.with_header(header("Content-Type", content_type)?)
|
|
||||||
.with_header(header("Cache-Control", "no-store")?)
|
|
||||||
.with_header(header("X-Request-Id", &metadata.request_id)?)
|
|
||||||
.with_header(header("X-Correlation-Id", &metadata.correlation_id)?);
|
|
||||||
if let Some(name) = download_name.and_then(screenshot_basename) {
|
|
||||||
response = response.with_header(header(
|
|
||||||
"Content-Disposition",
|
|
||||||
&format!("attachment; filename=\"{}\"", name.replace('"', "")),
|
|
||||||
)?);
|
|
||||||
}
|
|
||||||
request.respond(response).map_err(|err| anyhow!("{err}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn safe_download_stem(value: &str) -> String {
|
|
||||||
let stem = value
|
|
||||||
.chars()
|
|
||||||
.map(|ch| {
|
|
||||||
if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') {
|
|
||||||
ch
|
|
||||||
} else {
|
|
||||||
'_'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.take(96)
|
|
||||||
.collect::<String>();
|
|
||||||
if stem.is_empty() {
|
|
||||||
"candidate".to_string()
|
|
||||||
} else {
|
|
||||||
stem
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn header(name: &str, value: &str) -> Result<Header> {
|
|
||||||
Header::from_bytes(name.as_bytes(), value.as_bytes())
|
|
||||||
.map_err(|_| anyhow!("invalid header {name}: {value}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn now() -> String {
|
|
||||||
Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
|
Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
//! URL path and query parsing helpers for the portal.
|
||||||
|
//!
|
||||||
|
//! CONTRACT: these helpers are routing glue. Keep accepted URL shapes stable
|
||||||
|
//! because API handlers and the HTML portal depend on them.
|
||||||
|
|
||||||
|
pub(crate) fn normalize_path(url: &str) -> String {
|
||||||
|
let path = url.split('?').next().unwrap_or("/");
|
||||||
|
let path = path.strip_prefix("/portal").unwrap_or(path);
|
||||||
|
if path.is_empty() {
|
||||||
|
"/".to_string()
|
||||||
|
} else {
|
||||||
|
path.to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn query_flag(url: &str, key: &str) -> bool {
|
||||||
|
let Some(query) = url.split_once('?').map(|(_, query)| query) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
query.split('&').any(|pair| {
|
||||||
|
let (name, value) = pair.split_once('=').unwrap_or((pair, "1"));
|
||||||
|
name == key && matches!(value, "1" | "true" | "yes" | "on")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn query_param(url: &str, key: &str) -> Option<String> {
|
||||||
|
let query = url.split_once('?').map(|(_, query)| query)?;
|
||||||
|
query.split('&').find_map(|pair| {
|
||||||
|
let (name, value) = pair.split_once('=').unwrap_or((pair, ""));
|
||||||
|
(name == key && !value.is_empty()).then(|| value.to_string())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_investigation_pack_path(path: &str) -> Option<String> {
|
||||||
|
path.strip_prefix("/api/investigation-pack/")
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty() && !value.contains('/'))
|
||||||
|
.map(ToString::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_case_path(path: &str) -> Option<String> {
|
||||||
|
path.strip_prefix("/api/cases/")
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty() && !value.contains('/'))
|
||||||
|
.map(ToString::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn parse_case_status_path(path: &str) -> Option<String> {
|
||||||
|
path.strip_prefix("/api/cases/")
|
||||||
|
.and_then(|value| value.strip_suffix("/status"))
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty() && !value.contains('/'))
|
||||||
|
.map(ToString::to_string)
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
//! Portal role model and access-scope contract.
|
||||||
|
//!
|
||||||
|
//! CONTRACT: role aliases, serialized values and allowed scopes are part of
|
||||||
|
//! the portal API/security boundary. Keep changes explicit and covered by
|
||||||
|
//! existing role-gate tests in `main.rs`.
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub(crate) enum PortalRole {
|
||||||
|
Executive,
|
||||||
|
Manager,
|
||||||
|
Security,
|
||||||
|
Forensics,
|
||||||
|
Admin,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PortalRole {
|
||||||
|
pub(crate) fn parse(value: &str) -> Option<Self> {
|
||||||
|
match value.trim().to_ascii_lowercase().as_str() {
|
||||||
|
"executive" | "owner" | "rukovoditel" | "руководитель" => {
|
||||||
|
Some(Self::Executive)
|
||||||
|
}
|
||||||
|
"manager" | "workforce" | "руководитель_подразделения" => {
|
||||||
|
Some(Self::Manager)
|
||||||
|
}
|
||||||
|
"security" | "ib" | "soc" | "безопасность" => Some(Self::Security),
|
||||||
|
"forensics" | "investigation" | "расследования" => Some(Self::Forensics),
|
||||||
|
"admin" | "operations" | "operator" | "эксплуатация" => Some(Self::Admin),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Executive => "executive",
|
||||||
|
Self::Manager => "manager",
|
||||||
|
Self::Security => "security",
|
||||||
|
Self::Forensics => "forensics",
|
||||||
|
Self::Admin => "admin",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn label_ru(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Executive => "Руководитель",
|
||||||
|
Self::Manager => "Руководитель подразделения",
|
||||||
|
Self::Security => "Безопасность",
|
||||||
|
Self::Forensics => "Расследования",
|
||||||
|
Self::Admin => "Администратор",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn allowed_scopes(self) -> &'static [&'static str] {
|
||||||
|
match self {
|
||||||
|
Self::Executive => &["executive", "workforce"],
|
||||||
|
Self::Manager => &["executive", "workforce"],
|
||||||
|
Self::Security => &["security", "incidents", "ueba", "pfsense"],
|
||||||
|
Self::Forensics => &["forensics", "incidents", "ueba"],
|
||||||
|
Self::Admin => &[
|
||||||
|
"executive",
|
||||||
|
"workforce",
|
||||||
|
"security",
|
||||||
|
"forensics",
|
||||||
|
"incidents",
|
||||||
|
"ueba",
|
||||||
|
"pfsense",
|
||||||
|
"admin",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn can_access(self, scope: &str) -> bool {
|
||||||
|
self.allowed_scopes().contains(&scope)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
//! Readiness API payload helpers for the portal.
|
||||||
|
//!
|
||||||
|
//! CONTRACT: these helpers expose existing readiness bundle/status/verify
|
||||||
|
//! payloads. Keep file names, JSON fields and verification commands stable
|
||||||
|
//! unless the customer readiness contract is updated in the same PR.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
use crate::command_runner::run_in_dir;
|
||||||
|
use crate::{Cli, now};
|
||||||
|
|
||||||
|
pub(crate) fn readiness_latest(args: &Cli) -> Value {
|
||||||
|
read_json_file(
|
||||||
|
&args
|
||||||
|
.readiness_bundle_dir
|
||||||
|
.join("detmir-readiness-latest.json"),
|
||||||
|
)
|
||||||
|
.unwrap_or_else(|err| {
|
||||||
|
json!({
|
||||||
|
"ok": false,
|
||||||
|
"generated_at_utc": now(),
|
||||||
|
"error": err.to_string(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn readiness_bundle(args: &Cli) -> Value {
|
||||||
|
let dir = &args.readiness_bundle_dir;
|
||||||
|
let status = read_json_file(&dir.join("detmir-readiness-status.json")).unwrap_or_else(|err| {
|
||||||
|
json!({
|
||||||
|
"ok": false,
|
||||||
|
"error": err.to_string(),
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let latest_dir = fs::read_to_string(dir.join("latest-dir.txt"))
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_string();
|
||||||
|
let artifacts = [
|
||||||
|
"detmir-readiness-latest.json",
|
||||||
|
"detmir-readiness-act.md",
|
||||||
|
"detmir-readiness-act.html",
|
||||||
|
"sha256sums.txt",
|
||||||
|
"sha256sums.txt.sig",
|
||||||
|
"public-key.pem",
|
||||||
|
"detmir-readiness-status.json",
|
||||||
|
"detmir-readiness.prom",
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|name| {
|
||||||
|
let path = dir.join(name);
|
||||||
|
path.metadata().ok().map(|meta| {
|
||||||
|
json!({
|
||||||
|
"name": name,
|
||||||
|
"bytes": meta.len(),
|
||||||
|
"available": true,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
json!({
|
||||||
|
"ok": status.get("ok").and_then(Value::as_bool).unwrap_or(false),
|
||||||
|
"generated_at_utc": now(),
|
||||||
|
"bundle_dir": dir.display().to_string(),
|
||||||
|
"latest_archive_dir": latest_dir,
|
||||||
|
"status": status,
|
||||||
|
"artifacts": artifacts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn readiness_verify(args: &Cli) -> Value {
|
||||||
|
let dir = &args.readiness_bundle_dir;
|
||||||
|
let checksum = run_in_dir(
|
||||||
|
dir,
|
||||||
|
Command::new("sha256sum").arg("-c").arg("sha256sums.txt"),
|
||||||
|
);
|
||||||
|
let sig_path = dir.join("sha256sums.txt.sig");
|
||||||
|
let pub_path = dir.join("public-key.pem");
|
||||||
|
let signature = if sig_path.is_file() && pub_path.is_file() {
|
||||||
|
run_in_dir(
|
||||||
|
dir,
|
||||||
|
Command::new("openssl")
|
||||||
|
.arg("dgst")
|
||||||
|
.arg("-sha256")
|
||||||
|
.arg("-verify")
|
||||||
|
.arg("public-key.pem")
|
||||||
|
.arg("-signature")
|
||||||
|
.arg("sha256sums.txt.sig")
|
||||||
|
.arg("sha256sums.txt"),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Err("signature files are not available".to_string())
|
||||||
|
};
|
||||||
|
json!({
|
||||||
|
"ok": checksum.is_ok() && signature.is_ok(),
|
||||||
|
"generated_at_utc": now(),
|
||||||
|
"checksum_verified": checksum.is_ok(),
|
||||||
|
"signature_verified": signature.is_ok(),
|
||||||
|
"checksum_error": checksum.err(),
|
||||||
|
"signature_error": signature.err(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_json_file(path: &Path) -> Result<Value> {
|
||||||
|
let text = fs::read_to_string(path).with_context(|| format!("read {}", path.display()))?;
|
||||||
|
serde_json::from_str(&text).with_context(|| format!("parse {}", path.display()))
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
//! Portal role extraction and access-denial helpers.
|
||||||
|
//!
|
||||||
|
//! CONTRACT: role aliases, role envelope fields and forbidden response shape
|
||||||
|
//! are part of the portal security boundary. Keep changes explicit and covered
|
||||||
|
//! by role-gate tests.
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use tiny_http::{Request, StatusCode};
|
||||||
|
|
||||||
|
use crate::path_query::query_param;
|
||||||
|
use crate::portal_roles::PortalRole;
|
||||||
|
use crate::respond_json_status;
|
||||||
|
|
||||||
|
pub(crate) fn portal_role_from_request(request: &Request, url: &str) -> PortalRole {
|
||||||
|
query_param(url, "role")
|
||||||
|
.as_deref()
|
||||||
|
.and_then(PortalRole::parse)
|
||||||
|
.or_else(|| {
|
||||||
|
request
|
||||||
|
.headers()
|
||||||
|
.iter()
|
||||||
|
.find(|header| header.field.equiv("X-AWatch-Role"))
|
||||||
|
.and_then(|header| PortalRole::parse(header.value.as_str()))
|
||||||
|
})
|
||||||
|
.unwrap_or(PortalRole::Executive)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn role_envelope(role: PortalRole, scope: &str) -> Value {
|
||||||
|
json!({
|
||||||
|
"role": role.as_str(),
|
||||||
|
"role_label": role.label_ru(),
|
||||||
|
"scope": scope,
|
||||||
|
"allowed_scopes": role.allowed_scopes(),
|
||||||
|
"server_enforced": true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn respond_forbidden(request: Request, role: PortalRole, scope: &str) -> Result<()> {
|
||||||
|
respond_json_status(
|
||||||
|
request,
|
||||||
|
StatusCode(403),
|
||||||
|
&json!({
|
||||||
|
"ok": false,
|
||||||
|
"error": "forbidden",
|
||||||
|
"message": format!("Роль {} не имеет доступа к контуру {scope}", role.label_ru()),
|
||||||
|
"role": role.as_str(),
|
||||||
|
"scope": scope,
|
||||||
|
"server_enforced": true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
//! Snapshot cache helpers for the portal request path.
|
||||||
|
//!
|
||||||
|
//! CONTRACT: this module only owns short-lived in-process cache behavior.
|
||||||
|
//! It must not change snapshot payloads, source collection, API routes or
|
||||||
|
//! business calculations.
|
||||||
|
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
use crate::{Cli, HealthResponse, Snapshot, build_health, build_snapshot, now};
|
||||||
|
|
||||||
|
const SNAPSHOT_CACHE_TTL: Duration = Duration::from_secs(120);
|
||||||
|
|
||||||
|
pub(crate) type SnapshotCache = Arc<Mutex<Option<CachedSnapshot>>>;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub(crate) struct CachedSnapshot {
|
||||||
|
created: Instant,
|
||||||
|
snapshot: Snapshot,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn new_snapshot_cache() -> SnapshotCache {
|
||||||
|
Arc::new(Mutex::new(None))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn clone_snapshot_cache(cache: &SnapshotCache) -> SnapshotCache {
|
||||||
|
Arc::clone(cache)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn cached_snapshot(args: &Cli, cache: &SnapshotCache) -> Snapshot {
|
||||||
|
let mut guard = cache.lock().expect("snapshot cache mutex poisoned");
|
||||||
|
if let Some(cached) = guard.as_ref() {
|
||||||
|
if cached.created.elapsed() <= SNAPSHOT_CACHE_TTL {
|
||||||
|
return cached.snapshot.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let snapshot = build_snapshot(args);
|
||||||
|
*guard = Some(CachedSnapshot {
|
||||||
|
created: Instant::now(),
|
||||||
|
snapshot: snapshot.clone(),
|
||||||
|
});
|
||||||
|
snapshot
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_fast_health(cache: &SnapshotCache) -> HealthResponse {
|
||||||
|
match cache.try_lock() {
|
||||||
|
Ok(guard) => guard
|
||||||
|
.as_ref()
|
||||||
|
.map(|cached| build_health(&cached.snapshot))
|
||||||
|
.unwrap_or_else(lightweight_health),
|
||||||
|
Err(_) => lightweight_health(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lightweight_health() -> HealthResponse {
|
||||||
|
let mut sources = BTreeMap::new();
|
||||||
|
sources.insert("portal".to_string(), true);
|
||||||
|
HealthResponse {
|
||||||
|
ok: true,
|
||||||
|
generated_at_utc: now(),
|
||||||
|
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
sources,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
//! Static portal assets and generated API contract text.
|
||||||
|
//!
|
||||||
|
//! CONTRACT: this module only exposes embedded static files. Do not change
|
||||||
|
//! file contents, MIME handling, routes or API contracts from here.
|
||||||
|
|
||||||
|
pub(crate) const INDEX_HTML: &str = include_str!("static/index.html");
|
||||||
|
pub(crate) const ARCHITECTURE_HTML: &str = include_str!("static/architecture.html");
|
||||||
|
pub(crate) const APP_CSS: &str = include_str!("static/app.css");
|
||||||
|
pub(crate) const APP_JS: &str = include_str!("static/app.js");
|
||||||
|
pub(crate) const API_CONTRACT_OPENAPI: &str = include_str!("contracts/openapi.json");
|
||||||
|
pub(crate) const API_CONTRACT_TYPESCRIPT: &str = include_str!("contracts/typescript.d.ts");
|
||||||
Reference in New Issue
Block a user