From 7d314057e485f408f68ebd603e1cbf04b8768b9b Mon Sep 17 00:00:00 2001 From: Roland Reich Date: Sat, 26 Sep 2026 13:07:23 +0200 Subject: [PATCH] software package profiles implemeted, job deletion optimized --- .dockerignore | 1 + .env.example | 12 + .gitignore | 2 + Dockerfile | 2 +- README.md | 6 +- THIRD_PARTY_NOTICES.md | 3 + VERSION | 2 +- app/analyzer_profiles.py | 630 +++++++++ app/analyzer_profiles/system/7zip-sfx.json | 11 + .../system/advanced-installer.json | 10 + app/analyzer_profiles/system/greenshot.json | 45 + app/analyzer_profiles/system/inno.json | 17 + .../system/installshield.json | 10 + app/analyzer_profiles/system/nsis.json | 12 + .../system/pdf24-online.json | 67 + app/analyzer_profiles/system/squirrel.json | 10 + .../system/total-commander.json | 106 ++ app/analyzer_profiles/system/vlc.json | 24 + app/analyzer_profiles/system/winrar-sfx.json | 11 + app/analyzer_profiles/system/wix-burn.json | 12 + app/backup.py | 27 +- app/config.py | 5 +- app/i18n.py | 118 +- app/main.py | 213 ++- app/meshcentral.py | 3 +- app/presence.py | 3 +- app/privacy.py | 3 +- app/privacy_retention.py | 5 +- app/setup_analyzer.py | 1152 ++++++++++++++--- app/software_control.py | 178 ++- app/software_packages.py | 291 ++++- app/static/css/app.css | 98 ++ app/static/js/file-picker.js | 24 + app/templates/base.html | 2 +- app/templates/settings.html | 2 +- .../settings_backup_placeholder.html | 2 +- app/templates/settings_software.html | 33 + app/templates/setup_analyzer.html | 61 +- app/templates/setup_analyzer_profiles.html | 111 ++ app/templates/software_package.html | 5 +- app/templates/software_packages.html | 44 +- app/version.py | 2 +- data/analyzer-profiles/.gitkeep | 0 docker-compose.yml | 49 +- docker-entrypoint.sh | 95 +- docs/ASSET-VISUALS.md | 2 +- docs/INSTALLATION.md | 70 +- docs/SETUP_ANALYZER.md | 41 +- docs/analyzer-profiles/README.md | 64 + .../repository-index.example.json | 13 + docs/version-history/README.md | 4 +- docs/version-history/UPDATE-0.5.5.89.md | 62 + docs/version-history/UPDATE-0.5.5.90.md | 58 + requirements.txt | 1 + 54 files changed, 3492 insertions(+), 342 deletions(-) create mode 100755 app/analyzer_profiles.py create mode 100755 app/analyzer_profiles/system/7zip-sfx.json create mode 100755 app/analyzer_profiles/system/advanced-installer.json create mode 100755 app/analyzer_profiles/system/greenshot.json create mode 100755 app/analyzer_profiles/system/inno.json create mode 100755 app/analyzer_profiles/system/installshield.json create mode 100755 app/analyzer_profiles/system/nsis.json create mode 100755 app/analyzer_profiles/system/pdf24-online.json create mode 100755 app/analyzer_profiles/system/squirrel.json create mode 100755 app/analyzer_profiles/system/total-commander.json create mode 100755 app/analyzer_profiles/system/vlc.json create mode 100755 app/analyzer_profiles/system/winrar-sfx.json create mode 100755 app/analyzer_profiles/system/wix-burn.json create mode 100755 app/static/js/file-picker.js create mode 100755 app/templates/setup_analyzer_profiles.html create mode 100755 data/analyzer-profiles/.gitkeep create mode 100755 docs/analyzer-profiles/README.md create mode 100755 docs/analyzer-profiles/repository-index.example.json create mode 100755 docs/version-history/UPDATE-0.5.5.89.md create mode 100755 docs/version-history/UPDATE-0.5.5.90.md diff --git a/.dockerignore b/.dockerignore index 9169010..c490622 100755 --- a/.dockerignore +++ b/.dockerignore @@ -26,6 +26,7 @@ data/backups data/backup data/scripts data/software-packages +data/analyzer-profiles app/static/uploads/* !app/static/uploads/.gitkeep app/main_old.py diff --git a/.env.example b/.env.example index a598bf4..87388ad 100755 --- a/.env.example +++ b/.env.example @@ -15,3 +15,15 @@ BACKUP_RETENTION_DAYS=3 # address or 0.0.0.0 only when the listener must be reachable directly. CALLBACK_BIND_IP=127.0.0.1 CALLBACK_PORT=8090 + +# Setup Analyzer SFX safety limits +SETUP_ANALYZER_MAX_EXTRACTED_MB=8192 +SETUP_ANALYZER_MAX_EXTRACTED_FILES=20000 +SETUP_ANALYZER_MAX_SFX_DEPTH=2 +# Analyzer profile system / community repository +ANALYZER_PROFILE_REPOSITORY_URL= +ANALYZER_PROFILE_MAX_BYTES=2097152 +ANALYZER_PROFILE_REPOSITORY_MAX_BYTES=4194304 +SOFTWARE_PACKAGE_IMPORT_MAX_MB=8192 +SOFTWARE_PACKAGE_IMPORT_MAX_FILES=20000 + diff --git a/.gitignore b/.gitignore index 414702c..d312029 100755 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,8 @@ data/scripts/* !data/scripts/.gitkeep data/software-packages/* !data/software-packages/.gitkeep +data/analyzer-profiles/* +!data/analyzer-profiles/.gitkeep app/static/uploads/* !app/static/uploads/.gitkeep diff --git a/Dockerfile b/Dockerfile index c09e7e9..06c862c 100755 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ LABEL org.opencontainers.image.title="AssetManager" \ WORKDIR /opt/meshcentral RUN apt-get update \ - && apt-get install -y --no-install-recommends nodejs npm ca-certificates postgresql-client \ + && apt-get install -y --no-install-recommends nodejs npm ca-certificates postgresql-client 7zip unar \ && rm -rf /var/lib/apt/lists/* \ && npm install --omit=dev meshcentral \ && chown -R root:root /opt/meshcentral diff --git a/README.md b/README.md index 6260db7..21c4f3d 100755 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# AssetManager 0.5.5.88 +# AssetManager 0.5.5.90 AssetManager is a self-hosted web application for managing IT equipment and other organizational assets. It provides asset inventory, software inventory, remote job execution, reporting, privacy/retention documentation, and optional integration with MeshCentral. @@ -9,7 +9,7 @@ The project is licensed under the **Apache License 2.0** and may be used, modifi - configurable asset categories, fields, status values, images, and assignments - asset lists, detail views, history, bulk editing, Excel import/export, duplicate merging, and tree views - hardware and software inventory with comparison, aggregation views, and Excel export -- static Setup Analyzer for Windows installer technology, silent parameters, deployment-script export, and persistent software-package creation +- static Setup Analyzer for Windows installer technology, silent parameters, recursive SFX/embedded-installer analysis, modular analyzer profiles with import/export/community repository support, deployment-script export, and persistent software-package creation/import/export - install, uninstall, and reinstall software-package jobs with MeshCentral file transfer, callback tracking, process control, and optional post-install application launch - job definitions and remote job execution with status tracking, callbacks, retries, and logs - optional MeshCentral integration through the MeshCtrl command-line interface @@ -141,4 +141,4 @@ Copyright © 2026 Roland Reich Licensed under the Apache License, Version 2.0. See [LICENSE.txt](LICENSE.txt) for the complete license text. -Reusable asset/category images are persisted in `./data/uploads/library` and mounted into the application container. +Reusable asset/category images are persisted in `./data/uploads/library` below the common AssetManager data root mounted into the application container. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 67e398e..53c0a54 100755 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -19,6 +19,9 @@ AssetManager is released under the Apache License 2.0. The following components | bcrypt | Hashing library used by `passlib[bcrypt]` | Apache-2.0 | | openpyxl | Excel import and export | MIT | | pefile | Static Portable Executable metadata analysis for Setup Analyzer | MIT | +| cryptography | Static Authenticode certificate metadata parsing for Setup Analyzer | Apache-2.0 OR BSD-3-Clause | +| 7-Zip command-line tools | Static extraction of supported SFX/archive payloads in Setup Analyzer | System package; see distribution package license metadata | +| The Unarchiver (`unar` / `lsar`) | Static extraction/listing fallback for supported SFX/archive payloads | System package; see distribution package license metadata | | MeshCentral / MeshCtrl | Optional integration and remote jobs | Apache-2.0 | | PostgreSQL container image | Database service | Contains PostgreSQL and operating-system packages under their own licenses | | Python container image | Runtime base | Contains Python and operating-system packages under their own licenses | diff --git a/VERSION b/VERSION index 7539992..0ee22e3 100755 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.5.5.88 +0.5.5.90 diff --git a/app/analyzer_profiles.py b/app/analyzer_profiles.py new file mode 100755 index 0000000..138cd24 --- /dev/null +++ b/app/analyzer_profiles.py @@ -0,0 +1,630 @@ +from __future__ import annotations + +import configparser +import hashlib +import io +import json +import os +import re +import shutil +import tempfile +import urllib.parse +import urllib.request +import zipfile +from pathlib import Path +from typing import Any + + +PROFILE_SCHEMA = "assetmanager-analyzer-profile-v1" +PROFILE_BUNDLE_SCHEMA = "assetmanager-analyzer-profile-bundle-v1" +PROFILE_REPOSITORY_SCHEMA = "assetmanager-analyzer-profile-repository-v1" +PROFILE_API = 1 +SYSTEM_PROFILE_DIR = Path(__file__).resolve().parent / "analyzer_profiles" / "system" +DATA_ROOT = Path(os.getenv("ASSETMANAGER_DATA_ROOT", "/assetmanager-data")) +PROFILE_DATA_ROOT = Path(os.getenv("ANALYZER_PROFILE_DIR", str(DATA_ROOT / "analyzer-profiles"))) +COMMUNITY_PROFILE_DIR = PROFILE_DATA_ROOT / "community" +LOCAL_PROFILE_DIR = PROFILE_DATA_ROOT / "local" +PROFILE_STATE_FILE = PROFILE_DATA_ROOT / "state.json" +REPOSITORY_URL = os.getenv("ANALYZER_PROFILE_REPOSITORY_URL", "").strip() +MAX_PROFILE_BYTES = max(64 * 1024, int(os.getenv("ANALYZER_PROFILE_MAX_BYTES", str(2 * 1024 * 1024)))) +MAX_REPOSITORY_INDEX_BYTES = max(64 * 1024, int(os.getenv("ANALYZER_PROFILE_REPOSITORY_MAX_BYTES", str(4 * 1024 * 1024)))) + +for _directory in (COMMUNITY_PROFILE_DIR, LOCAL_PROFILE_DIR): + _directory.mkdir(parents=True, exist_ok=True) + + +def _safe_profile_id(value: str) -> str: + value = str(value or "").strip().lower() + if not re.fullmatch(r"[a-z0-9][a-z0-9._-]{1,95}", value): + raise ValueError("invalid profile id") + return value + + +def _clean_text(value: Any, maximum: int = 500) -> str: + return re.sub(r"[\x00-\x1f]+", " ", str(value or "")).strip()[:maximum] + + +def _state() -> dict[str, Any]: + try: + data = json.loads(PROFILE_STATE_FILE.read_text(encoding="utf-8")) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + +def _write_state(data: dict[str, Any]) -> None: + PROFILE_DATA_ROOT.mkdir(parents=True, exist_ok=True) + temporary = PROFILE_STATE_FILE.with_suffix(".tmp") + temporary.write_text(json.dumps(data, ensure_ascii=True, indent=2) + "\n", encoding="utf-8") + temporary.replace(PROFILE_STATE_FILE) + + +def profile_enabled(profile_id: str) -> bool: + return not bool((_state().get("disabled") or {}).get(profile_id)) + + +def set_profile_enabled(profile_id: str, enabled: bool) -> None: + profile_id = _safe_profile_id(profile_id) + data = _state() + disabled = data.setdefault("disabled", {}) + if enabled: + disabled.pop(profile_id, None) + else: + disabled[profile_id] = True + _write_state(data) + + +def validate_profile(raw: Any) -> dict[str, Any]: + if not isinstance(raw, dict): + raise ValueError("profile is not an object") + profile = json.loads(json.dumps(raw)) + if str(profile.get("schema") or "") != PROFILE_SCHEMA: + raise ValueError("unsupported analyzer profile schema") + if int(profile.get("profile_api") or 0) != PROFILE_API: + raise ValueError("unsupported analyzer profile API") + profile["id"] = _safe_profile_id(profile.get("id")) + profile["name"] = _clean_text(profile.get("name"), 180) + profile["version"] = _clean_text(profile.get("version") or "1.0.0", 40) + if not profile["name"]: + raise ValueError("profile name is required") + stage = str(profile.get("stage") or "analysis").strip().lower() + if stage not in {"marker", "analysis", "sfx_extracted"}: + raise ValueError("invalid analyzer profile stage") + profile["stage"] = stage + kind = str(profile.get("kind") or "vendor").strip().lower() + if kind not in {"technology", "vendor", "generic"}: + raise ValueError("invalid analyzer profile kind") + profile["kind"] = kind + try: + profile["priority"] = max(-10000, min(int(profile.get("priority") or 0), 10000)) + except (TypeError, ValueError): + profile["priority"] = 0 + for key in ("match", "result", "command", "metadata"): + if key in profile and not isinstance(profile[key], dict): + raise ValueError(f"profile {key} must be an object") + if stage == "marker": + markers = (profile.get("match") or {}).get("markers") or [] + if not isinstance(markers, list) or not markers: + raise ValueError("marker profile requires match.markers") + for marker in markers: + if not isinstance(marker, dict) or not _clean_text(marker.get("text"), 1024): + raise ValueError("invalid marker definition") + return profile + + +def _load_profile_file(path: Path, source: str) -> dict[str, Any] | None: + try: + if path.stat().st_size > MAX_PROFILE_BYTES: + return None + profile = validate_profile(json.loads(path.read_text(encoding="utf-8"))) + profile["source"] = source + profile["path"] = str(path) + profile["enabled"] = profile_enabled(profile["id"]) + return profile + except Exception: + return None + + +def load_profiles(include_disabled: bool = False) -> list[dict[str, Any]]: + profiles: dict[str, dict[str, Any]] = {} + # System is the fallback. Community can override a system profile and local + # profiles have highest precedence without modifying application files. + for directory, source in ( + (SYSTEM_PROFILE_DIR, "system"), + (COMMUNITY_PROFILE_DIR, "community"), + (LOCAL_PROFILE_DIR, "local"), + ): + if not directory.is_dir(): + continue + for path in sorted(directory.glob("*.json")): + profile = _load_profile_file(path, source) + if profile: + profiles[profile["id"]] = profile + result = list(profiles.values()) + if not include_disabled: + result = [profile for profile in result if profile.get("enabled", True)] + result.sort(key=lambda item: (int(item.get("priority") or 0), item.get("name", "").casefold()), reverse=True) + return result + + +def marker_needles() -> dict[bytes, str]: + result: dict[bytes, str] = {} + for profile in load_profiles(): + if profile.get("stage") != "marker": + continue + for marker in (profile.get("match") or {}).get("markers") or []: + text = _clean_text(marker.get("text"), 1024).casefold() + if not text: + continue + try: + raw = text.encode("utf-8") + except UnicodeEncodeError: + continue + result[raw] = text + try: + result[text.encode("utf-16le")] = text + except UnicodeEncodeError: + pass + return result + + +def detect_marker_profiles(found_markers: set[str]) -> list[dict[str, Any]]: + candidates: list[dict[str, Any]] = [] + for profile in load_profiles(): + if profile.get("stage") != "marker": + continue + score = 0 + signals: list[str] = [] + matched: list[str] = [] + for marker in (profile.get("match") or {}).get("markers") or []: + text = _clean_text(marker.get("text"), 1024).casefold() + if text and text in found_markers: + try: + score += int(marker.get("points") or 0) + except (TypeError, ValueError): + pass + signal = _clean_text(marker.get("signal_key"), 180) + if signal and signal not in signals: + signals.append(signal) + matched.append(text) + if not score: + continue + result = profile.get("result") or {} + floor = int(result.get("confidence_floor") or 55) + candidates.append({ + "key": str(result.get("installer_type") or profile["id"]), + "label": str(result.get("installer_label") or profile["name"]), + "confidence": max(floor, min(score, 99)), + "signals": signals, + "profile_id": profile["id"], + "profile_name": profile["name"], + "profile_version": profile["version"], + "profile_source": profile["source"], + "matched_rules": matched, + }) + candidates.sort(key=lambda item: int(item.get("confidence") or 0), reverse=True) + return candidates + + +def _profile_for_installer_type(installer_type: str) -> dict[str, Any] | None: + installer_type = str(installer_type or "").strip().casefold() + matches = [] + for profile in load_profiles(): + result = profile.get("result") or {} + if str(result.get("installer_type") or "").strip().casefold() == installer_type: + matches.append(profile) + matches.sort(key=lambda item: int(item.get("priority") or 0), reverse=True) + return matches[0] if matches else None + + + +def installer_type_flag(installer_type: str, key: str, default: bool = False) -> bool: + profile = _profile_for_installer_type(installer_type) + if not profile: + return default + result = profile.get("result") or {} + if key in result: + return bool(result.get(key)) + return default + +def command_profile(installer_type: str) -> dict[str, Any]: + profile = _profile_for_installer_type(installer_type) + if not profile: + return {} + command = json.loads(json.dumps(profile.get("command") or {})) + if command: + command["profile_id"] = profile["id"] + command["profile_name"] = profile["name"] + command["profile_version"] = profile["version"] + command["profile_source"] = profile["source"] + return command + + +def _identity(analysis: dict[str, Any], filename: str) -> str: + return " ".join([ + filename, + str(analysis.get("product_name") or ""), + str(analysis.get("manufacturer") or ""), + str(analysis.get("installer_label") or ""), + ]).casefold() + + +def _matches_analysis(profile: dict[str, Any], analysis: dict[str, Any], filename: str, found_markers: set[str]) -> tuple[bool, list[str]]: + match = profile.get("match") or {} + reasons: list[str] = [] + installer_types = [str(item).casefold() for item in match.get("installer_types") or []] + if installer_types and str(analysis.get("installer_type") or "").casefold() not in installer_types: + return False, [] + if installer_types: + reasons.append("installer_type") + patterns = match.get("filename_regex") or [] + if patterns: + if not any(re.search(str(pattern), filename, flags=re.IGNORECASE) for pattern in patterns): + return False, [] + reasons.append("filename") + identity = _identity(analysis, filename) + all_values = [str(item).casefold() for item in match.get("identity_contains_all") or []] + if any(value not in identity for value in all_values): + return False, [] + if all_values: + reasons.append("identity_all") + any_values = [str(item).casefold() for item in match.get("identity_contains_any") or []] + if any_values and not any(value in identity for value in any_values): + return False, [] + if any_values: + reasons.append("identity_any") + marker_all = [str(item).casefold() for item in match.get("markers_all") or []] + if any(value not in found_markers for value in marker_all): + return False, [] + marker_any = [str(item).casefold() for item in match.get("markers_any") or []] + if marker_any and not any(value in found_markers for value in marker_any): + return False, [] + if marker_all or marker_any: + reasons.append("markers") + return True, reasons + + +def _apply_result_overlay(analysis: dict[str, Any], profile: dict[str, Any]) -> dict[str, Any]: + result = profile.get("result") or {} + updated = dict(analysis) + for key, value in result.get("set", {}).items(): + updated[key] = value + for key, value in result.get("set_if_empty", {}).items(): + if not updated.get(key): + updated[key] = value + for key, values in result.get("append", {}).items(): + current = updated.get(key) + if not isinstance(current, list): + current = [] + for value in values if isinstance(values, list) else [values]: + if value not in current: + current.append(value) + updated[key] = current + updated["profile_id"] = profile["id"] + updated["profile_name"] = profile["name"] + updated["profile_version"] = profile["version"] + updated["profile_source"] = profile["source"] + return updated + + +def apply_analysis_profiles(analysis: dict[str, Any], filename: str, found_markers: set[str]) -> dict[str, Any]: + matches: list[tuple[int, dict[str, Any], list[str]]] = [] + for profile in load_profiles(): + if profile.get("stage") != "analysis": + continue + matched, reasons = _matches_analysis(profile, analysis, filename, found_markers) + if matched: + matches.append((int(profile.get("priority") or 0), profile, reasons)) + matches.sort(key=lambda item: item[0], reverse=True) + updated = dict(analysis) + applied: list[dict[str, Any]] = [] + for _priority, profile, reasons in matches: + updated = _apply_result_overlay(updated, profile) + command = profile.get("command") or {} + if command: + updated = apply_command_overlay(updated, command, filename) + metadata = profile.get("metadata") or {} + if metadata.get("filename_version_regex") and not updated.get("product_version"): + match = re.search(str(metadata["filename_version_regex"]), filename, flags=re.IGNORECASE) + if match: + updated["product_version"] = match.groupdict().get("version") or (match.group(1) if match.groups() else "") + applied.append({ + "id": profile["id"], "name": profile["name"], "version": profile["version"], + "source": profile["source"], "reasons": reasons, + }) + if applied: + updated["applied_profiles"] = applied + return updated + + +def apply_command_overlay(analysis: dict[str, Any], command: dict[str, Any], filename: str) -> dict[str, Any]: + updated = dict(analysis) + arguments = str(command.get("install_arguments") or "").strip() + if arguments: + updated["install_arguments"] = arguments + updated["install_command"] = f'"{filename}" {arguments}'.strip() + alternative = str(command.get("alternative_install_arguments") or "").strip() + if alternative: + updated["alternative_install_arguments"] = alternative + updated["alternative_install_command"] = f'"{filename}" {alternative}'.strip() + for key in ("success_codes", "reboot_codes", "detect_method", "command_confidence", "uninstall_command"): + if key in command: + updated[key] = command[key] + for warning in command.get("warning_keys") or []: + warnings = updated.setdefault("warning_keys", []) + if warning not in warnings: + warnings.append(warning) + return updated + + +def _read_relaxed_ini(path: Path) -> dict[str, dict[str, str]]: + data = path.read_bytes() + text = "" + for encoding in ("utf-8-sig", "cp1252", "latin-1"): + try: + text = data.decode(encoding) + break + except UnicodeDecodeError: + continue + sections: dict[str, dict[str, str]] = {} + current = "" + for raw in text.splitlines(): + line = raw.strip() + if not line or line.startswith((";", "#", "//")): + continue + if line.startswith("[") and line.endswith("]"): + current = line[1:-1].strip().casefold() + sections.setdefault(current, {}) + continue + if current and "=" in line: + key, value = line.split("=", 1) + sections[current][key.strip().casefold()] = value.strip() + return sections + + +def _ini_value(sections: dict[str, dict[str, str]], spec: dict[str, Any]) -> str: + section = str(spec.get("section") or "").casefold() + key = str(spec.get("key") or "").casefold() + return str((sections.get(section) or {}).get(key) or "").strip() + + +def _find_glob(root: Path, pattern: str) -> list[Path]: + pattern = str(pattern or "").strip() + if not pattern: + return [] + return sorted([item for item in root.rglob(pattern) if item.is_file()], key=lambda p: (len(p.relative_to(root).parts), p.as_posix().casefold())) + + +def _matches_sfx_profile(profile: dict[str, Any], root: Path) -> tuple[bool, dict[str, Any], list[str]]: + match = profile.get("match") or {} + reasons: list[str] = [] + for pattern in match.get("extracted_files_all") or []: + if not _find_glob(root, str(pattern)): + return False, {}, [] + reasons.append(f"file:{pattern}") + any_patterns = match.get("extracted_files_any") or [] + if any_patterns and not any(_find_glob(root, str(pattern)) for pattern in any_patterns): + return False, {}, [] + if any_patterns: + reasons.append("extracted_file_any") + ini_spec = match.get("ini") or {} + context: dict[str, Any] = {} + if ini_spec: + files = _find_glob(root, str(ini_spec.get("glob") or "")) + if not files: + return False, {}, [] + ini_path = files[0] + sections = _read_relaxed_ini(ini_path) + for condition in ini_spec.get("conditions_all") or []: + value = _ini_value(sections, condition).casefold() + contains = [str(item).casefold() for item in condition.get("contains_any") or []] + regex = str(condition.get("regex") or "") + if contains and not any(item in value for item in contains): + return False, {}, [] + if regex and not re.search(regex, value, flags=re.IGNORECASE): + return False, {}, [] + context = {"ini_path": ini_path, "ini": sections} + reasons.append(f"ini:{ini_path.relative_to(root).as_posix()}") + return True, context, reasons + + +def _metadata_from_sfx(profile: dict[str, Any], context: dict[str, Any], outer_path: Path) -> dict[str, Any]: + metadata = profile.get("metadata") or {} + sections = context.get("ini") or {} + result: dict[str, Any] = {} + for target, source in (metadata.get("ini_fields") or {}).items(): + specs = source if isinstance(source, list) else [source] + for spec in specs: + value = _ini_value(sections, spec) + if value: + result[target] = value + break + for target, value in (metadata.get("fixed") or {}).items(): + if not result.get(target): + result[target] = value + arch = metadata.get("architecture_from") or {} + if arch: + value = _ini_value(sections, arch) + for item in arch.get("patterns") or []: + if re.search(str(item.get("regex") or ""), value, flags=re.IGNORECASE): + result["architecture"] = str(item.get("value") or "") + result["architecture_source"] = str(metadata.get("architecture_source") or "profile_metadata") + break + process = metadata.get("process_name_from") or {} + if process: + value = _ini_value(sections, process) + if value: + result["process_names_default"] = value + version_regex = str(metadata.get("filename_version_regex") or "") + if version_regex and not result.get("product_version"): + match = re.search(version_regex, outer_path.name, flags=re.IGNORECASE) + if match: + result["product_version"] = match.groupdict().get("version") or (match.group(1) if match.groups() else "") + return result + + +def match_sfx_profiles(root: Path, outer_path: Path, base: dict[str, Any]) -> dict[str, Any] | None: + matches: list[tuple[int, dict[str, Any], dict[str, Any], list[str]]] = [] + for profile in load_profiles(): + if profile.get("stage") != "sfx_extracted": + continue + matched, context, reasons = _matches_sfx_profile(profile, root) + if matched: + matches.append((int(profile.get("priority") or 0), profile, context, reasons)) + if not matches: + return None + matches.sort(key=lambda item: item[0], reverse=True) + _priority, profile, context, reasons = matches[0] + result = dict(base) + profile_result = profile.get("result") or {} + result.update(profile_result.get("set") or {}) + result.update(_metadata_from_sfx(profile, context, outer_path)) + command = profile.get("command") or {} + result = apply_command_overlay(result, command, outer_path.name) + result["profile_id"] = profile["id"] + result["profile_name"] = profile["name"] + result["profile_version"] = profile["version"] + result["profile_source"] = profile["source"] + result["applied_profiles"] = [{ + "id": profile["id"], "name": profile["name"], "version": profile["version"], + "source": profile["source"], "reasons": reasons, + }] + ini_path = context.get("ini_path") + if ini_path: + result["metadata_file"] = ini_path.relative_to(root).as_posix() + return result + + + +def apply_profile(profile_id: str, analysis: dict[str, Any], filename: str) -> dict[str, Any]: + profile = get_profile(profile_id) + if not profile or not profile.get("enabled", True): + return dict(analysis) + updated = _apply_result_overlay(analysis, profile) + command = profile.get("command") or {} + if command: + updated = apply_command_overlay(updated, command, filename) + updated["applied_profiles"] = [{ + "id": profile["id"], "name": profile["name"], "version": profile["version"], + "source": profile["source"], "reasons": ["marker_profile"], + }] + return updated + +def _profile_path(profile_id: str, source: str) -> Path: + profile_id = _safe_profile_id(profile_id) + if source == "system": + return SYSTEM_PROFILE_DIR / f"{profile_id}.json" + if source == "community": + return COMMUNITY_PROFILE_DIR / f"{profile_id}.json" + if source == "local": + return LOCAL_PROFILE_DIR / f"{profile_id}.json" + raise ValueError("invalid profile source") + + +def get_profile(profile_id: str) -> dict[str, Any] | None: + for profile in load_profiles(include_disabled=True): + if profile.get("id") == profile_id: + return profile + return None + + +def export_profile_bundle(profile_id: str) -> bytes: + profile = get_profile(_safe_profile_id(profile_id)) + if not profile: + raise FileNotFoundError("profile not found") + public = {key: value for key, value in profile.items() if key not in {"source", "path", "enabled"}} + profile_bytes = (json.dumps(public, ensure_ascii=True, indent=2) + "\n").encode("utf-8") + manifest = { + "schema": PROFILE_BUNDLE_SCHEMA, + "bundle_version": 1, + "profile_id": profile["id"], + "profile_version": profile["version"], + "profile_api": PROFILE_API, + "sha256": hashlib.sha256(profile_bytes).hexdigest(), + } + stream = io.BytesIO() + with zipfile.ZipFile(stream, "w", compression=zipfile.ZIP_DEFLATED) as archive: + archive.writestr("manifest.json", json.dumps(manifest, ensure_ascii=True, indent=2) + "\n") + archive.writestr("profile.json", profile_bytes) + stream.seek(0) + return stream.read() + + +def import_profile_bundle(data: bytes, source: str = "community") -> dict[str, Any]: + if source not in {"community", "local"}: + raise ValueError("invalid import source") + if not data or len(data) > MAX_PROFILE_BYTES: + raise ValueError("profile bundle is empty or too large") + with zipfile.ZipFile(io.BytesIO(data), "r") as archive: + names = archive.namelist() + if any(name.startswith(("/", "\\")) or ".." in Path(name).parts for name in names): + raise ValueError("unsafe profile bundle path") + if "manifest.json" not in names or "profile.json" not in names: + raise ValueError("profile bundle is incomplete") + manifest = json.loads(archive.read("manifest.json")) + if str(manifest.get("schema") or "") != PROFILE_BUNDLE_SCHEMA: + raise ValueError("unsupported profile bundle") + profile_bytes = archive.read("profile.json") + if hashlib.sha256(profile_bytes).hexdigest() != str(manifest.get("sha256") or ""): + raise ValueError("profile checksum mismatch") + profile = validate_profile(json.loads(profile_bytes)) + if profile["id"] != str(manifest.get("profile_id") or ""): + raise ValueError("profile id mismatch") + path = _profile_path(profile["id"], source) + temporary = path.with_suffix(".tmp") + temporary.write_text(json.dumps(profile, ensure_ascii=True, indent=2) + "\n", encoding="utf-8") + temporary.replace(path) + set_profile_enabled(profile["id"], True) + return get_profile(profile["id"]) or profile + + +def delete_imported_profile(profile_id: str) -> bool: + profile = get_profile(_safe_profile_id(profile_id)) + if not profile or profile.get("source") == "system": + return False + path = Path(str(profile.get("path") or "")) + if path.is_file(): + path.unlink() + return True + + +def repository_index(url: str | None = None) -> dict[str, Any]: + url = str(url or REPOSITORY_URL).strip() + if not url: + return {"configured": False, "url": "", "profiles": []} + parsed = urllib.parse.urlparse(url) + if parsed.scheme != "https": + raise ValueError("profile repository URL must use HTTPS") + request = urllib.request.Request(url, headers={"User-Agent": "AssetManager-AnalyzerProfiles/1"}) + with urllib.request.urlopen(request, timeout=12) as response: + data = response.read(MAX_REPOSITORY_INDEX_BYTES + 1) + if len(data) > MAX_REPOSITORY_INDEX_BYTES: + raise ValueError("profile repository index is too large") + index = json.loads(data.decode("utf-8")) + if not isinstance(index, dict) or str(index.get("schema") or "") != PROFILE_REPOSITORY_SCHEMA: + raise ValueError("unsupported profile repository index") + profiles = index.get("profiles") or [] + if not isinstance(profiles, list): + raise ValueError("invalid profile repository index") + return {"configured": True, "url": url, "profiles": profiles, "name": _clean_text(index.get("name"), 180)} + + +def install_repository_profile(profile_id: str, url: str | None = None) -> dict[str, Any]: + profile_id = _safe_profile_id(profile_id) + index = repository_index(url) + entry = next((item for item in index.get("profiles") or [] if str(item.get("id") or "") == profile_id), None) + if not entry: + raise FileNotFoundError("profile is not present in repository") + bundle_url = str(entry.get("url") or "").strip() + parsed = urllib.parse.urlparse(bundle_url) + if parsed.scheme != "https": + raise ValueError("repository profile URL must use HTTPS") + request = urllib.request.Request(bundle_url, headers={"User-Agent": "AssetManager-AnalyzerProfiles/1"}) + with urllib.request.urlopen(request, timeout=20) as response: + data = response.read(MAX_PROFILE_BYTES + 1) + if len(data) > MAX_PROFILE_BYTES: + raise ValueError("repository profile is too large") + expected = str(entry.get("sha256") or "").strip().lower() + if expected and hashlib.sha256(data).hexdigest() != expected: + raise ValueError("repository profile checksum mismatch") + return import_profile_bundle(data, source="community") diff --git a/app/analyzer_profiles/system/7zip-sfx.json b/app/analyzer_profiles/system/7zip-sfx.json new file mode 100755 index 0000000..189b642 --- /dev/null +++ b/app/analyzer_profiles/system/7zip-sfx.json @@ -0,0 +1,11 @@ +{ + "schema": "assetmanager-analyzer-profile-v1", "profile_api": 1, + "id": "technology.7zip-sfx", "name": "7-Zip SFX", "version": "1.0.0", "kind": "technology", "stage": "marker", "priority": 100, + "match": {"markers": [ + {"text": "__7z_archive_signature__", "points": 90, "signal_key": "setup_analyzer.signal.7zip_signature"}, + {"text": "7-zip sfx", "points": 75, "signal_key": "setup_analyzer.signal.7zip_sfx"}, + {"text": "7zs.sfx", "points": 50, "signal_key": "setup_analyzer.signal.7zip_module"} + ]}, + "result": {"installer_type": "7zip_sfx", "installer_label": "7-Zip SFX", "confidence_floor": 55}, + "command": {"warning_keys": ["setup_analyzer.warning.sfx"]} +} diff --git a/app/analyzer_profiles/system/advanced-installer.json b/app/analyzer_profiles/system/advanced-installer.json new file mode 100755 index 0000000..e1a81ef --- /dev/null +++ b/app/analyzer_profiles/system/advanced-installer.json @@ -0,0 +1,10 @@ +{ + "schema": "assetmanager-analyzer-profile-v1", "profile_api": 1, + "id": "technology.advanced-installer", "name": "Advanced Installer", "version": "1.0.0", "kind": "technology", "stage": "marker", "priority": 100, + "match": {"markers": [ + {"text": "advanced installer", "points": 80, "signal_key": "setup_analyzer.signal.advanced_installer"}, + {"text": "caphyon", "points": 50, "signal_key": "setup_analyzer.signal.caphyon"} + ]}, + "result": {"installer_type": "advanced_installer", "installer_label": "Advanced Installer", "confidence_floor": 55}, + "command": {"install_arguments": "/exenoui /qn /norestart", "success_codes": [0,1641,3010], "reboot_codes": [1641,3010], "command_confidence": "medium", "warning_keys": ["setup_analyzer.warning.advanced_installer"]} +} diff --git a/app/analyzer_profiles/system/greenshot.json b/app/analyzer_profiles/system/greenshot.json new file mode 100755 index 0000000..891c3b6 --- /dev/null +++ b/app/analyzer_profiles/system/greenshot.json @@ -0,0 +1,45 @@ +{ + "schema": "assetmanager-analyzer-profile-v1", + "profile_api": 1, + "id": "vendor.greenshot", + "name": "Greenshot", + "version": "1.0.0", + "kind": "vendor", + "stage": "analysis", + "priority": 400, + "match": { + "installer_types": [ + "inno" + ], + "identity_contains_any": [ + "greenshot" + ] + }, + "result": { + "set_if_empty": { + "product_name": "Greenshot", + "manufacturer": "Greenshot" + }, + "set": { + "suppress_browser_default": true, + "process_names_default": "Greenshot.exe", + "start_application_default": true, + "start_executable_default": "C:\\Program Files\\Greenshot\\Greenshot.exe", + "start_arguments_default": "" + } + }, + "metadata": { + "filename_version_regex": "(?i)greenshot[-_ ]installer[-_ ](?P\\d+(?:\\.\\d+){1,3})" + }, + "command": { + "install_arguments": "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP- /ALLUSERS /DIR=\"C:\\Program Files\\Greenshot\" /CLOSEAPPLICATIONS /FORCECLOSEAPPLICATIONS", + "success_codes": [ + 0, + 3010 + ], + "reboot_codes": [ + 3010 + ], + "command_confidence": "high" + } +} diff --git a/app/analyzer_profiles/system/inno.json b/app/analyzer_profiles/system/inno.json new file mode 100755 index 0000000..5657009 --- /dev/null +++ b/app/analyzer_profiles/system/inno.json @@ -0,0 +1,17 @@ +{ + "schema": "assetmanager-analyzer-profile-v1", + "profile_api": 1, + "id": "technology.inno", + "name": "Inno Setup", + "version": "1.0.0", + "kind": "technology", + "stage": "marker", + "priority": 100, + "match": {"markers": [ + {"text": "inno setup setup data", "points": 75, "signal_key": "setup_analyzer.signal.inno_data"}, + {"text": "inno setup", "points": 35, "signal_key": "setup_analyzer.signal.inno"}, + {"text": "innosetup", "points": 20, "signal_key": "setup_analyzer.signal.inno_internal"} + ]}, + "result": {"installer_type": "inno", "installer_label": "Inno Setup", "confidence_floor": 55}, + "command": {"install_arguments": "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP-", "success_codes": [0,3010], "reboot_codes": [3010], "command_confidence": "high"} +} diff --git a/app/analyzer_profiles/system/installshield.json b/app/analyzer_profiles/system/installshield.json new file mode 100755 index 0000000..8d6b7e6 --- /dev/null +++ b/app/analyzer_profiles/system/installshield.json @@ -0,0 +1,10 @@ +{ + "schema": "assetmanager-analyzer-profile-v1", "profile_api": 1, + "id": "technology.installshield", "name": "InstallShield", "version": "1.0.0", "kind": "technology", "stage": "marker", "priority": 100, + "match": {"markers": [ + {"text": "installshield", "points": 80, "signal_key": "setup_analyzer.signal.installshield"}, + {"text": "installscript", "points": 30, "signal_key": "setup_analyzer.signal.installscript"} + ]}, + "result": {"installer_type": "installshield", "installer_label": "InstallShield", "confidence_floor": 55}, + "command": {"install_arguments": "/s /v\"/qn /norestart\"", "success_codes": [0,1641,3010], "reboot_codes": [1641,3010], "command_confidence": "medium", "warning_keys": ["setup_analyzer.warning.installshield"]} +} diff --git a/app/analyzer_profiles/system/nsis.json b/app/analyzer_profiles/system/nsis.json new file mode 100755 index 0000000..82a8889 --- /dev/null +++ b/app/analyzer_profiles/system/nsis.json @@ -0,0 +1,12 @@ +{ + "schema": "assetmanager-analyzer-profile-v1", "profile_api": 1, + "id": "technology.nsis", "name": "NSIS", "version": "1.0.0", "kind": "technology", "stage": "marker", "priority": 100, + "match": {"markers": [ + {"text": "nullsoft install system", "points": 80, "signal_key": "setup_analyzer.signal.nsis_system"}, + {"text": "nullsoftinst", "points": 55, "signal_key": "setup_analyzer.signal.nsis_installer"}, + {"text": "nullsoft", "points": 25, "signal_key": "setup_analyzer.signal.nullsoft"}, + {"text": "nsis", "points": 20, "signal_key": "setup_analyzer.signal.nsis"} + ]}, + "result": {"installer_type": "nsis", "installer_label": "NSIS", "confidence_floor": 55}, + "command": {"install_arguments": "/S", "success_codes": [0,3010], "reboot_codes": [3010], "command_confidence": "high"} +} diff --git a/app/analyzer_profiles/system/pdf24-online.json b/app/analyzer_profiles/system/pdf24-online.json new file mode 100755 index 0000000..67f6286 --- /dev/null +++ b/app/analyzer_profiles/system/pdf24-online.json @@ -0,0 +1,67 @@ +{ + "schema": "assetmanager-analyzer-profile-v1", + "profile_api": 1, + "id": "vendor.pdf24-online", + "name": "PDF24 Creator Online Installer", + "version": "1.0.0", + "kind": "vendor", + "stage": "marker", + "priority": 500, + "match": { + "markers": [ + { + "text": "global\\pdf24installermutex", + "points": 45, + "signal_key": "setup_analyzer.signal.profile_marker" + }, + { + "text": "pdf24 creator installer", + "points": 35, + "signal_key": "setup_analyzer.signal.profile_marker" + }, + { + "text": "/fromsmallinstaller", + "points": 35, + "signal_key": "setup_analyzer.signal.profile_marker" + }, + { + "text": "download.pdf24.org/pdf24-creator-latest-x64.exe", + "points": 20, + "signal_key": "setup_analyzer.signal.profile_marker" + }, + { + "text": "download.pdf24.org/pdf24-creator-latest-arm64.exe", + "points": 10, + "signal_key": "setup_analyzer.signal.profile_marker" + } + ] + }, + "result": { + "installer_type": "pdf24_online", + "installer_label": "PDF24 Creator online installer", + "confidence_floor": 80, + "set": { + "product_name": "PDF24 Creator", + "manufacturer": "geek software GmbH", + "architecture": "x86+x64+arm64", + "architecture_source": "profile", + "product_version": "" + }, + "wrapper": true + }, + "command": { + "install_arguments": "/SILENT", + "success_codes": [ + 0, + 3010 + ], + "reboot_codes": [ + 3010 + ], + "detect_method": "registry_display_name", + "command_confidence": "medium", + "warning_keys": [ + "setup_analyzer.warning.online_bootstrapper" + ] + } +} diff --git a/app/analyzer_profiles/system/squirrel.json b/app/analyzer_profiles/system/squirrel.json new file mode 100755 index 0000000..4974590 --- /dev/null +++ b/app/analyzer_profiles/system/squirrel.json @@ -0,0 +1,10 @@ +{ + "schema": "assetmanager-analyzer-profile-v1", "profile_api": 1, + "id": "technology.squirrel", "name": "Squirrel", "version": "1.0.0", "kind": "technology", "stage": "marker", "priority": 100, + "match": {"markers": [ + {"text": "squirrel", "points": 55, "signal_key": "setup_analyzer.signal.squirrel"}, + {"text": "releasify", "points": 25, "signal_key": "setup_analyzer.signal.squirrel_releasify"} + ]}, + "result": {"installer_type": "squirrel", "installer_label": "Squirrel", "confidence_floor": 55}, + "command": {"install_arguments": "--silent", "success_codes": [0], "reboot_codes": [], "command_confidence": "low", "warning_keys": ["setup_analyzer.warning.squirrel"]} +} diff --git a/app/analyzer_profiles/system/total-commander.json b/app/analyzer_profiles/system/total-commander.json new file mode 100755 index 0000000..3fa5c37 --- /dev/null +++ b/app/analyzer_profiles/system/total-commander.json @@ -0,0 +1,106 @@ +{ + "schema": "assetmanager-analyzer-profile-v1", + "profile_api": 1, + "id": "vendor.total-commander", + "name": "Total Commander", + "version": "1.0.0", + "kind": "vendor", + "stage": "sfx_extracted", + "priority": 500, + "match": { + "extracted_files_all": [ + "INSTALL.INF" + ], + "ini": { + "glob": "INSTALL.INF", + "conditions_all": [ + { + "section": "installation", + "key": "program", + "contains_any": [ + "Total Commander" + ] + }, + { + "section": "installation", + "key": "publisher", + "contains_any": [ + "Ghisler" + ] + } + ] + } + }, + "result": { + "set": { + "installer_type": "totalcmd_sfx", + "installer_label": "Total Commander self-extracting installer", + "confidence": 99, + "architecture_source": "embedded_install_inf", + "embedded_switches": [ + "/A1", + "/AH1" + ], + "warning_keys": [] + }, + "wrapper": true + }, + "metadata": { + "ini_fields": { + "product_name": [ + { + "section": "installation", + "key": "program" + }, + { + "section": "installation", + "key": "progname" + } + ], + "product_version": { + "section": "installation", + "key": "progver" + }, + "manufacturer": { + "section": "installation", + "key": "publisher" + } + }, + "fixed": { + "manufacturer": "Ghisler Software GmbH" + }, + "architecture_from": { + "section": "installation", + "key": "program", + "patterns": [ + { + "regex": "(?:64\\+32|32\\+64|64 \\+ 32|32 \\+ 64)", + "value": "x86+x64" + }, + { + "regex": "64[- ]?bit", + "value": "x64" + }, + { + "regex": "32[- ]?bit", + "value": "x86" + } + ] + }, + "architecture_source": "embedded_install_inf", + "process_name_from": { + "section": "installation", + "key": "updatecheck" + } + }, + "command": { + "install_arguments": "/AH1", + "alternative_install_arguments": "/A1", + "success_codes": [ + 0 + ], + "reboot_codes": [], + "detect_method": "registry_display_name", + "command_confidence": "high" + } +} diff --git a/app/analyzer_profiles/system/vlc.json b/app/analyzer_profiles/system/vlc.json new file mode 100755 index 0000000..57c82a6 --- /dev/null +++ b/app/analyzer_profiles/system/vlc.json @@ -0,0 +1,24 @@ +{ + "schema": "assetmanager-analyzer-profile-v1", + "profile_api": 1, + "id": "vendor.vlc-media-player", + "name": "VLC media player", + "version": "1.0.0", + "kind": "vendor", + "stage": "analysis", + "priority": 300, + "match": { + "installer_types": [ + "nsis" + ], + "filename_regex": [ + "^vlc(?:[-_.].*)?\\.exe$" + ] + }, + "result": { + "set_if_empty": { + "product_name": "VLC media player", + "manufacturer": "VideoLAN" + } + } +} diff --git a/app/analyzer_profiles/system/winrar-sfx.json b/app/analyzer_profiles/system/winrar-sfx.json new file mode 100755 index 0000000..2a4e660 --- /dev/null +++ b/app/analyzer_profiles/system/winrar-sfx.json @@ -0,0 +1,11 @@ +{ + "schema": "assetmanager-analyzer-profile-v1", "profile_api": 1, + "id": "technology.winrar-sfx", "name": "WinRAR SFX", "version": "1.0.0", "kind": "technology", "stage": "marker", "priority": 100, + "match": {"markers": [ + {"text": "__rar_archive_signature__", "points": 90, "signal_key": "setup_analyzer.signal.rar_signature"}, + {"text": "winrar sfx", "points": 75, "signal_key": "setup_analyzer.signal.winrar_sfx"}, + {"text": "rar sfx", "points": 45, "signal_key": "setup_analyzer.signal.rar_sfx"} + ]}, + "result": {"installer_type": "winrar_sfx", "installer_label": "WinRAR SFX", "confidence_floor": 55}, + "command": {"warning_keys": ["setup_analyzer.warning.sfx"]} +} diff --git a/app/analyzer_profiles/system/wix-burn.json b/app/analyzer_profiles/system/wix-burn.json new file mode 100755 index 0000000..0bdcdd2 --- /dev/null +++ b/app/analyzer_profiles/system/wix-burn.json @@ -0,0 +1,12 @@ +{ + "schema": "assetmanager-analyzer-profile-v1", "profile_api": 1, + "id": "technology.wix-burn", "name": "WiX Burn", "version": "1.0.0", "kind": "technology", "stage": "marker", "priority": 100, + "match": {"markers": [ + {"text": "wixburn", "points": 80, "signal_key": "setup_analyzer.signal.wix_burn"}, + {"text": "wixbundle", "points": 55, "signal_key": "setup_analyzer.signal.wix_bundle"}, + {"text": "wixstdba", "points": 45, "signal_key": "setup_analyzer.signal.wix_stdba"}, + {"text": "burn engine", "points": 30, "signal_key": "setup_analyzer.signal.burn_engine"} + ]}, + "result": {"installer_type": "wix_burn", "installer_label": "WiX Burn", "confidence_floor": 55}, + "command": {"install_arguments": "/quiet /norestart", "success_codes": [0,1641,3010], "reboot_codes": [1641,3010], "command_confidence": "high"} +} diff --git a/app/backup.py b/app/backup.py index e9bd126..90ad8c6 100755 --- a/app/backup.py +++ b/app/backup.py @@ -13,11 +13,13 @@ from urllib.parse import urlsplit, urlunsplit from .version import APP_VERSION -BACKUP_DIR = Path(os.getenv("BACKUP_DIR", "/data/backups")) -CONFIG_PATH = Path(os.getenv("APP_CONFIG", "/app/config/config.json")) -APPINFO_PATH = Path(os.getenv("APPINFO_PATH", "/app/config/APPINFO.json")) -UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", "/app/app/static/uploads")) -SOFTWARE_PACKAGE_DIR = Path(os.getenv("SOFTWARE_PACKAGE_DIR", "/app/data/software-packages")) +DATA_ROOT = Path(os.getenv("ASSETMANAGER_DATA_ROOT", "/assetmanager-data")) +BACKUP_DIR = Path(os.getenv("BACKUP_DIR", str(DATA_ROOT / "backups"))) +CONFIG_PATH = Path(os.getenv("APP_CONFIG", str(DATA_ROOT / "config" / "config.json"))) +APPINFO_PATH = Path(os.getenv("APPINFO_PATH", str(DATA_ROOT / "config" / "APPINFO.json"))) +UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", str(DATA_ROOT / "uploads"))) +SOFTWARE_PACKAGE_DIR = Path(os.getenv("SOFTWARE_PACKAGE_DIR", str(DATA_ROOT / "software-packages"))) +ANALYZER_PROFILE_DIR = Path(os.getenv("ANALYZER_PROFILE_DIR", str(DATA_ROOT / "analyzer-profiles"))) BACKUP_INTERVAL_HOURS = max(1, int(os.getenv("BACKUP_INTERVAL_HOURS", "8"))) BACKUP_RETENTION_DAYS = max(1, int(os.getenv("BACKUP_RETENTION_DAYS", "3"))) BACKUP_PREFIX = "assetmanager-backup-" @@ -103,7 +105,8 @@ def system_storage_information(log_dir: Path | None = None) -> dict: "config": _directory_status(CONFIG_PATH.parent), "uploads": _directory_status(UPLOAD_DIR), "software_packages": _directory_status(SOFTWARE_PACKAGE_DIR, create=True), - "logs": _directory_status(log_dir or Path(os.getenv("LOG_DIR", "/app/data/logs"))), + "analyzer_profiles": _directory_status(ANALYZER_PROFILE_DIR, create=True), + "logs": _directory_status(log_dir or Path(os.getenv("LOG_DIR", str(DATA_ROOT / "logs")))), "backups": _directory_status(BACKUP_DIR, create=True), "backup_count": len(backups), "backup_total_size": total_size, @@ -164,6 +167,10 @@ def create_backup(created_by: str = "system", reason: str = "manual") -> dict: target = files_dir / "software-packages" shutil.copytree(SOFTWARE_PACKAGE_DIR, target) included.append("files/software-packages/") + if ANALYZER_PROFILE_DIR.is_dir(): + target = files_dir / "analyzer-profiles" + shutil.copytree(ANALYZER_PROFILE_DIR, target) + included.append("files/analyzer-profiles/") metadata = { "format": 1, @@ -338,6 +345,14 @@ def restore_backup(name: str) -> dict: else: child.unlink() shutil.copytree(files / "software-packages", SOFTWARE_PACKAGE_DIR, dirs_exist_ok=True) + if (files / "analyzer-profiles").is_dir(): + ANALYZER_PROFILE_DIR.mkdir(parents=True, exist_ok=True) + for child in ANALYZER_PROFILE_DIR.iterdir(): + if child.is_dir(): + shutil.rmtree(child) + else: + child.unlink() + shutil.copytree(files / "analyzer-profiles", ANALYZER_PROFILE_DIR, dirs_exist_ok=True) return _metadata(path) diff --git a/app/config.py b/app/config.py index 71810d0..d297279 100755 --- a/app/config.py +++ b/app/config.py @@ -3,7 +3,8 @@ import os from pathlib import Path from typing import Any -CONFIG_PATH = Path(os.getenv("APP_CONFIG", "/app/config.json")) +DATA_ROOT = Path(os.getenv("ASSETMANAGER_DATA_ROOT", "/assetmanager-data")) +CONFIG_PATH = Path(os.getenv("APP_CONFIG", str(DATA_ROOT / "config" / "config.json"))) DEFAULT_CONFIG: dict[str, Any] = { "general": { @@ -42,6 +43,8 @@ DEFAULT_CONFIG: dict[str, Any] = { "automatic_retry_max_age_hours": 12, "automatic_retry_interval_seconds": 60, "automatic_retry_max_retries": 3, + "remote_job_cleanup_enabled": True, + "remote_job_retention_hours": 24, "inventory_exclusion_rules": [] }, "privacy": {}, diff --git a/app/i18n.py b/app/i18n.py index 73976df..b462e3b 100755 --- a/app/i18n.py +++ b/app/i18n.py @@ -667,7 +667,7 @@ BASE_TRANSLATIONS.update({ "duplicates.confirm_apply": ("Save the merge permanently? The following assets will be deleted.", "Zusammenführung endgültig speichern? Die nachfolgenden Assets werden gelöscht."), "duplicates.merge_unique_error": ("The duplicates could not be merged because a unique value is already assigned to another asset.", "Die Duplikate konnten nicht zusammengeführt werden, weil ein eindeutiger Wert bereits einem anderen Asset zugeordnet ist."), "settings.system_information": ("System information", "Systeminformationen"), - "settings.system_information_file_help": ("These values are read from /app/config/APPINFO.json and VERSION and cannot be edited here.", "Diese Angaben werden aus /app/config/APPINFO.json und VERSION gelesen und können hier nicht bearbeitet werden."), + "settings.system_information_file_help": ("These values are read from the persistent APPINFO.json and VERSION and cannot be edited here.", "Diese Angaben werden aus der persistenten APPINFO.json und VERSION gelesen und können hier nicht bearbeitet werden."), "settings.storage_information": ("Storage and runtime", "Speicher und Laufzeit"), "settings.storage_information_help": ("The following paths and states are detected at runtime inside the container.", "Die folgenden Pfade und Zustände werden zur Laufzeit im Container ermittelt."), "settings.database": ("Database", "Datenbank"), @@ -1476,10 +1476,25 @@ BASE_TRANSLATIONS.update({ "setup_analyzer.product_name": ("Product name", "Produktname"), "setup_analyzer.version": ("Version", "Version"), "setup_analyzer.manufacturer": ("Manufacturer", "Hersteller"), - "setup_analyzer.architecture": ("Architecture", "Architektur"), + "setup_analyzer.architecture": ("Target architecture", "Zielarchitektur"), + "setup_analyzer.launcher_architecture": ("Installer launcher architecture", "Architektur des Setup-Launchers"), + "setup_analyzer.architecture_source": ("Target architecture source", "Quelle der Zielarchitektur"), + "setup_analyzer.architecture_source.filename": ("Explicit package filename", "Eindeutiger Paketdateiname"), + "setup_analyzer.architecture_source.package_metadata": ("Package metadata", "Paketmetadaten"), + "setup_analyzer.architecture_source.launcher_requirement": ("64-bit launcher requirement", "64-Bit-Anforderung des Setup-Launchers"), + "setup_analyzer.architecture_source.pe_machine": ("Executable PE architecture", "PE-Architektur der Programmdatei"), + "setup_analyzer.architecture_source.embedded_install_inf": ("Embedded installer metadata", "Eingebettete Installer-Metadaten"), + "setup_analyzer.architecture_source.bootstrapper_targets": ("Architecture-specific bootstrapper download targets", "Architekturspezifische Download-Ziele des Bootstrappers"), + "setup_analyzer.version_source": ("Version source", "Quelle der Version"), + "setup_analyzer.manufacturer_source": ("Manufacturer source", "Quelle des Herstellers"), + "setup_analyzer.metadata_source.package_metadata": ("Package metadata", "Paketmetadaten"), + "setup_analyzer.metadata_source.pe_version": ("Executable VERSIONINFO", "VERSIONINFO der Programmdatei"), + "setup_analyzer.metadata_source.filename": ("Installer filename", "Dateiname des Installers"), + "setup_analyzer.metadata_source.authenticode_signer": ("Authenticode certificate publisher", "Herausgeber des Authenticode-Zertifikats"), "setup_analyzer.installation": ("Installation", "Installation"), "setup_analyzer.arguments": ("Silent arguments", "Silent-Parameter"), "setup_analyzer.recommended_command": ("Recommended command", "Empfohlener Aufruf"), + "setup_analyzer.alternative_command": ("Alternative automatic command", "Alternative automatische Installation"), "setup_analyzer.timeout": ("Timeout in seconds", "Timeout in Sekunden"), "setup_analyzer.run_as": ("Run as", "Ausführen als"), "setup_analyzer.logged_on_user": ("Logged-on user", "Angemeldeter Benutzer"), @@ -1510,8 +1525,11 @@ BASE_TRANSLATIONS.update({ "setup_analyzer.signal.caphyon": ("Caphyon marker", "Caphyon-Kennung"), "setup_analyzer.signal.squirrel": ("Squirrel marker", "Squirrel-Kennung"), "setup_analyzer.signal.squirrel_releasify": ("Squirrel releasify marker", "Squirrel-Releasify-Kennung"), + "setup_analyzer.signal.zip_sfx": ("ZIP-compatible self-extracting archive", "ZIP-kompatibles selbstentpackendes Archiv"), + "setup_analyzer.signal.7zip_signature": ("Embedded 7z archive signature", "Eingebettete 7z-Archivsignatur"), "setup_analyzer.signal.7zip_sfx": ("7-Zip SFX marker", "7-Zip-SFX-Kennung"), "setup_analyzer.signal.7zip_module": ("7-Zip SFX module marker", "7-Zip-SFX-Modulkennung"), + "setup_analyzer.signal.rar_signature": ("Embedded RAR archive signature", "Eingebettete RAR-Archivsignatur"), "setup_analyzer.signal.winrar_sfx": ("WinRAR SFX marker", "WinRAR-SFX-Kennung"), "setup_analyzer.signal.rar_sfx": ("RAR SFX marker", "RAR-SFX-Kennung"), "setup_analyzer.signal.msi_extension": ("MSI file extension", "MSI-Dateiendung"), @@ -1525,15 +1543,33 @@ BASE_TRANSLATIONS.update({ "setup_analyzer.warning.advanced_installer": ("Advanced Installer packages can use project-specific properties. Test the generated command.", "Advanced-Installer-Pakete können projektspezifische Eigenschaften verwenden. Den erzeugten Befehl testen."), "setup_analyzer.warning.squirrel": ("Squirrel behavior is vendor-dependent. Verify silent installation and installation scope.", "Das Verhalten von Squirrel ist herstellerabhängig. Silent-Installation und Installationsbereich prüfen."), "setup_analyzer.warning.sfx": ("SFX archives do not provide one universal silent switch. Inspect the embedded installer or vendor documentation.", "SFX-Archive besitzen keinen einheitlichen Silent-Parameter. Eingebetteten Installer oder Herstellerdokumentation prüfen."), + "setup_analyzer.warning.sfx_embedded_selected": ("The SFX payload was extracted statically and the most likely embedded installer was selected heuristically. Test the generated package before broad deployment.", "Der SFX-Payload wurde statisch entpackt und der wahrscheinlichste eingebettete Installer heuristisch ausgewählt. Das erzeugte Paket vor einer breiten Verteilung testen."), + "setup_analyzer.warning.sfx_tool_missing": ("The SFX container was detected, but no suitable extraction tool is available in the AssetManager container.", "Der SFX-Container wurde erkannt, aber im AssetManager-Container ist kein geeignetes Entpackwerkzeug verfügbar."), + "setup_analyzer.warning.sfx_extract_failed": ("The SFX container was detected but could not be extracted safely.", "Der SFX-Container wurde erkannt, konnte aber nicht sicher entpackt werden."), + "setup_analyzer.warning.sfx_no_installer": ("The SFX payload was extracted, but no supported embedded installer candidate was found.", "Der SFX-Payload wurde entpackt, aber es wurde kein unterstützter eingebetteter Installer-Kandidat gefunden."), "setup_analyzer.warning.unknown": ("No reliable silent command was detected. Review the installer manually before deployment.", "Es wurde kein verlässlicher Silent-Befehl erkannt. Den Installer vor der Verteilung manuell prüfen."), "setup_analyzer.warning.msiinfo": ("Detailed MSI metadata such as ProductCode requires the optional msiinfo utility. Silent MSI command generation still works.", "Detaillierte MSI-Metadaten wie ProductCode benötigen das optionale Werkzeug msiinfo. Die Erzeugung des Silent-MSI-Befehls funktioniert trotzdem."), "setup_analyzer.warning.pefile": ("PE metadata is limited because the pefile Python dependency is not installed.", "PE-Metadaten sind eingeschränkt, weil die Python-Abhängigkeit pefile nicht installiert ist."), + "setup_analyzer.warning.wrapper_architecture": ("The setup launcher architecture differs from the detected target architecture. This is normal for installer stubs such as NSIS or Inno Setup; the target architecture is evaluated separately.", "Die Architektur des Setup-Launchers unterscheidet sich von der erkannten Zielarchitektur. Das ist bei Installer-Stubs wie NSIS oder Inno Setup normal; die Zielarchitektur wird getrennt bewertet."), "setup_analyzer.warning.appx_context": ("MSIX/AppX installation scope depends on the execution context. Test deployment under the same account context used by the job.", "Der Installationsbereich von MSIX/AppX hängt vom Ausführungskontext ab. Die Verteilung im gleichen Kontokontext wie den späteren Job testen."), }) # v0.5.5.77 Software packages and deployment jobs BASE_TRANSLATIONS.update({ + "setup_analyzer.sfx_title": ("SFX / embedded installer analysis", "SFX-/Embedded-Installer-Analyse"), + "setup_analyzer.sfx_container": ("Outer container", "Äußerer Container"), + "setup_analyzer.sfx_status": ("Extraction status", "Entpackstatus"), + "setup_analyzer.sfx_status.success": ("Extracted safely", "Sicher entpackt"), + "setup_analyzer.sfx_status.tool_missing": ("Extraction tool missing", "Entpackwerkzeug fehlt"), + "setup_analyzer.sfx_status.failed": ("Extraction failed", "Entpacken fehlgeschlagen"), + "setup_analyzer.sfx_extractor": ("Extractor", "Entpackwerkzeug"), + "setup_analyzer.sfx_files": ("Files", "Dateien"), + "setup_analyzer.sfx_size": ("Extracted size", "Entpackte Größe"), + "setup_analyzer.sfx_selected": ("Selected embedded installer", "Ausgewählter eingebetteter Installer"), + "setup_analyzer.sfx_candidates": ("Embedded installer candidates", "Eingebettete Installer-Kandidaten"), + "setup_analyzer.sfx_limits": ("SFX safety limits", "SFX-Sicherheitsgrenzen"), + "setup_analyzer.sfx_depth": ("recursion depth {depth}", "Rekursionstiefe {depth}"), "setup_analyzer.suppress_browser": ("Suppress post-install browser launch", "Browser-/Webseiten-Aufruf nach dem Setup unterdrücken"), "setup_analyzer.suppress_browser_help": ("Stops only browser processes that were newly created inside the installer process tree. Existing browser sessions are not touched.", "Beendet nur Browserprozesse, die neu aus dem Prozessbaum des Installers gestartet wurden. Bereits laufende Browser werden nicht beendet."), "setup_analyzer.create_package": ("Create AssetManager package", "AssetManager-Softwarepaket erstellen"), @@ -1575,7 +1611,9 @@ BASE_TRANSLATIONS.update({ "software_packages.delete_jobs": ("Also delete {count} associated software jobs", "Auch {count} zugehörige Softwarejobs löschen"), "software_packages.delete_jobs_required": ("This package is referenced by {count} software jobs. Confirm deletion of the associated jobs first.", "Dieses Paket wird von {count} Softwarejobs verwendet. Bestätige zuerst das Löschen der zugehörigen Jobs."), "software_packages.delete_confirm": ('Really delete software package "{package}"?', 'Softwarepaket "{package}" wirklich löschen?'), + "software_packages.delete_confirm_with_jobs": ('Really delete software package "{package}"? {count} associated software jobs will also be deleted.', 'Softwarepaket "{package}" wirklich löschen? Dabei werden auch {count} zugehörige Softwarejobs gelöscht.'), "software_packages.delete_button": ("Delete package", "Paket löschen"), + "software_packages.delete_button_short": ("Delete", "Löschen"), "software_packages.deleted": ('Software package "{package}" was deleted.', 'Softwarepaket "{package}" wurde gelöscht.'), "jobs.type.software_deployment": ("Software deployment", "Softwareverteilung"), "jobs.action.reinstall": ("Reinstall", "Neu installieren"), @@ -1588,11 +1626,11 @@ BASE_TRANSLATIONS.update({ # v0.5.5.80 software package process control BASE_TRANSLATIONS.update({ "setup_analyzer.process_names": ("Processes to close before install/uninstall", "Vor Installation/Deinstallation zu beendende Prozesse"), - "setup_analyzer.process_names_help": ("Comma-, semicolon- or line-separated executable names. Known applications can be suggested automatically, for example Greenshot.exe.", "Komma-, Semikolon- oder zeilengetrennte EXE-Namen. Bei bekannten Anwendungen kann der Setup-Analyzer automatisch einen Vorschlag eintragen, z. B. Greenshot.exe."), + "setup_analyzer.process_names_help": ("Comma-, semicolon- or line-separated executable names. Known applications can be suggested automatically, for example ExampleApp.exe.", "Komma-, Semikolon- oder zeilengetrennte EXE-Namen. Bei bekannten Anwendungen kann der Setup-Analyzer automatisch einen Vorschlag eintragen, z. B. ExampleApp.exe."), "software_packages.process_control": ("Process control", "Prozesssteuerung"), "software_packages.process_control_help": ("These processes are closed before installation and uninstallation. AssetManager first requests a normal close and can then force termination if the process is still running.", "Diese Prozesse werden vor Installation und Deinstallation beendet. AssetManager fordert zuerst ein normales Beenden an und kann den Prozess anschließend zwangsweise beenden, wenn er weiterhin läuft."), "software_packages.process_names": ("Processes to close", "Zu beendende Prozesse"), - "software_packages.process_names_help": ("Enter executable names such as Greenshot.exe. Multiple names can be separated by comma, semicolon or line break.", "EXE-Namen wie Greenshot.exe eintragen. Mehrere Namen können durch Komma, Semikolon oder Zeilenumbruch getrennt werden."), + "software_packages.process_names_help": ("Enter executable names such as ExampleApp.exe. Multiple names can be separated by comma, semicolon or line break.", "EXE-Namen wie ExampleApp.exe eintragen. Mehrere Namen können durch Komma, Semikolon oder Zeilenumbruch getrennt werden."), "software_packages.process_grace_seconds": ("Grace period before force close (seconds)", "Wartezeit vor erzwungenem Beenden (Sekunden)"), "software_packages.force_close": ("Force termination if the process is still running", "Prozess zwangsweise beenden, wenn er weiterhin läuft"), "software_packages.process_control_saved": ("Process control was saved.", "Prozesssteuerung wurde gespeichert."), @@ -1602,7 +1640,7 @@ BASE_TRANSLATIONS.update({ # v0.5.5.82 post-install application start BASE_TRANSLATIONS.update({ "setup_analyzer.start_application": ("Start application after successful installation", "Anwendung nach erfolgreicher Installation starten"), - "setup_analyzer.start_application_help": ("Starts the configured application in the active interactive user session after installation detection succeeds. This option is preselected for known applications such as Greenshot.", "Startet die konfigurierte Anwendung nach erfolgreicher Installationserkennung in der aktiven interaktiven Benutzersitzung. Bei bekannten Anwendungen wie Greenshot wird die Option automatisch vorgeschlagen."), + "setup_analyzer.start_application_help": ("Starts the configured application in the active interactive user session after installation detection succeeds. This option is preselected for known applications with a matching analyzer profile.", "Startet die konfigurierte Anwendung nach erfolgreicher Installationserkennung in der aktiven interaktiven Benutzersitzung. Bei bekannten Anwendungen mit passendem Analyzer-Profil wird die Option automatisch vorgeschlagen."), "setup_analyzer.start_executable": ("Application executable", "Programmdatei"), "setup_analyzer.start_arguments": ("Application arguments", "Programmparameter"), "setup_analyzer.start_only_if_user_logged_on": ("Start only when an interactive user is logged on", "Nur starten, wenn ein interaktiver Benutzer angemeldet ist"), @@ -1612,7 +1650,7 @@ BASE_TRANSLATIONS.update({ "software_packages.post_install_help": ("Optionally start an application after install or reinstall. Because software jobs run as SYSTEM, AssetManager launches the application explicitly in the active interactive user session.", "Optional eine Anwendung nach Installation oder Neuinstallation starten. Da Softwarejobs unter SYSTEM laufen, startet AssetManager die Anwendung gezielt in der aktiven interaktiven Benutzersitzung."), "software_packages.start_application": ("Start application automatically", "Anwendung automatisch starten"), "software_packages.start_executable": ("Application executable", "Programmdatei"), - "software_packages.start_executable_help": ("Use the full executable path, for example C:\\Program Files\\Greenshot\\Greenshot.exe. System environment variables such as %ProgramFiles% are supported.", "Vollständigen Pfad zur EXE angeben, z. B. C:\\Program Files\\Greenshot\\Greenshot.exe. System-Umgebungsvariablen wie %ProgramFiles% werden unterstützt."), + "software_packages.start_executable_help": ("Use the full executable path, for example C:\\Program Files\\ExampleApp\\ExampleApp.exe. System environment variables such as %ProgramFiles% are supported.", "Vollständigen Pfad zur EXE angeben, z. B. C:\\Program Files\\ExampleApp\\ExampleApp.exe. System-Umgebungsvariablen wie %ProgramFiles% werden unterstützt."), "software_packages.start_arguments": ("Application arguments", "Programmparameter"), "software_packages.start_only_if_user_logged_on": ("Start only when an interactive user is logged on", "Nur starten, wenn ein interaktiver Benutzer angemeldet ist"), "software_packages.start_only_if_user_logged_on_help": ("Without an active user session the start is skipped. This does not fail the installation job.", "Ohne aktive Benutzersitzung wird der Start übersprungen. Der Installationsjob wird dadurch nicht als fehlgeschlagen gewertet."), @@ -1620,3 +1658,71 @@ BASE_TRANSLATIONS.update({ "software_packages.post_install_saved": ("Post-install settings were saved.", "Einstellungen nach der Installation wurden gespeichert."), "software_packages.post_install_error": ("Post-install settings could not be saved: {error}", "Einstellungen nach der Installation konnten nicht gespeichert werden: {error}"), }) + +# v0.5.5.90 analyzer profiles and portable package import/export +BASE_TRANSLATIONS.update({ + "setup_profiles.manage": ("Analyzer profiles", "Analyzer-Profile"), + "setup_profiles.title": ("Setup Analyzer profiles", "Setup-Analyzer-Profile"), + "setup_profiles.subtitle": ("Manage declarative detection profiles without changing AssetManager code.", "Deklarative Erkennungsprofile verwalten, ohne den AssetManager-Code zu ändern."), + "setup_profiles.back": ("Back to Setup Analyzer", "Zurück zum Setup-Analyzer"), + "setup_profiles.import_title": ("Import analyzer profile", "Analyzer-Profil importieren"), + "setup_profiles.import_help": (".amprofile bundles contain only declarative JSON rules and no executable plugin code.", ".amprofile-Pakete enthalten ausschließlich deklarative JSON-Regeln und keinen ausführbaren Plugin-Code."), + "setup_profiles.file": ("Profile file", "Profildatei"), + "setup_profiles.source": ("Source", "Quelle"), + "setup_profiles.source.system": ("System", "System"), + "setup_profiles.source.community": ("Community", "Community"), + "setup_profiles.source.local": ("Local", "Lokal"), + "setup_profiles.import_button": ("Import profile", "Profil importieren"), + "setup_profiles.installed_title": ("Installed analyzer profiles", "Installierte Analyzer-Profile"), + "setup_profiles.name": ("Profile", "Profil"), + "setup_profiles.id": ("Profile ID", "Profil-ID"), + "setup_profiles.kind": ("Type", "Typ"), + "setup_profiles.stage": ("Analysis stage", "Analysestufe"), + "setup_profiles.export_button": ("Export", "Exportieren"), + "setup_profiles.enable": ("Enable", "Aktivieren"), + "setup_profiles.disable": ("Disable", "Deaktivieren"), + "setup_profiles.delete_confirm": ('Really delete analyzer profile "{profile}"?', 'Analyzer-Profil "{profile}" wirklich löschen?'), + "setup_profiles.none": ("No analyzer profiles are installed.", "Es sind keine Analyzer-Profile installiert."), + "setup_profiles.repository_title": ("Community profile repository", "Community-Profil-Repository"), + "setup_profiles.repository_url": ("Repository:", "Repository:"), + "setup_profiles.repository_load": ("Load repository catalog", "Repository-Katalog laden"), + "setup_profiles.repository_install": ("Install profile", "Profil installieren"), + "setup_profiles.repository_empty": ("The repository contains no profiles.", "Das Repository enthält keine Profile."), + "setup_profiles.repository_not_configured": ("No analyzer-profile repository is configured yet. Configure an HTTPS index URL with ANALYZER_PROFILE_REPOSITORY_URL.", "Es ist noch kein Analyzer-Profil-Repository konfiguriert. Eine HTTPS-Index-URL kann mit ANALYZER_PROFILE_REPOSITORY_URL hinterlegt werden."), + "setup_profiles.matched_profile": ("Matched analyzer profile", "Verwendetes Analyzer-Profil"), + "setup_profiles.profile_source": ("Profile source", "Profilquelle"), + "software_packages.import_title": ("Import software package", "Softwarepaket importieren"), + "software_packages.import_help": ("Import an AssetManager .ampkg package or a compatible package ZIP exported by the Setup Analyzer.", "Ein AssetManager-.ampkg-Paket oder ein kompatibles, vom Setup-Analyzer exportiertes Paket-ZIP importieren."), + "software_packages.import_file": ("Package file", "Paketdatei"), + "software_packages.import_script_warning": ("I understand that imported software packages can contain executable PowerShell scripts which will run on managed devices.", "Mir ist bewusst, dass importierte Softwarepakete ausführbare PowerShell-Skripte enthalten können, die auf verwalteten Geräten ausgeführt werden."), + "software_packages.import_button": ("Import package", "Paket importieren"), + "software_packages.import_confirm_required": ("Confirm that imported packages can contain executable scripts.", "Bestätige, dass importierte Pakete ausführbare Skripte enthalten können."), + "software_packages.import_invalid_type": ("Select an AssetManager .ampkg or compatible .zip package.", "Wähle ein AssetManager-.ampkg- oder kompatibles .zip-Paket aus."), + "software_packages.import_failed": ("Software package import failed: {error}", "Softwarepaket konnte nicht importiert werden: {error}"), + "software_packages.imported": ('Software package "{package}" was imported.', 'Softwarepaket "{package}" wurde importiert.'), + "software_packages.export_button": ("Export", "Exportieren"), + "software_packages.export_failed": ("Software package export failed: {error}", "Softwarepaket konnte nicht exportiert werden: {error}"), +}) + +BASE_TRANSLATIONS.update({ + "setup_analyzer.signal.profile_marker": ("Analyzer profile marker matched", "Analyzer-Profil-Merkmal erkannt"), + "setup_analyzer.warning.online_bootstrapper": ("This profile identifies a network-dependent online bootstrapper. The installed version can be dynamic; prefer a version-pinned offline package for reproducible managed deployment when available.", "Dieses Profil erkennt einen netzwerkabhängigen Online-Bootstrapper. Die installierte Version kann dynamisch sein; für reproduzierbare Softwareverteilung sollte nach Möglichkeit ein versionsgebundenes Offline-Paket verwendet werden."), +}) +BASE_TRANSLATIONS.update({ + "software_packages.import_profile": ("Also import an analyzer profile embedded in the package as a Community profile", "Ein im Paket enthaltenes Analyzer-Profil ebenfalls als Community-Profil importieren"), +}) + + +# v0.5.5.90 file-picker localization and remote client job cleanup +BASE_TRANSLATIONS.update({ + "common.choose_file": ("Choose file", "Datei auswählen"), + "common.no_file_selected": ("No file selected", "Keine Datei ausgewählt"), + "software.settings.remote_cleanup_title": ("Client job files", "Jobdateien auf Clients"), + "software.settings.remote_cleanup_help": ("Automatically remove stale AssetManager job directories from managed clients. Cleanup runs about once per hour for online clients and additionally before a new file-based job is dispatched.", "Entfernt veraltete AssetManager-Jobverzeichnisse auf verwalteten Clients automatisch. Die Bereinigung läuft bei erreichbaren Clients etwa einmal pro Stunde und zusätzlich vor dem Versand eines neuen dateibasierten Jobs."), + "software.settings.remote_cleanup_badge": ("Client cleanup", "Client-Bereinigung"), + "software.settings.remote_cleanup_enabled": ("Automatically remove stale client job files", "Veraltete Jobdateien auf Clients automatisch löschen"), + "software.settings.remote_cleanup_enabled_help": ("Only AssetManager job directories named JobID-Attempt below the dedicated job root are considered. Current job files are never removed.", "Berücksichtigt werden ausschließlich AssetManager-Jobverzeichnisse im Schema JobID-Versuch unterhalb des vorgesehenen Jobordners. Dateien des aktuellen Jobs werden niemals entfernt."), + "software.settings.remote_cleanup_hours": ("Retention on client in hours", "Aufbewahrung auf dem Client in Stunden"), + "software.settings.remote_cleanup_hours_help": ("Job directories whose creation time is older than this value are treated as stale. Active jobs are excluded. Default: 24 hours (1 day).", "Jobverzeichnisse, deren Erstellzeit älter als dieser Wert ist, gelten als veraltet. Aktive Jobs werden ausgeschlossen. Standard: 24 Stunden (1 Tag)."), + "software.settings.remote_cleanup_paths": ("Managed job roots", "Verwaltete Jobpfade"), +}) diff --git a/app/main.py b/app/main.py index 19c22c7..8671bb6 100755 --- a/app/main.py +++ b/app/main.py @@ -48,7 +48,7 @@ from .config import load_config, public_config, save_config from .meshcentral import synchronize, fetch_device_summaries_for_linking from .migrations import apply_lightweight_migrations from .i18n import seed_i18n, translate, dictionary as translation_dictionary, languages as i18n_languages, clear_translation_cache -from .software_control import token_hash, detect_platform, execute_job, build_registry_user_script +from .software_control import token_hash, detect_platform, execute_job, build_registry_user_script, cleanup_stale_remote_job_directories from .job_state import backfill_asset_job_states, filter_state_key, sync_asset_job_state from .presence import mesh_presence_loop from .version import APP_VERSION @@ -56,19 +56,20 @@ from .privacy import merge_privacy_settings, localized_privacy_settings, normali from .privacy_retention import check_retention_category, delete_retention_category, append_deletion_audit, deletion_audit_tail, IMPLEMENTED_RETENTION_KEYS from .backup import (BACKUP_DIR, BACKUP_INTERVAL_HOURS, BACKUP_RETENTION_DAYS, backup_path, create_backup, delete_backup, list_backups, restore_backup, store_uploaded_backup, automatic_backup_loop, system_storage_information) from .setup_analyzer import register_setup_analyzer -from .software_packages import delete_package_storage, human_size, load_package_manifest, package_execution_timeout_seconds, package_summary, update_package_post_install, update_package_process_control +from .software_packages import delete_package_storage, human_size, load_package_manifest, package_execution_timeout_seconds, package_summary, update_package_post_install, update_package_process_control, export_package_bundle, import_package_bundle, PACKAGE_IMPORT_MAX_MB from openpyxl import Workbook, load_workbook from openpyxl.styles import Font, PatternFill, Alignment BASE_DIR = Path(__file__).resolve().parent -UPLOAD_DIR = BASE_DIR / "static" / "uploads" +DATA_ROOT = Path(os.getenv("ASSETMANAGER_DATA_ROOT", "/assetmanager-data")) +UPLOAD_DIR = Path(os.getenv("UPLOAD_DIR", str(DATA_ROOT / "uploads"))) UPLOAD_DIR.mkdir(parents=True, exist_ok=True) -STANDARD_IMAGE_DIR = Path(os.getenv("STANDARD_IMAGE_DIR", str(BASE_DIR / "static" / "uploads" / "library"))) +STANDARD_IMAGE_DIR = Path(os.getenv("STANDARD_IMAGE_DIR", str(UPLOAD_DIR / "library"))) STANDARD_IMAGE_DIR.mkdir(parents=True, exist_ok=True) STANDARD_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif"} -LOG_DIR = Path(os.getenv("SYNC_LOG_DIR", "/app/data/logs/sync")) +LOG_DIR = Path(os.getenv("SYNC_LOG_DIR", str(DATA_ROOT / "logs" / "sync"))) LOG_DIR.mkdir(parents=True, exist_ok=True) -APP_LOG_DIR = Path(os.getenv("APP_LOG_DIR", "/app/data/logs")) +APP_LOG_DIR = Path(os.getenv("APP_LOG_DIR", str(DATA_ROOT / "logs"))) APP_LOG_DIR.mkdir(parents=True, exist_ok=True) SOFTWARE_CALLBACK_DEBUG_LOG = APP_LOG_DIR / "software-callback-debug.log" _SOFTWARE_LOG_LOCK = threading.Lock() @@ -226,6 +227,9 @@ callback_app = FastAPI( _session_cfg = load_config().get("authentication", {}) _session_env = _session_cfg.get("session_secret_env", "SESSION_SECRET") _session_secret = os.getenv(_session_env) or os.getenv("SESSION_SECRET") or "assetmanager-change-this-session-secret" +# Persistent uploads live outside the application image in Docker deployments. +# Mount this route before /static so existing /static/uploads/... URLs remain valid. +app.mount("/static/uploads", StaticFiles(directory=UPLOAD_DIR), name="uploads") app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static") templates = Jinja2Templates(directory=BASE_DIR / "templates") templates.env.globals["application_config"] = load_config @@ -247,11 +251,12 @@ def application_version() -> str: templates.env.globals["application_version"] = application_version +templates.env.globals["application_info_path"] = lambda: str(Path(os.getenv("APPINFO_PATH", str(DATA_ROOT / "config" / "APPINFO.json")))) def application_info() -> dict[str, str]: """Read persistent application metadata. - Primary file: /app/config/APPINFO.json (or APPINFO_PATH). + Primary file: APPINFO_PATH below the persistent AssetManager data root. A legacy APPINFO.json is migrated once when possible. """ defaults = { @@ -259,7 +264,7 @@ def application_info() -> dict[str, str]: "contact": "", "website": "", "repository": "", "license": "", "copyright": "", "description": "", } - persistent = Path(os.getenv("APPINFO_PATH", "/app/config/APPINFO.json")) + persistent = Path(os.getenv("APPINFO_PATH", str(DATA_ROOT / "config" / "APPINFO.json"))) legacy_candidates = [ Path("/app/data/config/APPINFO.json"), # path used by v0.3.14.2 BASE_DIR.parent / "APPINFO.json", # /app/APPINFO.json @@ -401,6 +406,102 @@ SOFTWARE_TIMEOUT_STOP_EVENT = threading.Event() SOFTWARE_TIMEOUT_THREAD: threading.Thread | None = None SOFTWARE_JOB_TERMINAL_STATES = {"success", "failed", "partial", "timeout", "cancelled"} SOFTWARE_JOB_AUTOMATIC_RETRY_STATES = {"failed", "partial", "timeout", "sent"} +REMOTE_JOB_CLEANUP_INTERVAL_SECONDS = 3600 +_REMOTE_JOB_CLEANUP_LAST_RUN_MONOTONIC = 0.0 + + +def _remote_job_cleanup_settings() -> tuple[bool, int]: + settings = _software_settings() + enabled = bool(settings.get("remote_job_cleanup_enabled", True)) + try: + retention_hours = int(settings.get("remote_job_retention_hours", 24) or 24) + except (TypeError, ValueError): + retention_hours = 24 + return enabled, max(1, min(retention_hours, 8760)) + + +def _active_remote_job_directory_names(db: Session, asset_id: int) -> list[str]: + rows = ( + db.query(SoftwareJob.id, SoftwareJob.attempt_count) + .filter( + SoftwareJob.asset_id == asset_id, + SoftwareJob.status.notin_(SOFTWARE_JOB_TERMINAL_STATES), + SoftwareJob.attempt_count > 0, + ) + .all() + ) + return [f"{job_id}-{int(attempt_count or 0)}" for job_id, attempt_count in rows if int(attempt_count or 0) > 0] + + +def _run_remote_job_cleanup_maintenance() -> None: + enabled, retention_hours = _remote_job_cleanup_settings() + if not enabled: + return + + runtime_config = load_config() + cfg = runtime_config.get("meshcentral", {}) + password_env = str(cfg.get("password_env") or "MESHCENTRAL_PASSWORD") + password = os.getenv(password_env, "") + if not password: + logger.warning("Remote client job cleanup skipped because %s is not set", password_env) + return + + db = SessionLocal() + try: + assets = ( + db.query(Asset) + .join(SoftwareJob, SoftwareJob.asset_id == Asset.id) + .filter( + Asset.mesh_node_id.isnot(None), + Asset.mesh_online.is_(True), + ) + .distinct() + .order_by(Asset.id) + .all() + ) + if not assets: + return + + cleaned_clients = 0 + failed_clients = 0 + for asset in assets: + platform = detect_platform(asset) + if platform not in {"windows", "linux"}: + continue + excluded = _active_remote_job_directory_names(db, asset.id) + try: + result = cleanup_stale_remote_job_directories( + cfg, + asset, + password, + platform, + retention_hours, + min(max(30, int(cfg.get("timeout_seconds") or 120)), 120), + excluded, + ) + output = ((result.stdout or "") + "\n" + (result.stderr or "")).strip() + if result.returncode == 0: + cleaned_clients += 1 + if output: + logger.info("Remote job cleanup asset=%s (%s): %s", asset.id, asset.name, output.replace("\n", " | ")) + else: + failed_clients += 1 + logger.warning( + "Remote job cleanup failed asset=%s (%s) rc=%s: %s", + asset.id, asset.name, result.returncode, output, + ) + except Exception as exc: + failed_clients += 1 + logger.warning("Remote job cleanup exception asset=%s (%s): %s", asset.id, asset.name, exc) + + if cleaned_clients or failed_clients: + logger.info( + "Remote client job cleanup cycle completed: clients=%s failed=%s retention_hours=%s", + cleaned_clients, failed_clients, retention_hours, + ) + finally: + db.close() + def _configured_callback_worker_count() -> int: @@ -555,6 +656,18 @@ def _software_job_timeout_loop(stop_event: threading.Event) -> None: finally: db.close() + global _REMOTE_JOB_CLEANUP_LAST_RUN_MONOTONIC + now_monotonic = time.monotonic() + if ( + _REMOTE_JOB_CLEANUP_LAST_RUN_MONOTONIC <= 0 + or now_monotonic - _REMOTE_JOB_CLEANUP_LAST_RUN_MONOTONIC >= REMOTE_JOB_CLEANUP_INTERVAL_SECONDS + ): + _REMOTE_JOB_CLEANUP_LAST_RUN_MONOTONIC = now_monotonic + try: + _run_remote_job_cleanup_maintenance() + except Exception: + logger.exception("Remote client job cleanup maintenance failed") + for job_id, token, callback_base in queued: threading.Thread( target=execute_job, @@ -5929,16 +6042,88 @@ def software_packages_page(request: Request, db: Session = Depends(get_db)): .order_by(func.lower(SoftwarePackage.name), SoftwarePackage.id) .all() ) + package_ids = [package.id for package in packages] + job_counts: dict[int, int] = {} + if package_ids: + job_counts = { + int(package_id): int(count or 0) + for package_id, count in ( + db.query(SoftwareJob.package_id, func.count(SoftwareJob.id)) + .filter(SoftwareJob.package_id.in_(package_ids)) + .group_by(SoftwareJob.package_id) + .all() + ) + } + package_rows = [] + for package in packages: + row = package_summary(package) + row["job_count"] = job_counts.get(package.id, 0) + package_rows.append(row) return templates.TemplateResponse( "software_packages.html", { "request": request, - "package_rows": [package_summary(package) for package in packages], + "package_rows": package_rows, "human_size": human_size, }, ) +@app.post("/software/packages/import") +async def software_package_import(request: Request, package_file: UploadFile = File(...), confirm_scripts: str | None = Form(None), import_profile: str | None = Form(None), db: Session = Depends(get_db)): + _require_admin(request) + if str(confirm_scripts or "").strip().lower() not in {"1", "true", "yes", "on"}: + message = _translate_request(request, "software_packages.import_confirm_required", "Confirm that imported packages can contain executable scripts.") + return RedirectResponse("/software/packages?toast_error=" + quote(message), status_code=303) + suffix = Path(package_file.filename or "package.ampkg").suffix.lower() + if suffix not in {".ampkg", ".zip"}: + await package_file.close() + message = _translate_request(request, "software_packages.import_invalid_type", "Select an AssetManager .ampkg or compatible .zip package.") + return RedirectResponse("/software/packages?toast_error=" + quote(message), status_code=303) + temporary_path = None + try: + with tempfile.NamedTemporaryFile(prefix="assetmanager-package-import-", suffix=suffix, delete=False) as handle: + temporary_path = Path(handle.name) + total_bytes = 0 + limit_bytes = PACKAGE_IMPORT_MAX_MB * 1024 * 1024 + while True: + chunk = await package_file.read(1024 * 1024) + if not chunk: + break + total_bytes += len(chunk) + if total_bytes > limit_bytes: + raise ValueError(f"Package bundle exceeds import size limit ({PACKAGE_IMPORT_MAX_MB} MB).") + handle.write(chunk) + package = import_package_bundle(db, temporary_path, source_name=package_file.filename or "", import_profile=str(import_profile or "").strip().lower() in {"1", "true", "yes", "on"}) + except Exception as exc: + logger.exception("Software package import failed") + message = _translate_request(request, "software_packages.import_failed", "Software package import failed: {error}", error=str(exc)) + return RedirectResponse("/software/packages?toast_error=" + quote(message), status_code=303) + finally: + await package_file.close() + if temporary_path: + temporary_path.unlink(missing_ok=True) + message = _translate_request(request, "software_packages.imported", 'Software package "{package}" was imported.', package=package.name) + return RedirectResponse(f"/software/packages/{package.id}?toast_success=" + quote(message), status_code=303) + + +@app.get("/software/packages/{package_id}/export") +def software_package_export(package_id: int, request: Request, db: Session = Depends(get_db)): + _require_admin(request) + package = db.get(SoftwarePackage, package_id) + if not package or package.package_type != "deployment": + raise HTTPException(404, _translate_request(request, "software_packages.not_found", "Software package not found.")) + try: + data = export_package_bundle(package.id, APP_VERSION) + manifest = load_package_manifest(package.id) + except Exception as exc: + raise HTTPException(500, _translate_request(request, "software_packages.export_failed", "Software package export failed: {error}", error=str(exc))) from exc + base = re.sub(r"[^A-Za-z0-9._+-]+", "-", str(manifest.get("name") or package.name)).strip("-.") or f"package-{package.id}" + version = re.sub(r"[^A-Za-z0-9._+-]+", "-", str(manifest.get("version") or "")).strip("-.") + filename = f"{base}-{version}.ampkg" if version else f"{base}.ampkg" + return StreamingResponse(io.BytesIO(data), media_type="application/zip", headers={"Content-Disposition": f'attachment; filename="{filename}"'}) + + @app.get("/software/packages/{package_id}") def software_package_page(package_id: int, request: Request, db: Session = Depends(get_db)): _require_admin(request) @@ -6078,6 +6263,7 @@ async def software_package_delete(package_id: int, request: Request, db: Session form = await request.form() delete_jobs = str(form.get("delete_jobs") or "").strip().lower() in {"1", "true", "yes", "on"} + return_to = str(form.get("return_to") or "detail").strip().lower() job_ids = [row[0] for row in db.query(SoftwareJob.id).filter(SoftwareJob.package_id == package.id).all()] if job_ids and not delete_jobs: message = _translate_request( @@ -6086,8 +6272,9 @@ async def software_package_delete(package_id: int, request: Request, db: Session "This package is referenced by {count} software jobs. Confirm deletion of the associated jobs first.", count=len(job_ids), ) + redirect_path = "/software/packages" if return_to == "overview" else f"/software/packages/{package_id}" return RedirectResponse( - f"/software/packages/{package_id}?toast_error=" + quote(message), + redirect_path + "?toast_error=" + quote(message), status_code=303, ) @@ -8781,6 +8968,8 @@ def settings_software_save( automatic_retry_max_age_hours: int = Form(12), automatic_retry_interval_seconds: int = Form(60), automatic_retry_max_retries: int = Form(3), + remote_job_cleanup_enabled: str | None = Form(None), + remote_job_retention_hours: int = Form(24), inventory_exclusion_platform: list[str] = Form(default=[]), inventory_exclusion_name: list[str] = Form(default=[]), ): @@ -8814,11 +9003,13 @@ def settings_software_save( "automatic_retry_max_age_hours": max(1, min(int(automatic_retry_max_age_hours), 168)), "automatic_retry_interval_seconds": max(10, min(int(automatic_retry_interval_seconds), 86400)), "automatic_retry_max_retries": max(1, min(int(automatic_retry_max_retries), 10)), + "remote_job_cleanup_enabled": remote_job_cleanup_enabled == "on", + "remote_job_retention_hours": max(1, min(int(remote_job_retention_hours), 8760)), "inventory_exclusion_rules": submitted_exclusion_rules, }) save_config({"software": software}) _software_debug_log( - f"SETTINGS saved | dispatch_delay_seconds={software['dispatch_delay_seconds']} | callback_base_url={callback_base_url or ''} | callback_timeout_minutes={software['callback_timeout_minutes']} | callback_worker_count={software['callback_worker_count']} | automatic_retry_enabled={software['automatic_retry_enabled']} | automatic_retry_max_age_hours={software['automatic_retry_max_age_hours']} | automatic_retry_interval_seconds={software['automatic_retry_interval_seconds']} | automatic_retry_max_retries={software['automatic_retry_max_retries']} | inventory_exclusion_rules={len(submitted_exclusion_rules)} | verify_tls={software['callback_test_verify_tls']}", + f"SETTINGS saved | dispatch_delay_seconds={software['dispatch_delay_seconds']} | callback_base_url={callback_base_url or ''} | callback_timeout_minutes={software['callback_timeout_minutes']} | callback_worker_count={software['callback_worker_count']} | automatic_retry_enabled={software['automatic_retry_enabled']} | automatic_retry_max_age_hours={software['automatic_retry_max_age_hours']} | automatic_retry_interval_seconds={software['automatic_retry_interval_seconds']} | automatic_retry_max_retries={software['automatic_retry_max_retries']} | remote_job_cleanup_enabled={software['remote_job_cleanup_enabled']} | remote_job_retention_hours={software['remote_job_retention_hours']} | inventory_exclusion_rules={len(submitted_exclusion_rules)} | verify_tls={software['callback_test_verify_tls']}", force=True, ) return RedirectResponse( diff --git a/app/meshcentral.py b/app/meshcentral.py index d3769f7..36f2108 100755 --- a/app/meshcentral.py +++ b/app/meshcentral.py @@ -177,7 +177,8 @@ def _run_meshctrl( # stdout/stderr bewusst direkt in Dateien schreiben. Dadurch umgehen wir # possible pipe or buffer limits with very large MeshCtrl JSON output. - diagnostic_dir = Path(os.getenv("DIAGNOSTIC_DIR", "/app/data/logs/diagnostics")) + data_root = Path(os.getenv("ASSETMANAGER_DATA_ROOT", "/assetmanager-data")) + diagnostic_dir = Path(os.getenv("DIAGNOSTIC_DIR", str(data_root / "logs" / "diagnostics"))) diagnostic_dir.mkdir(parents=True, exist_ok=True) timestamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f") mode = "details" if include_details else "basic" diff --git a/app/presence.py b/app/presence.py index 6524f33..7599d43 100755 --- a/app/presence.py +++ b/app/presence.py @@ -86,7 +86,8 @@ def refresh_mesh_presence() -> dict[str, int]: if result.returncode != 0: raise RuntimeError((result.stderr or result.stdout or "MeshCentral presence query failed").strip()) - diagnostic_dir = Path(os.getenv("DIAGNOSTIC_DIR", "/app/data/logs/diagnostics")) + data_root = Path(os.getenv("ASSETMANAGER_DATA_ROOT", "/assetmanager-data")) + diagnostic_dir = Path(os.getenv("DIAGNOSTIC_DIR", str(data_root / "logs" / "diagnostics"))) presence_error_path = diagnostic_dir / "meshctrl-presence-last-error.stdout.json" try: devices = _extract_devices(_parse_json_output(result.stdout)) diff --git a/app/privacy.py b/app/privacy.py index 4b5a85a..7e63580 100755 --- a/app/privacy.py +++ b/app/privacy.py @@ -7,7 +7,8 @@ from typing import Any import json import os -PRIVACY_AUDIT_LOG = Path(os.getenv("PRIVACY_AUDIT_LOG", "/app/data/logs/privacy-policy-audit.log")) +DATA_ROOT = Path(os.getenv("ASSETMANAGER_DATA_ROOT", "/assetmanager-data")) +PRIVACY_AUDIT_LOG = Path(os.getenv("PRIVACY_AUDIT_LOG", str(DATA_ROOT / "logs" / "privacy-policy-audit.log"))) DEFAULT_PURPOSES = [ "Inventory of company computers and servers", diff --git a/app/privacy_retention.py b/app/privacy_retention.py index 7d03577..66dd4c0 100755 --- a/app/privacy_retention.py +++ b/app/privacy_retention.py @@ -12,8 +12,9 @@ from sqlalchemy import cast, func, or_, Text from .database import SessionLocal from .models import SoftwareJob -PRIVACY_DELETION_LOG = Path(os.getenv("PRIVACY_DELETION_LOG", "/app/data/logs/privacy-deletion-audit.log")) -APP_LOG_DIR = Path(os.getenv("APP_LOG_DIR", "/app/data/logs")) +DATA_ROOT = Path(os.getenv("ASSETMANAGER_DATA_ROOT", "/assetmanager-data")) +PRIVACY_DELETION_LOG = Path(os.getenv("PRIVACY_DELETION_LOG", str(DATA_ROOT / "logs" / "privacy-deletion-audit.log"))) +APP_LOG_DIR = Path(os.getenv("APP_LOG_DIR", str(DATA_ROOT / "logs"))) IMPLEMENTED_RETENTION_KEYS = {"am_job_payloads", "am_diagnostic_logs"} PROTECTED_LOG_FILES = { diff --git a/app/setup_analyzer.py b/app/setup_analyzer.py index 5b000f8..5238ed2 100755 --- a/app/setup_analyzer.py +++ b/app/setup_analyzer.py @@ -13,6 +13,7 @@ import zipfile from datetime import datetime, timezone from pathlib import Path from typing import Any, Callable +from urllib.parse import quote from xml.etree import ElementTree from sqlalchemy.orm import Session @@ -20,6 +21,23 @@ from sqlalchemy.orm import Session from .database import get_db from .models import SoftwarePackage from .software_packages import build_generated_detection_script, build_generated_install_script, build_generated_uninstall_script, normalize_package_manifest, normalize_process_names, package_directory, unique_package_name, write_package_storage +from .analyzer_profiles import ( + apply_analysis_profiles, + command_profile, + detect_marker_profiles, + marker_needles as profile_marker_needles, + match_sfx_profiles, + load_profiles, + export_profile_bundle, + import_profile_bundle, + delete_imported_profile, + set_profile_enabled, + repository_index, + install_repository_profile, + apply_profile, + installer_type_flag, + REPOSITORY_URL as PROFILE_REPOSITORY_URL, +) from fastapi import Depends, File, Form, HTTPException, Request, UploadFile from fastapi.responses import RedirectResponse, StreamingResponse @@ -29,10 +47,22 @@ try: except Exception: pefile = None +try: + from cryptography.hazmat.primitives.serialization import pkcs7 + from cryptography.x509.oid import ExtensionOID, ExtendedKeyUsageOID, NameOID +except Exception: + pkcs7 = None + ExtensionOID = None + ExtendedKeyUsageOID = None + NameOID = None + TEMP_ROOT = Path(os.getenv("SETUP_ANALYZER_TMP_DIR", "/tmp/assetmanager-setup-analyzer")) MAX_UPLOAD_MB = max(1, int(os.getenv("SETUP_ANALYZER_MAX_UPLOAD_MB", "4096"))) RETENTION_HOURS = max(1, int(os.getenv("SETUP_ANALYZER_RETENTION_HOURS", "24"))) +MAX_EXTRACTED_MB = max(64, int(os.getenv("SETUP_ANALYZER_MAX_EXTRACTED_MB", "8192"))) +MAX_EXTRACTED_FILES = max(100, int(os.getenv("SETUP_ANALYZER_MAX_EXTRACTED_FILES", "20000"))) +MAX_SFX_DEPTH = max(0, min(int(os.getenv("SETUP_ANALYZER_MAX_SFX_DEPTH", "2")), 4)) ALLOWED_EXTENSIONS = {".exe", ".msi", ".msp", ".msix", ".appx", ".msu"} SCAN_CHUNK_BYTES = 4 * 1024 * 1024 SCAN_OVERLAP_BYTES = 2048 @@ -40,77 +70,12 @@ SCAN_OVERLAP_BYTES = 2048 TEMP_ROOT.mkdir(parents=True, exist_ok=True) -INSTALLER_RULES: list[dict[str, Any]] = [ - { - "key": "inno", - "label": "Inno Setup", - "markers": [ - (b"inno setup setup data", 75, "setup_analyzer.signal.inno_data"), - (b"inno setup", 35, "setup_analyzer.signal.inno"), - (b"innosetup", 20, "setup_analyzer.signal.inno_internal"), - ], - }, - { - "key": "nsis", - "label": "NSIS", - "markers": [ - (b"nullsoft install system", 80, "setup_analyzer.signal.nsis_system"), - (b"nullsoftinst", 55, "setup_analyzer.signal.nsis_installer"), - (b"nullsoft", 25, "setup_analyzer.signal.nullsoft"), - (b"nsis", 20, "setup_analyzer.signal.nsis"), - ], - }, - { - "key": "wix_burn", - "label": "WiX Burn", - "markers": [ - (b"wixburn", 80, "setup_analyzer.signal.wix_burn"), - (b"wixbundle", 55, "setup_analyzer.signal.wix_bundle"), - (b"wixstdba", 45, "setup_analyzer.signal.wix_stdba"), - (b"burn engine", 30, "setup_analyzer.signal.burn_engine"), - ], - }, - { - "key": "installshield", - "label": "InstallShield", - "markers": [ - (b"installshield", 80, "setup_analyzer.signal.installshield"), - (b"installscript", 30, "setup_analyzer.signal.installscript"), - ], - }, - { - "key": "advanced_installer", - "label": "Advanced Installer", - "markers": [ - (b"advanced installer", 80, "setup_analyzer.signal.advanced_installer"), - (b"caphyon", 50, "setup_analyzer.signal.caphyon"), - ], - }, - { - "key": "squirrel", - "label": "Squirrel", - "markers": [ - (b"squirrel", 55, "setup_analyzer.signal.squirrel"), - (b"releasify", 25, "setup_analyzer.signal.squirrel_releasify"), - ], - }, - { - "key": "7zip_sfx", - "label": "7-Zip SFX", - "markers": [ - (b"7-zip sfx", 75, "setup_analyzer.signal.7zip_sfx"), - (b"7zs.sfx", 50, "setup_analyzer.signal.7zip_module"), - ], - }, - { - "key": "winrar_sfx", - "label": "WinRAR SFX", - "markers": [ - (b"winrar sfx", 75, "setup_analyzer.signal.winrar_sfx"), - (b"rar sfx", 45, "setup_analyzer.signal.rar_sfx"), - ], - }, -] +SFX_BINARY_SIGNATURES = { + b"\x37\x7a\xbc\xaf\x27\x1c": "__7z_archive_signature__", + b"Rar!\x1a\x07\x00": "__rar_archive_signature__", + b"Rar!\x1a\x07\x01\x00": "__rar_archive_signature__", +} + SWITCH_MARKERS = [ b"/verysilent", @@ -212,10 +177,8 @@ def _analysis_file(token: str) -> tuple[Path, dict[str, Any]]: def _scan_needles() -> dict[bytes, str]: - result: dict[bytes, str] = {} - markers = [marker for rule in INSTALLER_RULES for marker, _, _ in rule["markers"]] - markers.extend(SWITCH_MARKERS) - for marker in markers: + result = profile_marker_needles() + for marker in SWITCH_MARKERS: lower = marker.lower() normalized = lower.decode("ascii", errors="ignore") result[lower] = normalized @@ -223,26 +186,265 @@ def _scan_needles() -> dict[bytes, str]: return result -SCAN_NEEDLES = _scan_needles() - - def _scan_file_markers(path: Path) -> set[str]: found: set[str] = set() tail = b"" + needles = _scan_needles() with path.open("rb") as handle: while True: chunk = handle.read(SCAN_CHUNK_BYTES) if not chunk: break - data = (tail + chunk).lower() - for raw, normalized in SCAN_NEEDLES.items(): + combined = tail + chunk + for raw, normalized in SFX_BINARY_SIGNATURES.items(): + if normalized not in found and raw in combined: + found.add(normalized) + data = combined.lower() + for raw, normalized in needles.items(): if normalized not in found and raw in data: found.add(normalized) - tail = data[-SCAN_OVERLAP_BYTES:] + tail = combined[-SCAN_OVERLAP_BYTES:] return found +WRAPPER_INSTALLER_TYPES = { + "inno", + "nsis", + "wix_burn", + "installshield", + "advanced_installer", + "squirrel", + "zip_sfx", + "7zip_sfx", + "winrar_sfx", +} + + +def _normalize_architecture(value: str) -> str: + text = str(value or "").strip().casefold() + if text in {"x64", "amd64", "x86_64", "x86-64", "win64", "64-bit", "64bit"}: + return "x64" + if text in {"x86", "i386", "i486", "i586", "i686", "win32", "32-bit", "32bit"}: + return "x86" + if text in {"arm64", "aarch64"}: + return "arm64" + if text in {"arm", "arm32"}: + return "arm" + return str(value or "").strip() + + +def _filename_architecture_hint(filename: str) -> str: + """Return only explicit architecture hints from a package filename. + + Installer EXE launchers are frequently 32-bit even when they deploy a 64-bit + application. Therefore x86/x64 tokens in a vendor package filename are a + better target-architecture signal than the PE machine type of a known setup + bootstrapper. + """ + name = str(filename or "") + stem = Path(name).stem.casefold() + + # Some vendor filenames append architecture directly to a version; combined + # packages may also use tokens such as x32_64. + if re.search(r"(?i)(?:x32[_-]?64|x64[_-]?32)$", stem): + return "x86+x64" + for architecture, suffixes in ( + ("arm64", ("arm64", "aarch64")), + ("x64", ("x64", "amd64", "win64")), + ("x86", ("x86", "x32", "win32")), + ): + if any(stem.endswith(suffix) for suffix in suffixes): + return architecture + + patterns = ( + ("arm64", r"(?i)(?:^|[._+()\-\s])(?:arm64|aarch64)(?=$|[._+()\-\s])"), + ("x64", r"(?i)(?:^|[._+()\-\s])(?:x64|amd64|x86[_-]?64|win64|64[-_ ]?bit)(?=$|[._+()\-\s])"), + ("x86", r"(?i)(?:^|[._+()\-\s])(?:x86|x32|i[3-6]86|win32|32[-_ ]?bit)(?=$|[._+()\-\s])"), + ) + for architecture, pattern in patterns: + if re.search(pattern, name): + return architecture + return "" + + +def _resolve_target_architecture( + installer_type: str, + filename: str, + pe_architecture: str = "", + package_architecture: str = "", +) -> dict[str, str]: + """Separate target architecture from the executable launcher's PE type.""" + launcher = _normalize_architecture(pe_architecture) + package_value = _normalize_architecture(package_architecture) + filename_hint = _filename_architecture_hint(filename) + + if package_value: + return { + "architecture": package_value, + "architecture_source": "package_metadata", + "launcher_architecture": launcher, + } + if filename_hint: + return { + "architecture": filename_hint, + "architecture_source": "filename", + "launcher_architecture": launcher, + } + + installer_key = str(installer_type or "").strip().casefold() + if installer_key in WRAPPER_INSTALLER_TYPES or installer_type_flag(installer_key, "wrapper", False): + # A 64-bit/ARM64 launcher itself requires that architecture, so it is a + # useful fallback. A 32-bit launcher is *not* evidence that the payload is + # x86: NSIS/Inno and other bootstrapper stubs commonly stay PE32 for x64 + # products. + if launcher in {"x64", "arm64"}: + return { + "architecture": launcher, + "architecture_source": "launcher_requirement", + "launcher_architecture": launcher, + } + return { + "architecture": "", + "architecture_source": "", + "launcher_architecture": launcher, + } + + return { + "architecture": launcher, + "architecture_source": "pe_machine" if launcher else "", + "launcher_architecture": launcher, + } + + +def _filename_version_hint(filename: str) -> str: + """Return a conservative dotted version token from an installer filename. + + This is intentionally generic. Product/vendor-specific compact version + encodings are left to analyzer profiles. Common dotted version tokens can + still provide useful metadata when the setup launcher itself has no + VERSIONINFO resource. + """ + stem = Path(str(filename or "")).stem + matches = list(re.finditer(r"(?i)(?:^|[._+()\-\s])v?(?P\d{1,4}(?:\.\d{1,4}){1,3})(?=$|[._+()\-\s])", stem)) + if not matches: + return "" + # Prefer the most specific candidate (more components), then the first one. + matches.sort(key=lambda item: (-item.group("version").count("."), item.start())) + return matches[0].group("version") + + +def _authenticode_metadata(path: Path) -> dict[str, Any]: + """Read Authenticode certificate metadata without executing the file. + + The PE security directory is a file offset (not an RVA). We only use the + certificate publisher as a fallback when installer metadata does not expose a + manufacturer. A code-signing end-entity certificate is preferred over CA and + timestamp certificates contained in the same PKCS#7 structure. + """ + result: dict[str, Any] = { + "signature_present": None, + "signature_publisher": "", + "signature_subject": "", + } + try: + with path.open("rb") as handle: + header = handle.read(4096) + if len(header) < 0x40 or header[:2] != b"MZ": + return result + pe_offset = int.from_bytes(header[0x3C:0x40], "little", signed=False) + if pe_offset < 0 or pe_offset > 16 * 1024 * 1024: + return result + minimum = pe_offset + 4 + 20 + 2 + if len(header) < minimum: + handle.seek(0) + header = handle.read(minimum + 256) + if header[pe_offset:pe_offset + 4] != b"PE\x00\x00": + return result + optional_offset = pe_offset + 4 + 20 + magic = int.from_bytes(header[optional_offset:optional_offset + 2], "little", signed=False) + if magic == 0x10B: + data_directory_offset = optional_offset + 96 + elif magic == 0x20B: + data_directory_offset = optional_offset + 112 + else: + return result + security_entry_offset = data_directory_offset + (4 * 8) + need = security_entry_offset + 8 + if len(header) < need: + handle.seek(0) + header = handle.read(need) + certificate_offset = int.from_bytes(header[security_entry_offset:security_entry_offset + 4], "little", signed=False) + certificate_size = int.from_bytes(header[security_entry_offset + 4:security_entry_offset + 8], "little", signed=False) + if not certificate_offset or certificate_size < 8: + result["signature_present"] = False + return result + file_size = path.stat().st_size + if certificate_offset >= file_size or certificate_offset + certificate_size > file_size: + return result + result["signature_present"] = True + if pkcs7 is None: + return result + handle.seek(certificate_offset) + certificate_table = handle.read(certificate_size) + except (OSError, ValueError): + return result + + certificates = [] + position = 0 + while position + 8 <= len(certificate_table): + length = int.from_bytes(certificate_table[position:position + 4], "little", signed=False) + certificate_type = int.from_bytes(certificate_table[position + 6:position + 8], "little", signed=False) + if length < 8 or position + length > len(certificate_table): + break + if certificate_type == 0x0002: + blob = certificate_table[position + 8:position + length] + try: + import warnings + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + certificates.extend(pkcs7.load_der_pkcs7_certificates(blob)) + except Exception: + pass + position += (length + 7) & ~7 + + def certificate_score(certificate: Any) -> int: + score = 0 + try: + constraints = certificate.extensions.get_extension_for_oid(ExtensionOID.BASIC_CONSTRAINTS).value + score += -80 if constraints.ca else 40 + except Exception: + pass + try: + usage = certificate.extensions.get_extension_for_oid(ExtensionOID.EXTENDED_KEY_USAGE).value + if ExtendedKeyUsageOID.CODE_SIGNING in usage: + score += 120 + if ExtendedKeyUsageOID.TIME_STAMPING in usage: + score -= 100 + except Exception: + pass + try: + if certificate.subject != certificate.issuer: + score += 10 + except Exception: + pass + return score + + if not certificates: + return result + certificate = max(certificates, key=certificate_score) + try: + organizations = certificate.subject.get_attributes_for_oid(NameOID.ORGANIZATION_NAME) + common_names = certificate.subject.get_attributes_for_oid(NameOID.COMMON_NAME) + publisher = organizations[0].value if organizations else (common_names[0].value if common_names else "") + result["signature_publisher"] = str(publisher or "").strip() + result["signature_subject"] = certificate.subject.rfc4514_string() + except Exception: + pass + return result + + def _pe_metadata(path: Path) -> dict[str, Any]: + authenticode = _authenticode_metadata(path) result: dict[str, Any] = { "architecture": "", "company_name": "", @@ -250,7 +452,9 @@ def _pe_metadata(path: Path) -> dict[str, Any]: "product_version": "", "file_version": "", "original_filename": "", - "signature_present": None, + "signature_present": authenticode.get("signature_present"), + "signature_publisher": authenticode.get("signature_publisher", ""), + "signature_subject": authenticode.get("signature_subject", ""), } if pefile is None: return result @@ -349,22 +553,7 @@ def _msix_metadata(path: Path) -> dict[str, str]: def _detect_exe(found_markers: set[str]) -> tuple[str, str, int, list[str], list[dict[str, Any]]]: - candidates: list[dict[str, Any]] = [] - for rule in INSTALLER_RULES: - score = 0 - signals: list[str] = [] - for marker, points, signal_key in rule["markers"]: - if marker.decode("ascii").lower() in found_markers: - score += points - signals.append(signal_key) - if score: - candidates.append({ - "key": rule["key"], - "label": rule["label"], - "confidence": min(score, 99), - "signals": signals, - }) - candidates.sort(key=lambda item: item["confidence"], reverse=True) + candidates = detect_marker_profiles(found_markers) if not candidates: return "unknown_exe", "Unknown EXE installer", 25, ["setup_analyzer.signal.exe"], [] primary = candidates[0] @@ -372,7 +561,7 @@ def _detect_exe(found_markers: set[str]) -> tuple[str, str, int, list[str], list str(primary["key"]), str(primary["label"]), max(55, int(primary["confidence"])), - list(primary["signals"]), + list(primary.get("signals") or []), candidates, ) @@ -382,6 +571,8 @@ def _command_defaults(installer_type: str, filename: str, product_code: str, pro result: dict[str, Any] = { "install_arguments": "", "install_command": quoted, + "alternative_install_arguments": "", + "alternative_install_command": "", "uninstall_command": "", "success_codes": [0], "reboot_codes": [], @@ -416,27 +607,22 @@ def _command_defaults(installer_type: str, filename: str, product_code: str, pro reboot_codes=[3010], command_confidence="high", ) - elif installer_type == "inno": - args = "/VERYSILENT /SUPPRESSMSGBOXES /NORESTART /SP-" - result.update(install_arguments=args, install_command=f"{quoted} {args}", success_codes=[0, 3010], reboot_codes=[3010], command_confidence="high") - elif installer_type == "nsis": - args = "/S" - result.update(install_arguments=args, install_command=f"{quoted} {args}", success_codes=[0, 3010], reboot_codes=[3010], command_confidence="high") - elif installer_type == "wix_burn": - args = "/quiet /norestart" - result.update(install_arguments=args, install_command=f"{quoted} {args}", success_codes=[0, 1641, 3010], reboot_codes=[1641, 3010], command_confidence="high") - elif installer_type == "installshield": - args = '/s /v"/qn /norestart"' - result.update(install_arguments=args, install_command=f"{quoted} {args}", success_codes=[0, 1641, 3010], reboot_codes=[1641, 3010], command_confidence="medium") - result["warning_keys"].append("setup_analyzer.warning.installshield") - elif installer_type == "advanced_installer": - args = "/exenoui /qn /norestart" - result.update(install_arguments=args, install_command=f"{quoted} {args}", success_codes=[0, 1641, 3010], reboot_codes=[1641, 3010], command_confidence="medium") - result["warning_keys"].append("setup_analyzer.warning.advanced_installer") - elif installer_type == "squirrel": - args = "--silent" - result.update(install_arguments=args, install_command=f"{quoted} {args}", success_codes=[0], command_confidence="low") - result["warning_keys"].append("setup_analyzer.warning.squirrel") + elif command_profile(installer_type): + profile_command = command_profile(installer_type) + args = str(profile_command.get("install_arguments") or "").strip() + alt_args = str(profile_command.get("alternative_install_arguments") or "").strip() + if args: + result["install_arguments"] = args + result["install_command"] = f"{quoted} {args}" + if alt_args: + result["alternative_install_arguments"] = alt_args + result["alternative_install_command"] = f"{quoted} {alt_args}" + for key in ("success_codes", "reboot_codes", "detect_method", "command_confidence", "uninstall_command"): + if key in profile_command: + result[key] = profile_command[key] + for warning in profile_command.get("warning_keys") or []: + if warning not in result["warning_keys"]: + result["warning_keys"].append(warning) elif installer_type in {"msix", "appx"}: result.update( install_arguments="", @@ -446,14 +632,304 @@ def _command_defaults(installer_type: str, filename: str, product_code: str, pro command_confidence="medium", ) result["warning_keys"].append("setup_analyzer.warning.appx_context") - elif installer_type in {"7zip_sfx", "winrar_sfx"}: + elif installer_type in {"zip_sfx", "7zip_sfx", "winrar_sfx"}: result["warning_keys"].append("setup_analyzer.warning.sfx") else: result["warning_keys"].append("setup_analyzer.warning.unknown") return result -def analyze_file(path: Path, token: str, size: int, sha256: str) -> dict[str, Any]: + +def _sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def _archive_tool_status() -> dict[str, Any]: + sevenzip = shutil.which("7zz") or shutil.which("7z") + unar = shutil.which("unar") + lsar = shutil.which("lsar") + return { + "sevenzip": sevenzip or "", + "unar": unar or "", + "lsar": lsar or "", + "sevenzip_available": bool(sevenzip), + "unar_available": bool(unar and lsar), + } + + +def _safe_archive_member(value: str) -> str: + name = str(value or "").replace("\\", "/").strip() + while name.startswith("./"): + name = name[2:] + if not name or name.startswith("/") or re.match(r"^[A-Za-z]:", name): + raise ValueError("unsafe archive member path") + parts = [part for part in name.split("/") if part not in {"", "."}] + if not parts or any(part == ".." for part in parts): + raise ValueError("unsafe archive member path") + return "/".join(parts) + + +def _validate_archive_listing(entries: list[tuple[str, int]], max_files: int, max_bytes: int) -> tuple[int, int]: + file_count = 0 + total_size = 0 + for raw_name, raw_size in entries: + _safe_archive_member(raw_name) + file_count += 1 + if file_count > max_files: + raise ValueError("archive file count limit exceeded") + try: + size = max(0, int(raw_size or 0)) + except (TypeError, ValueError): + size = 0 + total_size += size + if total_size > max_bytes: + raise ValueError("archive extracted size limit exceeded") + return file_count, total_size + + +def _zip_listing(path: Path) -> list[tuple[str, int]]: + entries: list[tuple[str, int]] = [] + with zipfile.ZipFile(path, "r") as archive: + for info in archive.infolist(): + if info.is_dir(): + continue + entries.append((info.filename, int(info.file_size or 0))) + return entries + + +def _extract_zip_safely(path: Path, destination: Path, max_files: int, max_bytes: int) -> dict[str, Any]: + entries = _zip_listing(path) + file_count, total_size = _validate_archive_listing(entries, max_files, max_bytes) + destination.mkdir(parents=True, exist_ok=False) + root = destination.resolve() + with zipfile.ZipFile(path, "r") as archive: + for info in archive.infolist(): + if info.is_dir(): + continue + relative = _safe_archive_member(info.filename) + target = (destination / relative).resolve() + if root != target and root not in target.parents: + raise ValueError("archive member escapes extraction directory") + target.parent.mkdir(parents=True, exist_ok=True) + with archive.open(info, "r") as source, target.open("wb") as output: + shutil.copyfileobj(source, output, length=1024 * 1024) + return {"extractor": "python-zipfile", "file_count": file_count, "extracted_bytes": total_size} + + +def _sevenzip_listing(path: Path, executable: str) -> list[tuple[str, int]]: + output = _run_parser([executable, "l", "-slt", str(path)], timeout=60) + if not output: + raise ValueError("7-Zip could not list the archive") + entries: list[tuple[str, int]] = [] + in_files = False + current: dict[str, str] = {} + for raw_line in output.splitlines() + [""]: + line = raw_line.rstrip("\r\n") + if line.startswith("----------"): + in_files = True + current = {} + continue + if not in_files: + continue + if not line: + if current.get("Path") and current.get("Folder", "-") != "+": + entries.append((current["Path"], int(current.get("Size") or 0))) + current = {} + continue + if " = " in line: + key, value = line.split(" = ", 1) + current[key.strip()] = value.strip() + if not entries: + raise ValueError("7-Zip archive contains no files") + return entries + + +def _extract_with_sevenzip(path: Path, destination: Path, executable: str, max_files: int, max_bytes: int) -> dict[str, Any]: + entries = _sevenzip_listing(path, executable) + file_count, total_size = _validate_archive_listing(entries, max_files, max_bytes) + destination.mkdir(parents=True, exist_ok=False) + try: + completed = subprocess.run( + [executable, "x", "-y", f"-o{destination}", str(path)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + timeout=180, + check=False, + ) + except (OSError, subprocess.SubprocessError) as exc: + raise ValueError(f"7-Zip extraction failed: {exc}") from exc + if completed.returncode != 0: + raise ValueError("7-Zip extraction failed") + return {"extractor": Path(executable).name, "file_count": file_count, "extracted_bytes": total_size} + + +def _walk_lsar_entries(value: Any) -> list[tuple[str, int]]: + entries: list[tuple[str, int]] = [] + if isinstance(value, dict): + name = value.get("XADFileName") + if name and not bool(value.get("XADIsDirectory")): + entries.append((str(name), int(value.get("XADFileSize") or 0))) + for child in value.values(): + if isinstance(child, (dict, list)): + entries.extend(_walk_lsar_entries(child)) + elif isinstance(value, list): + for child in value: + entries.extend(_walk_lsar_entries(child)) + return entries + + +def _unar_listing(path: Path, lsar_executable: str) -> list[tuple[str, int]]: + output = _run_parser([lsar_executable, "-json", str(path)], timeout=60) + if not output: + raise ValueError("lsar could not list the archive") + try: + data = json.loads(output) + except ValueError as exc: + raise ValueError("lsar returned invalid archive metadata") from exc + entries = _walk_lsar_entries(data) + if not entries: + raise ValueError("archive contains no files") + return entries + + +def _extract_with_unar(path: Path, destination: Path, unar_executable: str, lsar_executable: str, max_files: int, max_bytes: int) -> dict[str, Any]: + entries = _unar_listing(path, lsar_executable) + file_count, total_size = _validate_archive_listing(entries, max_files, max_bytes) + destination.mkdir(parents=True, exist_ok=False) + try: + completed = subprocess.run( + [unar_executable, "-f", "-D", "-o", str(destination), str(path)], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + timeout=180, + check=False, + ) + except (OSError, subprocess.SubprocessError) as exc: + raise ValueError(f"unar extraction failed: {exc}") from exc + if completed.returncode != 0: + raise ValueError("unar extraction failed") + return {"extractor": Path(unar_executable).name, "file_count": file_count, "extracted_bytes": total_size} + + +def _validate_extracted_tree(root: Path, max_files: int, max_bytes: int) -> tuple[int, int]: + base = root.resolve() + count = 0 + total = 0 + for item in root.rglob("*"): + if item.is_symlink(): + raise ValueError("symbolic links are not allowed in extracted payloads") + if not item.is_file(): + continue + resolved = item.resolve() + if base != resolved and base not in resolved.parents: + raise ValueError("extracted file escapes extraction directory") + count += 1 + if count > max_files: + raise ValueError("archive file count limit exceeded") + total += item.stat().st_size + if total > max_bytes: + raise ValueError("archive extracted size limit exceeded") + return count, total + + +def _extract_sfx(path: Path, destination: Path, installer_type: str, max_files: int, max_bytes: int) -> dict[str, Any]: + tools = _archive_tool_status() + errors: list[str] = [] + attempted = False + if zipfile.is_zipfile(path): + attempted = True + try: + result = _extract_zip_safely(path, destination, max_files, max_bytes) + count, total = _validate_extracted_tree(destination, max_files, max_bytes) + result.update(status="success", file_count=count, extracted_bytes=total) + return result + except Exception as exc: + shutil.rmtree(destination, ignore_errors=True) + errors.append(str(exc)) + + sevenzip = str(tools.get("sevenzip") or "") + unar = str(tools.get("unar") or "") + lsar = str(tools.get("lsar") or "") + methods: list[str] = [] + if installer_type == "winrar_sfx": + if unar and lsar: + methods.append("unar") + if sevenzip: + methods.append("7zip") + else: + if sevenzip: + methods.append("7zip") + if unar and lsar: + methods.append("unar") + + for method in methods: + attempted = True + shutil.rmtree(destination, ignore_errors=True) + try: + if method == "7zip": + result = _extract_with_sevenzip(path, destination, sevenzip, max_files, max_bytes) + else: + result = _extract_with_unar(path, destination, unar, lsar, max_files, max_bytes) + count, total = _validate_extracted_tree(destination, max_files, max_bytes) + result.update(status="success", file_count=count, extracted_bytes=total) + return result + except Exception as exc: + errors.append(str(exc)) + + shutil.rmtree(destination, ignore_errors=True) + if not attempted: + return {"status": "tool_missing", "extractor": "", "file_count": 0, "extracted_bytes": 0, "error": "No SFX extraction tool is available."} + return {"status": "failed", "extractor": "", "file_count": 0, "extracted_bytes": 0, "error": "; ".join(errors[-3:])[:1000]} + + +def _candidate_score(analysis: dict[str, Any], relative_path: str) -> int: + installer_type = str(analysis.get("installer_type") or "") + base = { + "msi": 850, + "msp": 650, + "msix": 700, + "appx": 700, + "msu": 500, + "inno": 780, + "nsis": 760, + "wix_burn": 750, + "advanced_installer": 720, + "installshield": 680, + "squirrel": 600, + "zip_sfx": 350, + "7zip_sfx": 350, + "winrar_sfx": 350, + "unknown_exe": 180, + }.get(installer_type, 100) + name = Path(relative_path).name.casefold() + stem = Path(relative_path).stem.casefold() + score = base + int(analysis.get("confidence") or 0) + if stem in {"setup", "install", "installer"}: + score += 260 + elif any(token in stem for token in ("setup", "install", "installer")): + score += 80 + if any(token in name for token in ("uninstall", "unins000", "unins001", "remove")): + score -= 1200 + if any(token in name for token in ("vc_redist", "vcredist", "dotnet", "directx", "prereq", "prerequisite")): + score -= 350 + if analysis.get("product_name"): + score += 30 + if analysis.get("manufacturer"): + score += 10 + return score + + +def _analyze_basic_file(path: Path) -> dict[str, Any]: filename = path.name extension = path.suffix.lower() with path.open("rb") as handle: @@ -468,6 +944,7 @@ def analyze_file(path: Path, token: str, size: int, sha256: str) -> dict[str, An confidence = 20 signal_keys = ["setup_analyzer.signal.unknown"] candidates: list[dict[str, Any]] = [] + primary_profile_id = "" if extension == ".msi": installer_type, installer_label, confidence = "msi", "Windows Installer (MSI)", 99 signal_keys = ["setup_analyzer.signal.msi_extension"] @@ -485,80 +962,305 @@ def analyze_file(path: Path, token: str, size: int, sha256: str) -> dict[str, An signal_keys = ["setup_analyzer.signal.msu"] elif extension == ".exe" or magic[:2] == b"MZ": installer_type, installer_label, confidence, signal_keys, candidates = _detect_exe(found_markers) + if candidates: + primary_profile_id = str(candidates[0].get("profile_id") or "") + if installer_type == "unknown_exe" and zipfile.is_zipfile(path): + installer_type, installer_label, confidence = "zip_sfx", "ZIP self-extracting archive", 85 + signal_keys = ["setup_analyzer.signal.zip_sfx"] product_name = msi.get("ProductName") or msix.get("display_name") or msix.get("identity_name") or pe.get("product_name") or "" - product_version = msi.get("ProductVersion") or msix.get("version") or pe.get("product_version") or pe.get("file_version") or "" - manufacturer = msi.get("Manufacturer") or msix.get("publisher") or pe.get("company_name") or "" - architecture = msix.get("architecture") or pe.get("architecture") or "" + product_version = msi.get("ProductVersion") or msix.get("version") or pe.get("product_version") or pe.get("file_version") or _filename_version_hint(filename) or "" + manufacturer = msi.get("Manufacturer") or msix.get("publisher") or pe.get("company_name") or pe.get("signature_publisher") or "" + product_version_source = ( + "package_metadata" if (msi.get("ProductVersion") or msix.get("version")) else + "pe_version" if (pe.get("product_version") or pe.get("file_version")) else + "filename" if product_version else "" + ) + manufacturer_source = ( + "package_metadata" if (msi.get("Manufacturer") or msix.get("publisher")) else + "pe_version" if pe.get("company_name") else + "authenticode_signer" if manufacturer else "" + ) + architecture_info = _resolve_target_architecture( + installer_type, filename, pe_architecture=pe.get("architecture", ""), package_architecture=msix.get("architecture", ""), + ) product_code = msi.get("ProductCode", "") upgrade_code = msi.get("UpgradeCode", "") - identity_text = f"{filename} {product_name} {manufacturer}".casefold() - if "greenshot" in identity_text: - product_name = product_name or "Greenshot" - manufacturer = manufacturer or "Greenshot" - if not product_version: - version_match = re.search(r"(?i)greenshot[-_ ]installer[-_ ](\d+(?:\.\d+){1,3})", filename) - if version_match: - product_version = version_match.group(1) defaults = _command_defaults(installer_type, filename, product_code, product_name) - if installer_type == "inno" and "greenshot" in identity_text: - arguments = str(defaults.get("install_arguments") or "").strip() - if not re.search(r"(?i)(^|\s)/(ALLUSERS|CURRENTUSER)(?=\s|$)", arguments): - arguments += " /ALLUSERS" - if not re.search(r"(?i)(^|\s)/DIR=", arguments): - arguments += ' /DIR="C:\\Program Files\\Greenshot"' - defaults["install_arguments"] = arguments.strip() - defaults["install_command"] = f'"{filename}" {arguments.strip()}' - defaults["detect_method"] = "registry_display_name" warning_keys = list(defaults.pop("warning_keys", [])) if extension in {".msi", ".msp"} and not msi: warning_keys.append("setup_analyzer.warning.msiinfo") if (extension == ".exe" or magic[:2] == b"MZ") and pefile is None: warning_keys.append("setup_analyzer.warning.pefile") - + launcher_architecture = architecture_info["launcher_architecture"] + architecture = architecture_info["architecture"] + if (launcher_architecture and architecture and launcher_architecture != architecture and + (installer_type in WRAPPER_INSTALLER_TYPES or installer_type_flag(installer_type, "wrapper", False))): + warning_keys.append("setup_analyzer.warning.wrapper_architecture") embedded_switches = [marker.decode("ascii") for marker in SWITCH_MARKERS if marker.decode("ascii").lower() in found_markers] - identity_text = f"{filename} {product_name} {manufacturer}".casefold() - is_greenshot = "greenshot" in identity_text - suppress_browser_default = is_greenshot - process_names_default = "Greenshot.exe" if is_greenshot else "" - start_application_default = is_greenshot - start_executable_default = r"C:\Program Files\Greenshot\Greenshot.exe" if is_greenshot else "" analysis = { + "filename": filename, "extension": extension, "installer_type": installer_type, "installer_label": installer_label, + "confidence": confidence, "candidates": candidates, "product_name": product_name, "product_version": product_version, + "manufacturer": manufacturer, "product_version_source": product_version_source, "manufacturer_source": manufacturer_source, + "signature_publisher": pe.get("signature_publisher", ""), "architecture": architecture, "launcher_architecture": launcher_architecture, + "architecture_source": architecture_info["architecture_source"], "product_code": product_code, "upgrade_code": upgrade_code, + "signature_present": pe.get("signature_present") if pe else None, + "original_filename": pe.get("original_filename", "") if pe else "", "signal_keys": signal_keys, + "embedded_switches": embedded_switches, "suppress_browser_default": False, "process_names_default": "", + "start_application_default": False, "start_executable_default": "", "start_arguments_default": "", + "warning_keys": warning_keys, **defaults, + } + if primary_profile_id: + analysis = apply_profile(primary_profile_id, analysis, filename) + analysis = apply_analysis_profiles(analysis, filename, found_markers) + return analysis + + +def _read_relaxed_ini(path: Path) -> dict[str, dict[str, str]]: + """Read installer metadata INI files without executing or trusting them.""" + raw = path.read_bytes() + text = "" + for encoding in ("utf-8-sig", "cp1252", "latin-1"): + try: + text = raw.decode(encoding) + break + except UnicodeDecodeError: + continue + sections: dict[str, dict[str, str]] = {} + current = "" + for raw_line in text.splitlines(): + line = raw_line.strip() + if not line or line.startswith((";", "#", "//")): + continue + if line.startswith("[") and line.endswith("]"): + current = line[1:-1].strip().casefold() + sections.setdefault(current, {}) + continue + if not current or "=" not in line: + continue + key, value = line.split("=", 1) + sections.setdefault(current, {})[key.strip().casefold()] = value.strip() + return sections + + + +def _analyze_sfx_recursive( + archive_path: Path, + archive_type: str, + job_dir: Path, + depth: int, + budget: dict[str, int], + sequence: list[int], +) -> dict[str, Any]: + sequence[0] += 1 + extract_dir = job_dir / f"sfx-extracted-{sequence[0]:03d}" + remaining_files = max(1, MAX_EXTRACTED_FILES - budget.get("files", 0)) + remaining_bytes = max(1, (MAX_EXTRACTED_MB * 1024 * 1024) - budget.get("bytes", 0)) + result = _extract_sfx(archive_path, extract_dir, archive_type, remaining_files, remaining_bytes) + result["depth"] = depth + result["container_file"] = archive_path.name + result["candidates"] = [] + if result.get("status") != "success": + return result + + budget["files"] = budget.get("files", 0) + int(result.get("file_count") or 0) + budget["bytes"] = budget.get("bytes", 0) + int(result.get("extracted_bytes") or 0) + root_rel = extract_dir.relative_to(job_dir).as_posix() + result["source_root_rel"] = root_rel + candidate_files = [ + item for item in extract_dir.rglob("*") + if item.is_file() and item.suffix.lower() in ALLOWED_EXTENSIONS + ] + candidate_files.sort(key=lambda item: (len(item.relative_to(extract_dir).parts), item.as_posix().casefold())) + + for candidate_path in candidate_files: + relative_path = candidate_path.relative_to(extract_dir).as_posix() + try: + basic = _analyze_basic_file(candidate_path) + except Exception: + continue + record = { + "relative_path": relative_path, + "source_root_rel": root_rel, + "installer_type": basic.get("installer_type", ""), + "installer_label": basic.get("installer_label", ""), + "confidence": int(basic.get("confidence") or 0), + "product_name": basic.get("product_name", ""), + "product_version": basic.get("product_version", ""), + "manufacturer": basic.get("manufacturer", ""), + "architecture": basic.get("architecture", ""), + "launcher_architecture": basic.get("launcher_architecture", ""), + "architecture_source": basic.get("architecture_source", ""), + "score": _candidate_score(basic, relative_path), + "analysis": basic, + } + result["candidates"].append(record) + + if basic.get("installer_type") in {"zip_sfx", "7zip_sfx", "winrar_sfx"} and depth < MAX_SFX_DEPTH: + nested = _analyze_sfx_recursive(candidate_path, str(basic.get("installer_type")), job_dir, depth + 1, budget, sequence) + for nested_candidate in nested.get("candidates") or []: + nested_candidate["score"] = int(nested_candidate.get("score") or 0) - ((depth + 1) * 15) + result["candidates"].append(nested_candidate) + + result["candidates"].sort(key=lambda item: (int(item.get("score") or 0), int(item.get("confidence") or 0)), reverse=True) + return result + + +def _public_sfx_candidate(record: dict[str, Any]) -> dict[str, Any]: + return { + "relative_path": record.get("relative_path", ""), + "installer_type": record.get("installer_type", ""), + "installer_label": record.get("installer_label", ""), + "confidence": record.get("confidence", 0), + "product_name": record.get("product_name", ""), + "product_version": record.get("product_version", ""), + "manufacturer": record.get("manufacturer", ""), + "architecture": record.get("architecture", ""), + "launcher_architecture": record.get("launcher_architecture", ""), + "architecture_source": record.get("architecture_source", ""), + "score": record.get("score", 0), + } + + +def _create_payload_archive(target: Path, meta: dict[str, Any]) -> Path: + root_rel = str(meta.get("deployment_payload_dir") or "").strip() + if not root_rel: + return target + root = (target.parent / root_rel).resolve() + job_root = target.parent.resolve() + if job_root != root and job_root not in root.parents: + raise HTTPException(400, "Invalid embedded payload directory.") + if not root.is_dir(): + raise HTTPException(404, "Embedded payload is no longer available.") + archive_path = target.parent / "embedded-payload.zip" + if archive_path.exists(): + archive_path.unlink() + count, total = _validate_extracted_tree(root, MAX_EXTRACTED_FILES, MAX_EXTRACTED_MB * 1024 * 1024) + if count <= 0 or total <= 0: + raise HTTPException(400, "Embedded payload is empty.") + with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as archive: + for item in sorted(root.rglob("*")): + if item.is_file(): + archive.write(item, arcname=item.relative_to(root).as_posix()) + return archive_path + + +def _deployment_source_file(target: Path, meta: dict[str, Any]) -> Path: + if str(meta.get("deployment_source") or "direct") == "embedded_payload": + return _create_payload_archive(target, meta) + return target + +def analyze_file(path: Path, token: str, size: int, sha256: str) -> dict[str, Any]: + base = _analyze_basic_file(path) + analysis = dict(base) + analysis.update({ "token": token, - "filename": filename, + "filename": path.name, "size": size, "size_human": _human_size(size), "sha256": sha256, - "extension": extension, - "installer_type": installer_type, - "installer_label": installer_label, - "confidence": confidence, - "candidates": candidates, - "product_name": product_name, - "product_version": product_version, - "manufacturer": manufacturer, - "architecture": architecture, - "product_code": product_code, - "upgrade_code": upgrade_code, - "signature_present": pe.get("signature_present") if pe else None, - "original_filename": pe.get("original_filename", "") if pe else "", - "signal_keys": signal_keys, - "embedded_switches": embedded_switches, - "suppress_browser_default": suppress_browser_default, - "process_names_default": process_names_default, - "start_application_default": start_application_default, - "start_executable_default": start_executable_default, - "start_arguments_default": "", - "warning_keys": warning_keys, "analyzed_at": datetime.now(timezone.utc).isoformat(), "msiinfo_available": shutil.which("msiinfo") is not None, "pefile_available": pefile is not None, - **defaults, - } + "archive_tools": _archive_tool_status(), + "sfx_analysis": None, + "deployment_source": "direct", + "deployment_payload_dir": "", + "embedded_installer_path": "", + }) + + outer_type = str(base.get("installer_type") or "") + if outer_type in {"zip_sfx", "7zip_sfx", "winrar_sfx"}: + budget = {"files": 0, "bytes": 0} + sequence = [0] + sfx = _analyze_sfx_recursive(path, outer_type, path.parent, 0, budget, sequence) + public_sfx = { + "status": sfx.get("status", "failed"), + "extractor": sfx.get("extractor", ""), + "file_count": sfx.get("file_count", 0), + "extracted_bytes": sfx.get("extracted_bytes", 0), + "extracted_size_human": _human_size(int(sfx.get("extracted_bytes") or 0)), + "error": sfx.get("error", ""), + "container_type": outer_type, + "container_label": base.get("installer_label", ""), + "container_confidence": base.get("confidence", 0), + "candidates": [_public_sfx_candidate(item) for item in (sfx.get("candidates") or [])[:20]], + "selected": None, + } + analysis["sfx_analysis"] = public_sfx + warning_keys = [key for key in analysis.get("warning_keys", []) if key != "setup_analyzer.warning.sfx"] + selectable_candidates = [ + item for item in (sfx.get("candidates") or []) + if item.get("installer_type") not in {"zip_sfx", "7zip_sfx", "winrar_sfx"} + ] + vendor_profile = None + if sfx.get("status") == "success": + root_rel = str(sfx.get("source_root_rel") or "").strip() + if root_rel: + extract_root = (path.parent / root_rel).resolve() + if extract_root.is_dir(): + vendor_profile = match_sfx_profiles(extract_root, path, base) + + if sfx.get("status") == "success" and selectable_candidates: + selected = selectable_candidates[0] + selected_analysis = dict(selected.get("analysis") or {}) + public_sfx["selected"] = _public_sfx_candidate(selected) + analysis["outer_installer_type"] = outer_type + analysis["outer_installer_label"] = base.get("installer_label", "") + analysis["outer_confidence"] = base.get("confidence", 0) + analysis["installer_type"] = selected_analysis.get("installer_type", outer_type) + analysis["installer_label"] = selected_analysis.get("installer_label", base.get("installer_label", "")) + analysis["confidence"] = selected_analysis.get("confidence", base.get("confidence", 0)) + for key in ( + "product_name", "product_version", "manufacturer", "architecture", + "launcher_architecture", "architecture_source", + "product_code", "upgrade_code", + "signal_keys", "embedded_switches", "install_arguments", "install_command", + "uninstall_command", "success_codes", "reboot_codes", "detect_method", + "command_confidence", "suppress_browser_default", "process_names_default", + "start_application_default", "start_executable_default", "start_arguments_default", + ): + if key in selected_analysis: + analysis[key] = selected_analysis[key] + for key in selected_analysis.get("warning_keys", []): + if key not in warning_keys and key != "setup_analyzer.warning.sfx": + warning_keys.append(key) + warning_keys.append("setup_analyzer.warning.sfx_embedded_selected") + analysis["deployment_source"] = "embedded_payload" + analysis["deployment_payload_dir"] = selected.get("source_root_rel", "") + analysis["embedded_installer_path"] = selected.get("relative_path", "") + selected_command = str(analysis.get("install_command") or "") + selected_name = str(selected_analysis.get("filename") or "") + if selected_name and selected_command: + analysis["install_command"] = selected_command.replace(f'"{selected_name}"', f'"{selected.get("relative_path", selected_name)}"', 1) + elif vendor_profile: + analysis["outer_installer_type"] = outer_type + analysis["outer_installer_label"] = base.get("installer_label", "") + analysis["outer_confidence"] = base.get("confidence", 0) + for key, value in vendor_profile.items(): + analysis[key] = value + analysis["deployment_source"] = "direct" + analysis["deployment_payload_dir"] = "" + analysis["embedded_installer_path"] = "" + analysis["warning_keys"] = [ + key for key in warning_keys + if key not in { + "setup_analyzer.warning.sfx", + "setup_analyzer.warning.sfx_no_installer", + } + ] + else: + if sfx.get("status") == "tool_missing": + warning_keys.append("setup_analyzer.warning.sfx_tool_missing") + elif sfx.get("status") == "success": + warning_keys.append("setup_analyzer.warning.sfx_no_installer") + else: + warning_keys.append("setup_analyzer.warning.sfx_extract_failed") + analysis["warning_keys"] = warning_keys + (path.parent / "analysis.json").write_text(json.dumps(analysis, ensure_ascii=True, indent=2), encoding="utf-8") return analysis - def _form_value(value: str | None, fallback: str = "") -> str: return (value if value is not None else fallback).strip() @@ -770,6 +1472,8 @@ def _build_package_manifest( "installer_file": target.name, "install": { "script": "install.ps1", + "source_mode": "embedded_archive" if meta.get("deployment_source") == "embedded_payload" else "direct", + "embedded_installer": meta.get("embedded_installer_path", "") if meta.get("deployment_source") == "embedded_payload" else "", "arguments": values["install_arguments"], "timeout_seconds": values["timeout_seconds"], "run_as": values["run_as"], @@ -802,6 +1506,12 @@ def _build_package_manifest( "sha256": meta.get("sha256", ""), "confidence": meta.get("confidence", 0), "command_confidence": meta.get("command_confidence", "none"), + "container_type": meta.get("outer_installer_type", ""), + "embedded_installer": meta.get("embedded_installer_path", ""), + "profile_id": meta.get("profile_id", ""), + "profile_name": meta.get("profile_name", ""), + "profile_version": meta.get("profile_version", ""), + "profile_source": meta.get("profile_source", ""), }, } @@ -810,14 +1520,85 @@ def register_setup_analyzer(app: Any, templates: Any, require_admin: Callable[[R @app.get("/software/setup-analyzer") def setup_analyzer_page(request: Request): require_admin(request) - return templates.TemplateResponse("setup_analyzer.html", {"request": request, "analysis": None, "max_upload_mb": MAX_UPLOAD_MB}) + return templates.TemplateResponse("setup_analyzer.html", {"request": request, "analysis": None, "max_upload_mb": MAX_UPLOAD_MB, "max_extracted_mb": MAX_EXTRACTED_MB, "max_extracted_files": MAX_EXTRACTED_FILES, "max_sfx_depth": MAX_SFX_DEPTH}) + + @app.get("/software/setup-analyzer/profiles") + def setup_analyzer_profiles_page(request: Request, repository: int = 0): + require_admin(request) + repository_data: dict[str, Any] = {"configured": bool(PROFILE_REPOSITORY_URL), "url": PROFILE_REPOSITORY_URL, "profiles": []} + repository_error = "" + if repository and PROFILE_REPOSITORY_URL: + try: + repository_data = repository_index() + except Exception as exc: + repository_error = str(exc) + return templates.TemplateResponse("setup_analyzer_profiles.html", { + "request": request, + "profiles": load_profiles(include_disabled=True), + "repository": repository_data, + "repository_error": repository_error, + }) + + @app.post("/software/setup-analyzer/profiles/import") + async def setup_analyzer_profile_import(request: Request, profile_file: UploadFile = File(...), source: str = Form("community")): + require_admin(request) + try: + data = await profile_file.read(2 * 1024 * 1024 + 1) + profile = import_profile_bundle(data, source=source if source in {"community", "local"} else "community") + except Exception as exc: + return RedirectResponse("/software/setup-analyzer/profiles?toast_error=" + quote(str(exc)), status_code=303) + finally: + await profile_file.close() + return RedirectResponse("/software/setup-analyzer/profiles?toast_success=" + quote(f"Analyzer profile {profile.get('name', profile.get('id', ''))} imported."), status_code=303) + + @app.get("/software/setup-analyzer/profiles/{profile_id}/export") + def setup_analyzer_profile_export(profile_id: str, request: Request): + require_admin(request) + try: + data = export_profile_bundle(profile_id) + except Exception as exc: + raise HTTPException(404, str(exc)) from exc + safe = re.sub(r"[^A-Za-z0-9._-]+", "-", profile_id).strip("-.") or "analyzer-profile" + return StreamingResponse(io.BytesIO(data), media_type="application/zip", headers={"Content-Disposition": f'attachment; filename="{safe}.amprofile"'}) + + @app.post("/software/setup-analyzer/profiles/{profile_id}/toggle") + async def setup_analyzer_profile_toggle(profile_id: str, request: Request): + require_admin(request) + form = await request.form() + enabled = str(form.get("enabled") or "").strip().lower() in {"1", "true", "yes", "on"} + try: + set_profile_enabled(profile_id, enabled) + except Exception as exc: + return RedirectResponse("/software/setup-analyzer/profiles?toast_error=" + quote(str(exc)), status_code=303) + return RedirectResponse("/software/setup-analyzer/profiles", status_code=303) + + @app.post("/software/setup-analyzer/profiles/{profile_id}/delete") + def setup_analyzer_profile_delete(profile_id: str, request: Request): + require_admin(request) + try: + if not delete_imported_profile(profile_id): + raise ValueError("System profiles cannot be deleted.") + except Exception as exc: + return RedirectResponse("/software/setup-analyzer/profiles?toast_error=" + quote(str(exc)), status_code=303) + return RedirectResponse("/software/setup-analyzer/profiles", status_code=303) + + @app.post("/software/setup-analyzer/profiles/repository/install") + async def setup_analyzer_repository_install(request: Request): + require_admin(request) + form = await request.form() + profile_id = str(form.get("profile_id") or "") + try: + profile = install_repository_profile(profile_id) + except Exception as exc: + return RedirectResponse("/software/setup-analyzer/profiles?repository=1&toast_error=" + quote(str(exc)), status_code=303) + return RedirectResponse("/software/setup-analyzer/profiles?repository=1&toast_success=" + quote(f"Analyzer profile {profile.get('name', profile_id)} installed."), status_code=303) @app.post("/software/setup-analyzer/analyze") async def setup_analyzer_analyze(request: Request, installer: UploadFile = File(...)): require_admin(request) token, path, size, sha256 = await _save_upload(installer) analysis = analyze_file(path, token, size, sha256) - return templates.TemplateResponse("setup_analyzer.html", {"request": request, "analysis": analysis, "max_upload_mb": MAX_UPLOAD_MB}) + return templates.TemplateResponse("setup_analyzer.html", {"request": request, "analysis": analysis, "max_upload_mb": MAX_UPLOAD_MB, "max_extracted_mb": MAX_EXTRACTED_MB, "max_extracted_files": MAX_EXTRACTED_FILES, "max_sfx_depth": MAX_SFX_DEPTH}) @app.post("/software/setup-analyzer/export/powershell") def setup_analyzer_export_powershell( @@ -843,7 +1624,8 @@ def register_setup_analyzer(app: Any, templates: Any, require_admin: Callable[[R require_admin(request) target, meta = _analysis_file(token) values = _export_values(meta, product_name, product_version, manufacturer, architecture, install_arguments, timeout_seconds, run_as, success_codes, reboot_codes, suppress_browser, process_names, start_application, start_executable, start_arguments, start_only_if_user_logged_on, start_fail_job_on_error) - package = _build_package_manifest(target, meta, values) + deployment_source = _deployment_source_file(target, meta) + package = _build_package_manifest(deployment_source, meta, values) package, _profile_notes = normalize_package_manifest(package) script = build_generated_install_script(package) name = _safe_package_name(values["product_name"] or target.stem) @@ -874,7 +1656,8 @@ def register_setup_analyzer(app: Any, templates: Any, require_admin: Callable[[R require_admin(request) target, meta = _analysis_file(token) values = _export_values(meta, product_name, product_version, manufacturer, architecture, install_arguments, timeout_seconds, run_as, success_codes, reboot_codes, suppress_browser, process_names, start_application, start_executable, start_arguments, start_only_if_user_logged_on, start_fail_job_on_error) - package = _build_package_manifest(target, meta, values) + deployment_source = _deployment_source_file(target, meta) + package = _build_package_manifest(deployment_source, meta, values) package, _profile_notes = normalize_package_manifest(package) install_script = build_generated_install_script(package) detect_script = build_generated_detection_script(package) @@ -883,12 +1666,18 @@ def register_setup_analyzer(app: Any, templates: Any, require_admin: Callable[[R public_analysis.update(values) stream = io.BytesIO() with zipfile.ZipFile(stream, "w", compression=zipfile.ZIP_DEFLATED) as archive: - archive.write(target, arcname=target.name) + archive.write(deployment_source, arcname=deployment_source.name) archive.writestr("install.ps1", install_script) archive.writestr("uninstall.ps1", uninstall_script) archive.writestr("detect.ps1", detect_script) archive.writestr("package.json", json.dumps(package, ensure_ascii=True, indent=2)) archive.writestr("analysis.json", json.dumps(public_analysis, ensure_ascii=True, indent=2)) + profile_id = str(meta.get("profile_id") or "").strip() + if profile_id: + try: + archive.writestr("metadata/analyzer-profile.amprofile", export_profile_bundle(profile_id)) + except Exception: + pass stream.seek(0) name = _safe_package_name(values["product_name"] or target.stem) version = _safe_package_name(values["product_version"]) if values["product_version"] else "" @@ -939,7 +1728,8 @@ def register_setup_analyzer(app: Any, templates: Any, require_admin: Callable[[R start_only_if_user_logged_on, start_fail_job_on_error, ) - manifest = _build_package_manifest(target, meta, values) + deployment_source = _deployment_source_file(target, meta) + manifest = _build_package_manifest(deployment_source, meta, values) manifest, _profile_notes = normalize_package_manifest(manifest) install_script = build_generated_install_script(manifest) detect_script = build_generated_detection_script(manifest) @@ -971,7 +1761,7 @@ def register_setup_analyzer(app: Any, templates: Any, require_admin: Callable[[R manifest["assetmanager_package_id"] = package.id write_package_storage( package.id, - target, + deployment_source, install_script, uninstall_script, detect_script, diff --git a/app/software_control.py b/app/software_control.py index 7012d62..9d2e6eb 100755 --- a/app/software_control.py +++ b/app/software_control.py @@ -440,13 +440,14 @@ def _prepare_remote_directory( remote_dir: str, timeout: int, remote_file: str | None = None, + reset_acl: bool = True, ) -> subprocess.CompletedProcess: - """Create the job directory and remove a stale target file. + """Create the job directory and optionally remove one stale target file. - MeshCtrl's Upload action does not overwrite an existing file reliably on - all Windows agents. Failed jobs intentionally retain their directories, so - a repeated dispatch must explicitly remove a previous run.ps1 before the - upload starts. + ACLs are applied to the directory only. Never use a recursive icacls /T + here: directory inheritance flags such as (OI)(CI) must not be rewritten + onto already uploaded files. Doing so can leave files with no effective + ACEs and make them unreadable even for the SYSTEM account. """ if platform == 'windows': command=( @@ -455,12 +456,51 @@ def _prepare_remote_directory( ) if remote_file: command += "Remove-Item -LiteralPath '"+remote_file.replace("'","''")+"' -Force -ErrorAction SilentlyContinue;" - command += "& icacls.exe $p /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' /T /C|Out-Null" + if reset_acl: + command += ( + "& icacls.exe $p /inheritance:r " + "/grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' " + "/C|Out-Null;" + ) return _run_meshctrl(cfg,asset,password,['RunCommand','--id',asset.mesh_node_id,'--run',command,'--powershell','--reply'],timeout) command=f"mkdir -p '{remote_dir}'" if remote_file: command += f" && rm -f -- '{remote_file}'" - command += f" && chmod 700 '{remote_dir}'" + if reset_acl: + command += f" && chmod 700 '{remote_dir}'" + return _run_meshctrl(cfg,asset,password,['RunCommand','--id',asset.mesh_node_id,'--run',command,'--reply'],timeout) + + +def _remote_script_preflight( + cfg: dict, + asset: Asset, + password: str, + platform: str, + remote_file: str, + timeout: int, +) -> subprocess.CompletedProcess: + """Verify that the uploaded job script exists and is readable. + + The Windows diagnostic also prints the effective ACL so an upload/ACL + problem is visible in the dispatcher log before PowerShell is launched. + """ + if platform == 'windows': + escaped=remote_file.replace("'","''") + command=( + "$f='"+escaped+"';" + "$exists=Test-Path -LiteralPath $f -PathType Leaf;" + "Write-Output ('File exists: '+$exists);" + "if($exists){" + "try{$i=Get-Item -LiteralPath $f -ErrorAction Stop;Write-Output ('Size: '+$i.Length)}" + "catch{Write-Output ('Size: ERROR - '+$_.Exception.Message)};" + "try{$s=[System.IO.File]::Open($f,[System.IO.FileMode]::Open,[System.IO.FileAccess]::Read,[System.IO.FileShare]::ReadWrite);$s.Close();Write-Output 'Readable: True'}" + "catch{Write-Output ('Readable: False - '+$_.Exception.Message)};" + "Write-Output 'ACL:'; & icacls.exe $f" + "}" + ) + return _run_meshctrl(cfg,asset,password,['RunCommand','--id',asset.mesh_node_id,'--run',command,'--powershell','--reply'],timeout) + escaped=remote_file.replace("'","'\''") + command=f"test -f '{escaped}' && test -r '{escaped}' && ls -l '{escaped}'" return _run_meshctrl(cfg,asset,password,['RunCommand','--id',asset.mesh_node_id,'--run',command,'--reply'],timeout) @@ -510,6 +550,94 @@ def _cleanup_remote_directory(cfg: dict, asset: Asset, password: str, platform: action=['RunCommand','--id',asset.mesh_node_id,'--run',f"rm -rf -- '{remote_dir}'",'--reply'] return _run_meshctrl(cfg,asset,password,action,timeout) +def cleanup_stale_remote_job_directories( + cfg: dict, + asset: Asset, + password: str, + platform: str, + retention_hours: int, + timeout: int, + exclude_directory_names: list[str] | None = None, +) -> subprocess.CompletedProcess: + """Remove stale AssetManager job-attempt directories on the client. + + Windows age is based on directory CreationTimeUtc, not LastWriteTimeUtc. + That represents the age of the job workspace and is not extended when a + script or installer later touches files in that directory. Only numeric + AssetManager job directories (legacy ```` and current + ``-``) are considered. Active/current directory names + supplied by the caller are excluded. Deletion is best-effort. + """ + try: + retention = max(1, min(int(retention_hours), 8760)) + except (TypeError, ValueError): + retention = 24 + + excluded = sorted({str(name or '').strip() for name in (exclude_directory_names or []) if str(name or '').strip()}) + + if platform == 'windows': + root = r'C:\ProgramData\AssetManager\Jobs' + escaped_root = root.replace("'", "''") + excluded_ps = ','.join("'" + name.replace("'", "''") + "'" for name in excluded) + command = ( + f"$root='{escaped_root}';" + f"$cutoff=[DateTime]::UtcNow.AddHours(-{retention});" + f"$excluded=@({excluded_ps});" + "if(Test-Path -LiteralPath $root){" + "$found=0;$eligible=0;$removed=0;$failed=0;$young=0;$active=0;$ignored=0;" + "Get-ChildItem -LiteralPath $root -Directory -Force -ErrorAction SilentlyContinue|ForEach-Object{" + "$item=$_;$found++;" + "if($item.Name -notmatch '^[0-9]+(?:-[0-9]+)?$'){$ignored++;return};" + "if($excluded -contains $item.Name){$active++;Write-Output ('Kept active job directory: '+$item.FullName);return};" + "$created=$item.CreationTimeUtc;" + "if($created -ge $cutoff){$young++;return};" + "$eligible++;$dir=$item.FullName;" + "try{Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction Stop;$removed++;Write-Output ('Removed stale job directory: '+$dir+' created_utc='+$created.ToString('o'))}" + "catch{" + "$firstError=$_.Exception.Message;" + "try{" + "& icacls.exe $dir /inheritance:e /grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' /T /C /Q | Out-Null;" + "Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction Stop;" + "$removed++;Write-Output ('Removed stale job directory after ACL repair: '+$dir+' first_error='+$firstError)" + "}catch{$failed++;Write-Output ('Failed stale job directory: '+$dir+' - '+$_.Exception.Message+' first_error='+$firstError)}" + "}" + "};" + f"Write-Output ('Stale cleanup summary: found='+$found+' eligible='+$eligible+' removed='+$removed+' failed='+$failed+' young='+$young+' active='+$active+' ignored='+$ignored+' retention_hours={retention}')" + "}else{Write-Output 'Stale cleanup summary: job root not present'}" + ) + action = ['RunCommand','--id',asset.mesh_node_id,'--run',command,'--powershell','--reply'] + return _run_meshctrl(cfg,asset,password,action,timeout) + + root = '/var/lib/assetmanager/jobs' + minutes = retention * 60 + exclude_tests = ' '.join(f"! -name '{name}'" for name in excluded) + command = ( + f"root='{root}'; " + "if [ -d \"$root\" ]; then " + f"find \"$root\" -mindepth 1 -maxdepth 1 -type d -mmin +{minutes} \\( -name '[0-9]*' -o -name '[0-9]*-[0-9]*' \\) {exclude_tests} -print -exec rm -rf -- {{}} \\;; " + f"echo 'Stale cleanup completed; retention_hours={retention}'; " + "else echo 'Stale cleanup summary: job root not present'; fi" + ) + action = ['RunCommand','--id',asset.mesh_node_id,'--run',command,'--reply'] + return _run_meshctrl(cfg,asset,password,action,timeout) + + +def _cleanup_stale_remote_job_directories( + cfg: dict, + asset: Asset, + password: str, + platform: str, + current_remote_dir: str, + retention_hours: int, + timeout: int, +) -> subprocess.CompletedProcess: + """Compatibility wrapper for dispatcher-side cleanup.""" + current_name = Path(current_remote_dir.replace('\\', '/')).name + return cleanup_stale_remote_job_directories( + cfg, asset, password, platform, retention_hours, timeout, [current_name] if current_name else [] + ) + + def _add_job_event(db, job: SoftwareJob, event_type: str, status: str | None = None, message: str | None = None) -> None: db.add(JobEvent( @@ -562,7 +690,14 @@ def execute_job(job_id: int, token: str, callback_base: str) -> None: sync_asset_job_state(db, job) db.commit() - cfg=load_config().get('meshcentral',{}) + runtime_config=load_config() + cfg=runtime_config.get('meshcentral',{}) + software_settings=runtime_config.get('software',{}) + remote_cleanup_enabled=bool(software_settings.get('remote_job_cleanup_enabled', True)) + try: + remote_job_retention_hours=max(1,min(int(software_settings.get('remote_job_retention_hours',24) or 24),8760)) + except (TypeError,ValueError): + remote_job_retention_hours=24 password_env=str(cfg.get('password_env') or 'MESHCENTRAL_PASSWORD') password=os.getenv(password_env,'') if not password: raise RuntimeError(f'MeshCentral-Passwortvariable {password_env} ist nicht gesetzt.') @@ -586,6 +721,8 @@ def execute_job(job_id: int, token: str, callback_base: str) -> None: f'Mesh node id: {asset.mesh_node_id}', f'Interpreter: {interpreter}', f'Upload required: {upload_required}', + f'Remote stale cleanup enabled: {remote_cleanup_enabled}', + f'Remote job retention: {remote_job_retention_hours} hours', f'Resolved script characters: {len(payload)}', f'Resolved script SHA-256: {hashlib.sha256(payload.encode("utf-8")).hexdigest()}', ] @@ -614,6 +751,16 @@ def execute_job(job_id: int, token: str, callback_base: str) -> None: Path(temp_path).write_text(payload,encoding='utf-8',newline='\n') diagnostics += [f'Remote directory: {remote_dir}',f'Remote file: {remote_file}',f'Local staging bytes: {os.path.getsize(temp_path)}'] + if remote_cleanup_enabled: + stale_cleanup=_cleanup_stale_remote_job_directories( + cfg,asset,password,job.platform,remote_dir,remote_job_retention_hours,min(timeout,120) + ) + diagnostics += [ + '--- Stale remote job cleanup stdout ---',stale_cleanup.stdout or '', + '--- Stale remote job cleanup stderr ---',stale_cleanup.stderr or '', + f'Stale remote job cleanup return code: {stale_cleanup.returncode}', + ] + prepared=_prepare_remote_directory(cfg,asset,password,job.platform,remote_dir,timeout,remote_file) diagnostics += ['--- Prepare directory stdout ---',prepared.stdout or '','--- Prepare directory stderr ---',prepared.stderr or '',f'Prepare return code: {prepared.returncode}'] if prepared.returncode!=0: raise RuntimeError(f'Remote Jobverzeichnis konnte nicht erstellt werden: {prepared.stderr or prepared.stdout}') @@ -626,8 +773,8 @@ def execute_job(job_id: int, token: str, callback_base: str) -> None: upload_ok=uploaded.returncode==0 and 'Upload done' in upload_text and 'Upload error' not in upload_text if not upload_ok: # MeshCtrl can return code 0 together with "Upload error". - # Recreate the directory, remove any stale target and retry once. - repair=_prepare_remote_directory(cfg,asset,password,job.platform,remote_dir,min(timeout,120),remote_file) + # Remove only the stale target and retry once. Do not touch ACLs of already uploaded files. + repair=_prepare_remote_directory(cfg,asset,password,job.platform,remote_dir,min(timeout,120),remote_file,reset_acl=False) diagnostics += ['--- Upload repair stdout ---',repair.stdout or '','--- Upload repair stderr ---',repair.stderr or '',f'Upload repair return code: {repair.returncode}'] uploaded=_upload_script(cfg,asset,password,temp_path,remote_dir,timeout) upload_results.append(uploaded) @@ -662,6 +809,7 @@ def execute_job(job_id: int, token: str, callback_base: str) -> None: remote_dir, min(timeout, 120), remote_package_file, + reset_acl=False, ) diagnostics += [ f'--- Package upload {package_file.name} repair stdout ---', package_repair.stdout or '', @@ -687,6 +835,16 @@ def execute_job(job_id: int, token: str, callback_base: str) -> None: f'MeshCentral-Paketdateiupload fehlgeschlagen fuer {package_file.name}: {combined.strip()}' ) + preflight=_remote_script_preflight(cfg,asset,password,job.platform,remote_file,min(timeout,120)) + diagnostics += [ + '--- Remote script preflight stdout ---', preflight.stdout or '', + '--- Remote script preflight stderr ---', preflight.stderr or '', + f'Remote script preflight return code: {preflight.returncode}', + ] + preflight_text=(preflight.stdout or '')+'\n'+(preflight.stderr or '') + if preflight.returncode!=0 or (job.platform=='windows' and ('File exists: True' not in preflight_text or 'Readable: True' not in preflight_text)): + raise RuntimeError('Remote Jobskript ist nach dem Upload nicht lesbar. Siehe Remote script preflight im Dispatcherlog.') + diagnostics += [f'Remote execution shell: {launch_shell}',f'Remote execution command: {launch_command}',f'MeshCtrl PowerShell mode: False'] started=datetime.utcnow() result=_launch_uploaded_script(cfg,asset,password,job.platform,interpreter,remote_file,timeout) diff --git a/app/software_packages.py b/app/software_packages.py index c835a8d..90c9f0f 100755 --- a/app/software_packages.py +++ b/app/software_packages.py @@ -1,11 +1,14 @@ from __future__ import annotations import base64 +import hashlib +import io import json import os import re import shutil import uuid +import zipfile from pathlib import Path from typing import Any @@ -14,7 +17,8 @@ from sqlalchemy.orm import Session from .models import SoftwarePackage -PACKAGE_ROOT = Path(os.getenv("SOFTWARE_PACKAGE_DIR", "/app/data/software-packages")) +DATA_ROOT = Path(os.getenv("ASSETMANAGER_DATA_ROOT", "/assetmanager-data")) +PACKAGE_ROOT = Path(os.getenv("SOFTWARE_PACKAGE_DIR", str(DATA_ROOT / "software-packages"))) PACKAGE_ROOT.mkdir(parents=True, exist_ok=True) @@ -32,6 +36,18 @@ def _safe_member_name(value: str) -> str: return name +def _safe_relative_member_path(value: str) -> str: + name = str(value or "").replace("\\", "/").strip() + while name.startswith("./"): + name = name[2:] + if not name or name.startswith("/") or re.match(r"^[A-Za-z]:", name): + raise ValueError("invalid relative package member path") + parts = [part for part in name.split("/") if part not in {"", "."}] + if not parts or any(part == ".." for part in parts): + raise ValueError("invalid relative package member path") + return "/".join(parts) + + def _safe_package_label(value: str) -> str: text = re.sub(r"[\x00-\x1f]+", " ", str(value or "")).strip() return re.sub(r"\s+", " ", text)[:180] @@ -76,6 +92,15 @@ def write_package_storage( json.dumps(analysis, ensure_ascii=True, indent=2) + "\n", encoding="utf-8", ) + profile_id = str((analysis or {}).get("profile_id") or "").strip() + if profile_id: + try: + from .analyzer_profiles import export_profile_bundle + profile_dir = temporary / "metadata" + profile_dir.mkdir(parents=True, exist_ok=True) + (profile_dir / "analyzer-profile.amprofile").write_bytes(export_profile_bundle(profile_id)) + except Exception: + pass if target.exists(): shutil.rmtree(target) temporary.replace(target) @@ -151,16 +176,21 @@ def human_size(size: int) -> str: def package_summary(package: SoftwarePackage) -> dict[str, Any]: manifest: dict[str, Any] = {} - error = "" + storage_size = 0 + errors: list[str] = [] try: manifest = load_package_manifest(package.id) except Exception as exc: - error = str(exc) + errors.append(str(exc)) + try: + storage_size = package_storage_size(package.id) + except Exception as exc: + errors.append(str(exc)) return { "package": package, "manifest": manifest, - "storage_size": package_storage_size(package.id), - "storage_error": error, + "storage_size": storage_size, + "storage_error": "; ".join(error for error in errors if error), } @@ -756,20 +786,6 @@ def normalize_package_manifest(manifest: dict[str, Any]) -> tuple[dict[str, Any] result["install"] = install arguments = str(install.get("arguments") or "").strip() - if installer_type == "inno" and "greenshot" in identity: - before = arguments - current_user_scope = re.search(r"(?i)(^|\s)/CURRENTUSER(?=\s|$)", arguments) is not None - if not current_user_scope: - if not re.search(r"(?i)(^|\s)/ALLUSERS(?=\s|$)", arguments): - arguments = _append_install_argument(arguments, "/ALLUSERS", r"(^|\s)/ALLUSERS(?=\s|$)") - if not re.search(r"(?i)(^|\s)/DIR=", arguments): - arguments = (arguments + ' /DIR="C:\\Program Files\\Greenshot"').strip() - if arguments != before: - notes.append("greenshot_machine_scope") - install["arguments"] = arguments - if not current_user_scope: - result.setdefault("deployment_profile", "greenshot-machine") - existing_process_control = result.get("process_control") process_names_configured = isinstance(existing_process_control, dict) and "process_names" in existing_process_control process_control = result.setdefault("process_control", {}) @@ -777,9 +793,6 @@ def normalize_package_manifest(manifest: dict[str, Any]) -> tuple[dict[str, Any] process_control = {} result["process_control"] = process_control process_names = normalize_process_names(process_control.get("process_names")) - if "greenshot" in identity and not process_names and not process_names_configured: - process_names = ["Greenshot.exe"] - notes.append("greenshot_process_control") process_control["process_names"] = process_names try: grace_seconds = int(process_control.get("grace_seconds", 5)) @@ -806,15 +819,8 @@ def normalize_package_manifest(manifest: dict[str, Any]) -> tuple[dict[str, Any] if not isinstance(post_install, dict): post_install = {} result["post_install"] = post_install - if "greenshot" in identity and not start_configured: - post_install["start_application"] = True - notes.append("greenshot_post_install_start") - else: - post_install["start_application"] = _manifest_bool(post_install.get("start_application"), False) - if "greenshot" in identity and not executable_configured: - post_install["executable"] = r"C:\Program Files\Greenshot\Greenshot.exe" - else: - post_install["executable"] = _clean_manifest_text(post_install.get("executable"), 1024) + post_install["start_application"] = _manifest_bool(post_install.get("start_application"), False) + post_install["executable"] = _clean_manifest_text(post_install.get("executable"), 1024) post_install["arguments"] = _clean_manifest_text(post_install.get("arguments"), 2048) post_install["only_if_user_logged_on"] = _manifest_bool(post_install.get("only_if_user_logged_on"), True) post_install["fail_job_on_error"] = _manifest_bool(post_install.get("fail_job_on_error"), False) @@ -844,12 +850,15 @@ def build_generated_install_script(manifest: dict[str, Any]) -> str: install = manifest.get("install") or {} installer_type = str(manifest.get("installer_type") or "").strip().lower() installer_file = _safe_member_name(manifest.get("installer_file", "")) + source_mode = str(install.get("source_mode") or "direct").strip().lower() + embedded_installer = "" + if source_mode == "embedded_archive": + embedded_installer = _safe_relative_member_path(install.get("embedded_installer", "")) arguments = str(install.get("arguments") or "").strip() success_codes = _int_codes(install.get("success_codes"), [0]) reboot_codes = _int_codes(install.get("reboot_codes"), []) timeout_seconds = package_execution_timeout_seconds(manifest) suppress_browser = bool(install.get("suppress_browser")) - greenshot_preset = "greenshot_machine_scope" in notes or str(manifest.get("deployment_profile") or "") == "greenshot-machine" success = ", ".join(str(code) for code in success_codes) reboot = ", ".join(str(code) for code in reboot_codes) or "-999999" @@ -858,17 +867,38 @@ def build_generated_install_script(manifest: dict[str, Any]) -> str: "Set-StrictMode -Version Latest", "", "$packageDir = $PSScriptRoot", - f"$installer = Join-Path $packageDir {_ps_quote(installer_file)}", + f"$packageSource = Join-Path $packageDir {_ps_quote(installer_file)}", + f"$sourceMode = {_ps_quote(source_mode)}", + f"$embeddedInstaller = {_ps_quote(embedded_installer)}", + "$installer = $packageSource", + "$installerWorkingDirectory = $packageDir", f"$installerType = {_ps_quote(installer_type)}", f"$arguments = {_ps_quote(arguments)}", f"$successCodes = @({success})", f"$rebootCodes = @({reboot})", f"$timeoutSeconds = {timeout_seconds}", "$suppressBrowser = $" + ("true" if suppress_browser else "false"), - "$greenshotPreset = $" + ("true" if greenshot_preset else "false"), "$innoLog = $null", "", - "if (-not (Test-Path -LiteralPath $installer)) {", + "if (-not (Test-Path -LiteralPath $packageSource)) {", + "\tWrite-Error \"Package source not found: $packageSource\"", + "\texit 2", + "}", + "", + "if ($sourceMode -eq 'embedded_archive') {", + "\t$payloadDir = Join-Path $packageDir '_embedded_payload'", + "\tif (Test-Path -LiteralPath $payloadDir) { Remove-Item -LiteralPath $payloadDir -Recurse -Force -ErrorAction Stop }", + "\tNew-Item -ItemType Directory -Path $payloadDir -Force | Out-Null", + "\tExpand-Archive -LiteralPath $packageSource -DestinationPath $payloadDir -Force", + "\t$embeddedWindowsPath = $embeddedInstaller.Replace('/', '\\')", + "\t$installer = Join-Path $payloadDir $embeddedWindowsPath", + "\t$installerWorkingDirectory = [System.IO.Path]::GetDirectoryName($installer)", + "\tif ([string]::IsNullOrWhiteSpace($installerWorkingDirectory)) { $installerWorkingDirectory = $payloadDir }", + "\tWrite-Output (\"Embedded payload extracted: \" + $payloadDir)", + "\tWrite-Output (\"Embedded installer selected: \" + $embeddedInstaller)", + "}", + "", + "if (-not (Test-Path -LiteralPath $installer -PathType Leaf)) {", "\tWrite-Error \"Installer not found: $installer\"", "\texit 2", "}", @@ -882,8 +912,7 @@ def build_generated_install_script(manifest: dict[str, Any]) -> str: "", "Write-Output (\"Installer file: \" + $installer)", "Write-Output (\"Installer type: \" + $installerType)", - "Write-Output (\"Installer working directory: \" + $packageDir)", - "if ($greenshotPreset) { Write-Output 'Greenshot deployment preset: machine scope, C:\\Program Files\\Greenshot' }", + "Write-Output (\"Installer working directory: \" + $installerWorkingDirectory)", "", "$browserPidsBefore = @()", "if ($suppressBrowser) {", @@ -897,23 +926,23 @@ def build_generated_install_script(manifest: dict[str, Any]) -> str: if installer_type == "msi": lines += [ "$processArguments = '/i \"' + $installer + '\" ' + $arguments", - "$process = Start-Process -FilePath 'msiexec.exe' -ArgumentList $processArguments -WorkingDirectory $packageDir -PassThru -NoNewWindow", + "$process = Start-Process -FilePath 'msiexec.exe' -ArgumentList $processArguments -WorkingDirectory $installerWorkingDirectory -PassThru -NoNewWindow", ] elif installer_type == "msp": lines += [ "$processArguments = '/p \"' + $installer + '\" ' + $arguments", - "$process = Start-Process -FilePath 'msiexec.exe' -ArgumentList $processArguments -WorkingDirectory $packageDir -PassThru -NoNewWindow", + "$process = Start-Process -FilePath 'msiexec.exe' -ArgumentList $processArguments -WorkingDirectory $installerWorkingDirectory -PassThru -NoNewWindow", ] elif installer_type == "msu": lines += [ "$processArguments = '\"' + $installer + '\" ' + $arguments", - "$process = Start-Process -FilePath 'wusa.exe' -ArgumentList $processArguments -WorkingDirectory $packageDir -PassThru -NoNewWindow", + "$process = Start-Process -FilePath 'wusa.exe' -ArgumentList $processArguments -WorkingDirectory $installerWorkingDirectory -PassThru -NoNewWindow", ] elif installer_type in {"msix", "appx"}: lines += ["Add-AppxPackage -Path $installer -ErrorAction Stop", "exit 0"] return "\n".join(lines) + "\n" else: - lines.append("$process = Start-Process -FilePath $installer -ArgumentList $arguments -WorkingDirectory $packageDir -PassThru -NoNewWindow") + lines.append("$process = Start-Process -FilePath $installer -ArgumentList $arguments -WorkingDirectory $installerWorkingDirectory -PassThru -NoNewWindow") lines += [ "Write-Output (\"Installer PID: \" + $process.Id)", @@ -1417,3 +1446,183 @@ def build_deployment_user_script(manifest: dict[str, Any], action: str) -> str: "Write-JobLog (\"Software deployment completed: $deploymentPackage; action=$deploymentAction; reboot=$rebootRequired\")", ] return "\n".join(lines) + +# v0.5.5.90 portable software-package bundles +PACKAGE_BUNDLE_SCHEMA = "assetmanager-software-package-bundle-v1" +PACKAGE_IMPORT_MAX_MB = max(64, int(os.getenv("SOFTWARE_PACKAGE_IMPORT_MAX_MB", "8192"))) +PACKAGE_IMPORT_MAX_FILES = max(100, int(os.getenv("SOFTWARE_PACKAGE_IMPORT_MAX_FILES", "20000"))) + + +def _sha256_path(path: Path) -> str: + import hashlib + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def export_package_bundle(package_id: int, app_version: str = "") -> bytes: + root = package_directory(package_id) + manifest = load_package_manifest(package_id) + if not root.is_dir(): + raise FileNotFoundError("package storage not found") + files: dict[str, dict[str, Any]] = {} + for path in sorted(root.rglob("*")): + if not path.is_file(): + continue + rel = path.relative_to(root).as_posix() + _safe_relative_member_path(rel) + files[rel] = {"sha256": _sha256_path(path), "size": path.stat().st_size} + + embedded_profile: bytes | None = None + profile_member = "metadata/analyzer-profile.amprofile" + stored_profile_path = root / profile_member + if stored_profile_path.is_file(): + embedded_profile = stored_profile_path.read_bytes() + profile_id = str((manifest.get("analysis") or {}).get("profile_id") or "").strip() + if embedded_profile is None and profile_id: + try: + from .analyzer_profiles import export_profile_bundle + embedded_profile = export_profile_bundle(profile_id) + except Exception: + embedded_profile = None + if embedded_profile is not None: + files[profile_member] = { + "sha256": hashlib.sha256(embedded_profile).hexdigest(), + "size": len(embedded_profile), + } + + export_manifest = { + "schema": PACKAGE_BUNDLE_SCHEMA, + "bundle_version": 1, + "application": "AssetManager", + "application_version": str(app_version or ""), + "package_schema": str(manifest.get("schema") or ""), + "name": str(manifest.get("name") or ""), + "version": str(manifest.get("version") or ""), + "profile_id": profile_id, + "files": files, + } + stream = io.BytesIO() + with zipfile.ZipFile(stream, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=6) as archive: + archive.writestr("assetmanager-export.json", json.dumps(export_manifest, ensure_ascii=True, indent=2) + "\n") + for rel in files: + if rel == "metadata/analyzer-profile.amprofile": + if embedded_profile is not None: + archive.writestr(rel, embedded_profile) + else: + archive.write(root / rel, arcname=rel) + stream.seek(0) + return stream.read() + + +def _validate_package_zip_member(info: zipfile.ZipInfo) -> str: + name = str(info.filename or "").replace("\\", "/") + if not name or name.endswith("/"): + return "" + name = _safe_relative_member_path(name) + mode = (info.external_attr >> 16) & 0o170000 + if mode == 0o120000: + raise ValueError("symbolic links are not allowed in package bundles") + return name + + +def import_package_bundle(db: Session, data: bytes | Path, source_name: str = "", import_profile: bool = False) -> SoftwarePackage: + if isinstance(data, Path): + if not data.is_file() or data.stat().st_size <= 0: + raise ValueError("package bundle is empty") + if data.stat().st_size > PACKAGE_IMPORT_MAX_MB * 1024 * 1024: + raise ValueError("package bundle exceeds import size limit") + zip_source: Any = data + else: + if not data: + raise ValueError("package bundle is empty") + if len(data) > PACKAGE_IMPORT_MAX_MB * 1024 * 1024: + raise ValueError("package bundle exceeds import size limit") + zip_source = io.BytesIO(data) + temporary_root = PACKAGE_ROOT / f".import-{uuid.uuid4().hex}.tmp" + temporary_root.mkdir(parents=True, exist_ok=False) + package: SoftwarePackage | None = None + embedded_profile_data: bytes | None = None + try: + with zipfile.ZipFile(zip_source, "r") as archive: + infos = [info for info in archive.infolist() if not info.is_dir()] + if len(infos) > PACKAGE_IMPORT_MAX_FILES: + raise ValueError("package bundle contains too many files") + total = sum(max(0, int(info.file_size)) for info in infos) + if total > PACKAGE_IMPORT_MAX_MB * 1024 * 1024: + raise ValueError("expanded package bundle exceeds import size limit") + members: dict[str, zipfile.ZipInfo] = {} + for info in infos: + name = _validate_package_zip_member(info) + if not name: + continue + if name in members: + raise ValueError("duplicate package bundle member") + members[name] = info + if "package.json" not in members: + raise ValueError("package.json is missing") + export_meta: dict[str, Any] = {} + if "assetmanager-export.json" in members: + export_meta = json.loads(archive.read(members["assetmanager-export.json"])) + if str(export_meta.get("schema") or "") != PACKAGE_BUNDLE_SCHEMA: + raise ValueError("unsupported AssetManager package bundle") + manifest = json.loads(archive.read(members["package.json"])) + manifest, _notes = normalize_package_manifest(manifest) + installer = _safe_member_name(manifest.get("installer_file", "")) + required = {"package.json", installer, _safe_member_name((manifest.get("install") or {}).get("script", "install.ps1")), _safe_member_name((manifest.get("uninstall") or {}).get("script", "uninstall.ps1"))} + if str((manifest.get("detection") or {}).get("method") or "manual") != "manual": + required.add(_safe_member_name((manifest.get("detection") or {}).get("script", "detect.ps1"))) + missing = sorted(name for name in required if name not in members) + if missing: + raise ValueError("package bundle is incomplete: " + ", ".join(missing)) + checksums = export_meta.get("files") or {} + for name, info in members.items(): + if name == "assetmanager-export.json": + continue + content = archive.read(info) + if name == "metadata/analyzer-profile.amprofile": + embedded_profile_data = content + if name in checksums: + expected = str((checksums.get(name) or {}).get("sha256") or "").lower() + if expected and hashlib.sha256(content).hexdigest() != expected: + raise ValueError(f"checksum mismatch for {name}") + if name in checksums: + import hashlib + expected = str((checksums.get(name) or {}).get("sha256") or "").lower() + if expected and hashlib.sha256(content).hexdigest() != expected: + raise ValueError(f"checksum mismatch for {name}") + target = temporary_root / name + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(content) + + package = SoftwarePackage( + name=unique_package_name(db, str(manifest.get("name") or Path(source_name or "Imported package").stem), str(manifest.get("version") or "")), + description=f"Imported AssetManager package | {str(manifest.get('vendor') or '').strip()}".strip(" |"), + package_type="deployment", + enabled=True, + is_system=False, + command_windows="install.ps1", + callback_timeout_minutes=max(5, min(((package_execution_timeout_seconds(manifest) + 59) // 60) + 5, 240)), + ) + db.add(package) + db.flush() + manifest["assetmanager_package_id"] = package.id + (temporary_root / "package.json").write_text(json.dumps(manifest, ensure_ascii=True, indent=2) + "\n", encoding="utf-8") + (temporary_root / "assetmanager-export.json").unlink(missing_ok=True) + target_root = package_directory(package.id) + if target_root.exists(): + shutil.rmtree(target_root) + temporary_root.replace(target_root) + db.commit() + if import_profile and embedded_profile_data: + from .analyzer_profiles import import_profile_bundle + import_profile_bundle(embedded_profile_data, source="community") + return package + except Exception: + db.rollback() + shutil.rmtree(temporary_root, ignore_errors=True) + if package is not None and getattr(package, "id", None): + shutil.rmtree(package_directory(package.id), ignore_errors=True) + raise diff --git a/app/static/css/app.css b/app/static/css/app.css index 99462ce..6db1ba7 100755 --- a/app/static/css/app.css +++ b/app/static/css/app.css @@ -3327,4 +3327,102 @@ html[data-theme="dark"] .standard-data-table-scroll > .data-table > thead > .col width:10px !important; z-index:40 !important; } +/* v0.5.5.89: software package overview actions */ +.software-package-row-actions{ + display:flex; + align-items:center; + gap:6px; + white-space:nowrap; +} +.software-package-row-actions .inline-form{ + display:inline-flex; + align-items:center; + margin:0; +} + +/* v0.5.5.90: analyzer-profile and package exchange controls */ +.software-package-import-form{ + display:flex; + flex-direction:column; + align-items:flex-start; + gap:10px; +} +.software-package-import-form > label:not(.checkbox-label){ + display:flex; + flex-direction:column; + align-items:flex-start; + gap:5px; +} +.software-package-import-options{ + display:flex; + flex-direction:column; + align-items:flex-start; + gap:.65rem; + width:100%; + margin:.2rem 0 .45rem; +} +.software-package-import-options .checkbox-label{ + display:inline-flex!important; + flex-direction:row!important; + align-items:flex-start!important; + justify-content:flex-start!important; + gap:.55rem!important; + width:auto!important; + max-width:min(100%,900px)!important; + margin:0!important; + text-align:left!important; + line-height:1.35; +} +.software-package-import-options .checkbox-label input[type=checkbox]{ + display:inline-block!important; + width:16px!important; + min-width:16px!important; + height:16px!important; + flex:0 0 16px!important; + margin:.12rem 0 0!important; + padding:0!important; +} +.software-package-import-options .checkbox-label span{ + display:block; + min-width:0; +} + + +/* v0.5.5.90: translated file selector used by analyzer/package exchange pages. */ +.localized-file-picker{ + display:flex; + align-items:center; + flex-wrap:wrap; + gap:.65rem; + min-width:0; +} +.localized-file-picker-input{ + position:absolute!important; + width:1px!important; + height:1px!important; + padding:0!important; + margin:-1px!important; + overflow:hidden!important; + clip:rect(0,0,0,0)!important; + white-space:nowrap!important; + border:0!important; +} +.localized-file-picker-button{ + display:inline-flex; + align-items:center; + margin:0!important; + font-weight:normal; + cursor:pointer; +} +.localized-file-picker-name{ + min-width:0; + max-width:min(70vw,720px); + overflow:hidden; + text-overflow:ellipsis; + white-space:nowrap; + color:var(--am-text,#1f2933); +} +html[data-theme="dark"] .localized-file-picker-name{ + color:#edf3f8; +} diff --git a/app/static/js/file-picker.js b/app/static/js/file-picker.js new file mode 100755 index 0000000..19af7ec --- /dev/null +++ b/app/static/js/file-picker.js @@ -0,0 +1,24 @@ +(() => { + function bindPicker(picker) { + const input = picker.querySelector('[data-file-picker-input]'); + const output = picker.querySelector('[data-file-picker-name]'); + const button = picker.querySelector('[data-file-picker-button]'); + if (!input || !output || input.dataset.filePickerBound === '1') return; + input.dataset.filePickerBound = '1'; + const emptyText = output.dataset.emptyText || ''; + const refresh = () => { + const files = Array.from(input.files || []); + output.textContent = files.length ? files.map(file => file.name).join(', ') : emptyText; + output.title = output.textContent; + }; + button?.addEventListener('click', () => input.click()); + input.addEventListener('change', refresh); + refresh(); + } + + function bindAll(root = document) { + root.querySelectorAll('[data-file-picker]').forEach(bindPicker); + } + + bindAll(); +})(); diff --git a/app/templates/base.html b/app/templates/base.html index c82dd82..a8aa2fb 100755 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -136,5 +136,5 @@ document.documentElement.classList.toggle('sidebar-collapsed-preset', collapsed); }); })(); - + diff --git a/app/templates/settings.html b/app/templates/settings.html index 531a202..6570ee9 100755 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -82,7 +82,7 @@

{{ t('settings.system_information') }}

{{ t('settings.system_information_file_help') }}

-
{{ t('settings.appinfo_path') }}
/app/config/APPINFO.json
+
{{ t('settings.appinfo_path') }}
{{ application_info_path() }}
{{ t('about.version') }}
{{ application_version() }}
{{ t('about.author') }}
{{ appinfo.author or '—' }}
{{ t('about.organization') }}
{{ appinfo.organization or '—' }}
diff --git a/app/templates/settings_backup_placeholder.html b/app/templates/settings_backup_placeholder.html index e55c4d2..ed01921 100755 --- a/app/templates/settings_backup_placeholder.html +++ b/app/templates/settings_backup_placeholder.html @@ -8,6 +8,6 @@

{{ t('settings.backup_planned', 'Datenbanksicherung ist für v0.3.15.0 vorgesehen') }}

{{ t('settings.backup_planned_help', 'Die automatische Sicherung alle 8 Stunden, eine Aufbewahrung von 3 Tagen sowie Download, Upload, manuelles Backup und Wiederherstellung werden im nächsten Entwicklungsschritt umgesetzt.') }}

-

{{ t('settings.backup_directory', 'Backup-Verzeichnis') }}: /app/data/backups

+

{{ t('settings.backup_directory', 'Backup-Verzeichnis') }}: /assetmanager-data/backups

{% endblock %} diff --git a/app/templates/settings_software.html b/app/templates/settings_software.html index 86afd34..f2706b3 100755 --- a/app/templates/settings_software.html +++ b/app/templates/settings_software.html @@ -76,6 +76,39 @@ +
+
+
+

{{ t('software.settings.remote_cleanup_title') }}

+

{{ t('software.settings.remote_cleanup_help') }}

+
+ {{ t('software.settings.remote_cleanup_badge') }} +
+ +
+ + + + +
+ {{ t('software.settings.remote_cleanup_paths') }} + C:\ProgramData\AssetManager\Jobs + /var/lib/assetmanager/jobs +
+
+
+
diff --git a/app/templates/setup_analyzer.html b/app/templates/setup_analyzer.html index 8e7e9dd..9b043df 100755 --- a/app/templates/setup_analyzer.html +++ b/app/templates/setup_analyzer.html @@ -6,6 +6,7 @@

{{ t('setup_analyzer.subtitle') }}

@@ -15,9 +16,12 @@

{{ t('setup_analyzer.upload_title') }}

{{ t('setup_analyzer.static_note') }}

- + +
+ + + {{ t('common.no_file_selected') }} +

{{ t('setup_analyzer.max_upload', size=max_upload_mb) }}

@@ -63,6 +67,45 @@
+{% if analysis.sfx_analysis %} +
+

{{ t('setup_analyzer.sfx_title') }}

+
+
{{ t('setup_analyzer.sfx_container') }}
{{ analysis.sfx_analysis.container_label }}
+
{{ t('setup_analyzer.sfx_status') }}
{{ t('setup_analyzer.sfx_status.' ~ analysis.sfx_analysis.status) }}
+
{{ t('setup_analyzer.sfx_extractor') }}
{{ analysis.sfx_analysis.extractor or t('setup_analyzer.not_available') }}
+
{{ t('setup_analyzer.sfx_files') }}
{{ analysis.sfx_analysis.file_count }}
+
{{ t('setup_analyzer.sfx_size') }}
{{ analysis.sfx_analysis.extracted_size_human }}
+ {% if analysis.sfx_analysis.selected %} +
{{ t('setup_analyzer.sfx_selected') }}
{{ analysis.sfx_analysis.selected.relative_path }}
+
{{ t('setup_analyzer.technology') }}
{{ analysis.sfx_analysis.selected.installer_label }} ({{ analysis.sfx_analysis.selected.confidence }} %)
+ {% endif %} +
+ {% if analysis.sfx_analysis.candidates %} +
+ {{ t('setup_analyzer.sfx_candidates') }} ({{ analysis.sfx_analysis.candidates|length }}) +
+ + + + {% for candidate in analysis.sfx_analysis.candidates %} + + + + + + + + {% endfor %} + +
{{ t('setup_analyzer.file') }}{{ t('setup_analyzer.technology') }}{{ t('setup_analyzer.confidence') }}{{ t('setup_analyzer.product_name') }}{{ t('setup_analyzer.version') }}
{{ candidate.relative_path }}{{ candidate.installer_label }}{{ candidate.confidence }} %{{ candidate.product_name }}{{ candidate.product_version }}
+
+
+ {% endif %} + {% if analysis.sfx_analysis.error %}

{{ analysis.sfx_analysis.error }}

{% endif %} +
+{% endif %} + {% if analysis.warning_keys %}

{{ t('setup_analyzer.notes') }}

@@ -97,6 +140,7 @@

{{ t('setup_analyzer.recommended_command') }}:
{{ analysis.install_command }}

+ {% if analysis.alternative_install_command %}

{{ t('setup_analyzer.alternative_command') }}:
{{ analysis.alternative_install_command }}

{% endif %} @@ -116,7 +160,7 @@ {{ t('setup_analyzer.suppress_browser_help') }} {{ t('setup_analyzer.process_names_help') }}
@@ -164,6 +208,15 @@
{{ t('setup_analyzer.pefile') }}
{{ t('common.yes') if analysis.pefile_available else t('common.no') }}
msiinfo
{{ t('common.yes') if analysis.msiinfo_available else t('common.no') }}
+
7-Zip
{{ t('common.yes') if analysis.archive_tools.sevenzip_available else t('common.no') }}
+
unar / lsar
{{ t('common.yes') if analysis.archive_tools.unar_available else t('common.no') }}
+
{{ t('setup_analyzer.sfx_limits') }}
{{ max_extracted_mb }} MB / {{ max_extracted_files }} {{ t('setup_analyzer.sfx_files') }} / {{ t('setup_analyzer.sfx_depth', depth=max_sfx_depth) }}
+ {% if analysis.launcher_architecture %}
{{ t('setup_analyzer.launcher_architecture') }}
{{ analysis.launcher_architecture }}
{% endif %} + {% if analysis.architecture_source %}
{{ t('setup_analyzer.architecture_source') }}
{{ t('setup_analyzer.architecture_source.' ~ analysis.architecture_source) }}
{% endif %} + {% if analysis.product_version_source %}
{{ t('setup_analyzer.version_source') }}
{{ t('setup_analyzer.metadata_source.' ~ analysis.product_version_source) }}
{% endif %} + {% if analysis.manufacturer_source %}
{{ t('setup_analyzer.manufacturer_source') }}
{{ t('setup_analyzer.metadata_source.' ~ analysis.manufacturer_source) }}
{% endif %} + {% if analysis.profile_name %}
{{ t('setup_profiles.matched_profile') }}
{{ analysis.profile_name }} {{ analysis.profile_version or '' }} {{ analysis.profile_id }}
{% endif %} + {% if analysis.profile_source %}
{{ t('setup_profiles.profile_source') }}
{{ t('setup_profiles.source.' ~ analysis.profile_source) }}
{% endif %} {% if analysis.original_filename %}
OriginalFilename
{{ analysis.original_filename }}
{% endif %}
diff --git a/app/templates/setup_analyzer_profiles.html b/app/templates/setup_analyzer_profiles.html new file mode 100755 index 0000000..1280e67 --- /dev/null +++ b/app/templates/setup_analyzer_profiles.html @@ -0,0 +1,111 @@ +{% extends 'base.html' %} +{% block content %} +
+
+

{{ t('setup_profiles.title') }}

+

{{ t('setup_profiles.subtitle') }}

+
+ +
+ +
+

{{ t('setup_profiles.import_title') }}

+

{{ t('setup_profiles.import_help') }}

+
+ +
+ + + {{ t('common.no_file_selected') }} +
+ + +
+
+ +
+

{{ t('setup_profiles.installed_title') }}

+
+ + + + + + + + + + + + + + + {% for profile in profiles %} + + + + + + + + + + + {% else %} + + {% endfor %} + +
{{ t('setup_profiles.name') }}{{ t('setup_profiles.id') }}{{ t('setup_analyzer.version') }}{{ t('setup_profiles.kind') }}{{ t('setup_profiles.stage') }}{{ t('setup_profiles.source') }}{{ t('software_packages.status') }}{{ t('jobs.actions') }}
{{ profile.name }}{{ profile.id }}{{ profile.version }}{{ profile.kind }}{{ profile.stage }}{{ t('setup_profiles.source.' ~ profile.source) }}{% if profile.enabled %}{{ t('software_packages.enabled') }}{% else %}{{ t('software_packages.disabled') }}{% endif %} +
+ {{ t('setup_profiles.export_button') }} +
+ + +
+ {% if profile.source != 'system' %} +
+ +
+ {% endif %} +
+
{{ t('setup_profiles.none') }}
+
+
+ +
+

{{ t('setup_profiles.repository_title') }}

+ {% if repository.url %} +

{{ t('setup_profiles.repository_url') }} {{ repository.url }}

+ {% if not request.query_params.get('repository') %} + {{ t('setup_profiles.repository_load') }} + {% elif repository_error %} +
{{ repository_error }}
+ {% else %} +
+ + + + {% for item in repository.profiles %} + + + + + {% else %}{% endfor %} + +
{{ t('setup_profiles.name') }}{{ t('setup_analyzer.version') }}{{ t('setup_profiles.id') }}{{ t('jobs.actions') }}
{{ item.name or item.id }}{{ item.version or '—' }}{{ item.id }}
{{ t('setup_profiles.repository_empty') }}
+
+ {% endif %} + {% else %} +

{{ t('setup_profiles.repository_not_configured') }}

+

ANALYZER_PROFILE_REPOSITORY_URL=https://.../index.json

+ {% endif %} +
+{% endblock %} diff --git a/app/templates/software_package.html b/app/templates/software_package.html index 273399d..5e1197b 100755 --- a/app/templates/software_package.html +++ b/app/templates/software_package.html @@ -6,6 +6,7 @@

{{ t('software_packages.detail_subtitle') }}

@@ -51,7 +52,7 @@

{{ t('software_packages.process_control_help') }}

{{ t('software_packages.process_names_help') }} {{ t('software_packages.start_executable_help') }}