From 610a3bb44760b18da6287e3d3d4df82e7878cf38 Mon Sep 17 00:00:00 2001 From: oceans2alaska Date: Wed, 4 Nov 2026 18:50:24 -0800 Subject: [PATCH] Privacy hardening, remote-peer IP masking and review fixes - Add privacy projection (core/privacy.py), private report storage, bounded process capture; omit identities, free text and unapproved paths - Mask remote peer IPs: only loopback/host/wildcard stay numeric, others are reduced to remote_scope private/public (schema + scoring updated) - Map ss TCP states to canonical names; ASCII-only numeric fields; fix "Microsoft Visual C++" product matching (Python and PowerShell) - Merge 7fed3b9: -Diagnostic switch, serialization exit code 3, PS7 module path, UI error detail; keep hardened task/certificate collectors - Add audit docs, privacy docs and regression tests Co-Authored-By: Claude Sonnet 5.5 --- README.md | 253 ++++++++++------- audit/DEPENDENCY_INVENTORY.md | 104 +++++++ audit/PHI_DATA_FLOW.md | 90 ++++++ audit/READ_ONLY_VERIFICATION.md | 111 ++++++++ audit/REMEDIATION_PLAN.md | 133 +++++++++ audit/SECURITY_AUDIT.md | 248 +++++++++++++++++ constraints-tested-linux.txt | 9 + docs/DEVELOPMENT.md | 4 + docs/PLATFORMS.md | 2 + docs/PRIVACY_HARDENING.md | 202 ++++++++++++++ docs/SCORING.md | 8 +- docs/SECURITY_REMEDIATION_STATUS.md | 52 ++++ docs/WINDOWS-COLLECTOR-RELIABILITY.md | 5 + powershell/Get-ServerCertificates.ps1 | 28 +- powershell/Get-ServerInventory.ps1 | 1 + powershell/Get-ServerNetwork.ps1 | 2 +- powershell/Get-ServerRoles.ps1 | 4 +- powershell/Get-ServerServices.ps1 | 8 +- powershell/Get-ServerSoftware.ps1 | 11 +- powershell/Get-ServerStorage.ps1 | 49 ++-- powershell/Get-ServerSystem.ps1 | 16 +- powershell/Get-ServerTasks.ps1 | 35 +-- powershell/Inventory.Common.ps1 | 50 +++- powershell/README.md | 55 ++-- requirements-headless.txt | 1 + requirements.txt | 4 +- samsara/analyzers/dependencies.py | 10 +- samsara/analyzers/policy.py | 1 + samsara/assets/privacy_catalogue.json | 379 ++++++++++++++++++++++++++ samsara/assets/privacy_policy.json | 9 + samsara/collectors/importer.py | 46 +--- samsara/collectors/linux.py | 74 ++--- samsara/collectors/windows.py | 65 ++--- samsara/core/detection.py | 2 +- samsara/core/privacy.py | 259 ++++++++++++++++++ samsara/core/process.py | 62 +++++ samsara/core/storage.py | 118 ++++++++ samsara/models/inventory.py | 22 +- samsara/reporting/export.py | 45 +-- samsara/ui/window.py | 57 +++- schemas/inventory-v1.schema.json | 303 +++++++++++++++----- tests/powershell/Test-Collectors.ps1 | 101 +------ tests/report_helpers.py | 14 + tests/test_core.py | 2 +- tests/test_import.py | 2 +- tests/test_linux.py | 41 ++- tests/test_privacy.py | 130 +++++++++ tests/test_scoring.py | 25 +- tests/test_ui.py | 21 +- tests/test_windows.py | 37 +-- tools/validate_windows_host.py | 13 +- 51 files changed, 2719 insertions(+), 604 deletions(-) create mode 100644 audit/DEPENDENCY_INVENTORY.md create mode 100644 audit/PHI_DATA_FLOW.md create mode 100644 audit/READ_ONLY_VERIFICATION.md create mode 100644 audit/REMEDIATION_PLAN.md create mode 100644 audit/SECURITY_AUDIT.md create mode 100644 constraints-tested-linux.txt create mode 100644 docs/PRIVACY_HARDENING.md create mode 100644 docs/SECURITY_REMEDIATION_STATUS.md create mode 100644 requirements-headless.txt create mode 100644 samsara/assets/privacy_catalogue.json create mode 100644 samsara/assets/privacy_policy.json create mode 100644 samsara/core/privacy.py create mode 100644 samsara/core/process.py create mode 100644 samsara/core/storage.py create mode 100644 tests/report_helpers.py create mode 100644 tests/test_privacy.py diff --git a/README.md b/README.md index b61eb36..6a16a86 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,18 @@ # Samsara -Source repository: [oceans2alaska/Samsara](https://git.doorstodurin.com/oceans2alaska/Samsara). +[Source repository](https://git.doorstodurin.com/oceans2alaska/Samsara). +Windows Server and Red Hat Enterprise Linux retirement assessment using Python 3.11+ +and PySide6. Inventory, dependency evidence and heuristic retirement risk remain +separate from historical usage validation and administrator retirement approval. -Windows Server and Red Hat Enterprise Linux retirement assessment. Collect inventory, review dependencies and risks, and save evidence-based reports. Python 3.11+ and PySide6. +**Privacy hardening is implemented, but production approval is pending.** This is +not a HIPAA compliance or PHI-free attestation. Read +[privacy controls, configuration and acceptance gates](docs/PRIVACY_HARDENING.md) +before collecting real inventory. Historical audit documents remain in `audit/`. -## Get started +## Desktop setup + +From the project root on a Linux desktop: ```bash python3 -m venv .venv @@ -13,135 +21,184 @@ python -m pip install -r requirements.txt python -m samsara.main ``` -On Windows, create the environment with `py -3 -m venv .venv` and activate `.venv\Scripts\activate`. Run from the project root in a desktop session. +On Windows, use `py -3 -m venv .venv`, then +`.venv\Scripts\python.exe -m pip install -r requirements.txt` and +`.venv\Scripts\python.exe -m samsara.main`. -Linux desktop compatibility includes Mint. Debian-based desktops may need `python3-venv libegl1 libopengl0 libxcb-cursor0 libxkbcommon-x11-0` installed through their package manager. If Qt reports a missing xcb cursor library, install the package providing `libxcb-cursor.so.0`. +Desktop dependencies are pinned to `PySide6_Essentials` (Qt Core/Gui/Widgets) and +`jsonschema`; WebEngine, PDF and Qt Addons are not required. The existing development +environment still includes the former umbrella packages; requirements changes do +not uninstall them. `constraints-tested-linux.txt` records observed versions, not +a complete platform/hash lock or vulnerability clearance. Obtain packages using +approved provisioning; installation normally requires external downloads. -## Analyze +Linux desktop compatibility includes Mint. Debian-based desktops may need +`python3-venv libegl1 libopengl0 libxcb-cursor0 libxkbcommon-x11-0` through their +package manager. Run the GUI in a graphical desktop session. -1. Open **Analyze**. Leave **Assessment source → This computer** and **Operating system → Auto-detect** selected. -2. Click **Analyze**. The app identifies the local OS, runs its available read-only collector and prepares the assessment report. -3. The **Summary** page opens automatically with a retirement risk estimate, OS type/version, important software and enabled scheduled jobs/scripts. Higher scores mean more retirement concerns; the score is not retirement approval. -4. Open detailed tabs for **Networking**, **Software**, **Tasks**, **Dependencies**, **Services**, **Storage**, **Roles**, **Certificates**, **Risk findings**, **Collection checks** and **System**. Tables are searchable and sortable. -5. Click **Save HTML report**, or use **Reports** to export HTML/JSON. **History** reopens assessments from this session. +## Assessment workflow -Explicit profiles include Windows Server 2016, 2019, 2022 and 2025; RHEL 7 (legacy), 8, 9 and 10; and RHEL-compatible Linux. A local profile must match the detected OS. Auto-detect also allows local testing on other Linux distributions. Profile choices do not imply that every live collector is implemented or every release is validated. +1. Choose **Analyze → This computer → Auto-detect → Analyze** to assess the execution + host. Explicit profiles include Server 2016/2019/2022/2025 and RHEL 7 (legacy), + 8/9/10 or compatible distributions. A selected profile must match the host. +2. Review **Summary**: OS/version, known software families, scheduled-work state, + retirement risk, evidence coverage and confidence. A low score is not clearance. +3. Use searchable/sortable detailed tabs: Networking, Software, Tasks, Dependencies, + Services, Storage & shares, Roles, Certificates, Risk findings, Collection checks + and System. Omitted fields and missing scopes require manual verification. +4. Save minimized HTML or JSON directly inside the configured private local report + directory. New filenames only. HTML creates no raw inventory sidecar. +5. **History** retains up to ten minimized assessments in the session by default. + **Clear session assessments** releases application references; it does not securely + erase memory or delete exports. -Choose **Saved inventory file** to analyze existing JSON; the recorded OS is displayed without probing another computer. **Windows Server sample** and **RHEL sample** provide clearly labeled synthetic examples. No synthetic results are loaded into history automatically. +**Saved inventory file** imports bounded local JSON and recomputes the assessment. +Source claims and old findings are not trusted. Legacy input enters memory before +filtering: do not intentionally import known patient-bearing files to redact them. +**Windows Server sample** and **RHEL sample** are clearly synthetic; they never scan +another host or populate history automatically. -## Windows Server assessments: local collection +## Privacy defaults and local output -Local Windows collection is implemented for the Server 2016/2019/2022/2025 release builds. **Acceptance is pending a successful real inventory on an authorized Windows Server.** Development was performed on Linux, without a PowerShell runtime. Synthetic tests are not Windows compatibility certification. +`privacy_policy.json` under `samsara/assets/` is an administrator-reviewed deployment +configuration. Defaults omit host/domain/account identities, custom free-text names, +certificate identities and all paths. A finite catalogue retains recognizable +application families. States, PIDs, ports, numeric IPs, OS versions, capacities and +counts remain useful, but are still confidential infrastructure metadata. -Use an existing authorized account on the Windows Server being assessed. The collector runs locally; a Windows 10/11 desktop cannot scan another server in this phase. No WinRM, SSH, agent installation, domain administrator account or saved credentials are required. Local elevation is never automatic. Start with least privilege; denied/hidden scopes become failed or partial checks. If your organization authorizes a local elevated token for broader inventory, open that session yourself. AppLocker/WDAC, constrained language and script signing/execution policies may block collection; follow your organization's approved signing/distribution process. The app does not bypass policy. +Only **exact, explicitly approved local share roots** can be retained. Approval of +one root never authorizes child paths or filenames. No file contents are inspected. +There is no automatic patient-name detector or guarantee that infrastructure +metadata cannot contain PHI before projection. -### GUI on a compatible Windows desktop environment +Set `report_directory` to an approved local directory. Linux creates a missing root +with mode 0700 and files with 0600; existing broadly readable roots are rejected. +Windows requires a provisioned protected directory ACL and checks child ACLs before +writing. Common remote paths, symlinks and reparse points are rejected. Cloud sync, +backups, unusual storage backends and OS incidental writes require separate review. +See the [configuration and Windows directory setup instructions](docs/PRIVACY_HARDENING.md). -Use 64-bit Python 3.11+ already available on the authorized execution host. Keep `powershell/`, `schemas/` and `samsara/` together in the checkout. From its root in Command Prompt: +## Local Windows Server collection + +The code targets the Server 2016, 2019, 2022 and 2025 release builds. +**Actual Windows inventory, revised PowerShell queries and Windows output ACLs have +not been validated on a Windows execution host in this environment.** Synthetic +Python tests are not Windows compatibility certification. Do not deploy to production +until authorized lab validation and CHS review are complete. + +Run locally using an existing authorized account and 64-bit Python 3.11+. A Windows +10/11 reporting desktop does not remotely scan servers in this phase. No WinRM/SSH +configuration, service restart, agent installation, saved credentials or automatic +elevation is performed. Do not use Domain Administrator by default. Permission +failures and incomplete scopes remain visible; provider ACL filtering may be silent. +Follow normal application-control/script-signing policy; no execution-policy bypass. + +The GUI runs collection in a background worker. Each PowerShell section has a +45-second limit, an overall 300-second budget and bounded output. Stdout remains in +bounded memory instead of an inventory temporary file; stderr is discarded. +Cancellation/timeouts terminate and reap the owned child process. + +For headless Server/Core collection, install only the headless requirements through +approved provisioning. Keep `samsara/`, `powershell/` and `schemas/` together. After +configuring and provisioning `C:\SamsaraReports` as described above: ```bat py -3 -m venv .venv -.venv\Scripts\python.exe -m pip install -r requirements.txt -.venv\Scripts\python.exe -m samsara.main +.venv\Scripts\python.exe -m pip install -r requirements-headless.txt +.venv\Scripts\python.exe -m samsara.collectors.windows --output C:\SamsaraReports\windows-inventory.json ``` -Choose **Analyze → This computer → Auto-detect → Analyze**, or select the matching Windows Server release. Windows PowerShell 5.1 at the Windows system path performs the local inventory in the background. Review the Summary and each tab, especially **Collection checks**, then save HTML/JSON. The history and report source say **Local Windows read-only collection**. Cancel terminates and reaps the active PowerShell process and keeps the previous assessment. Each section has a 45-second limit, overall execution a 300-second budget, and output a 10 MiB limit; timed-out sections remain visible as incomplete. +Use a new filename. Copy minimized output only through an organization-approved +transfer process; Samsara provides no remote transport. GUI compatibility is separate +from collector compatibility: use headless collection if the Qt version cannot run +on the server OS or no graphical desktop is present. -Qt desktop compatibility is separate from collector compatibility. Current Qt 6.12 lists Windows 10 1809+ and Windows 11 as supported desktops; do **not** assume a Qt GUI will run on Server 2016 or Server Core. Use headless collection on those hosts and analyze the resulting file on a compatible desktop. See [Qt's Windows platform requirements](https://doc.qt.io/qt-6/windows.html). - -### Headless Windows collection (including Server Core / Server 2016) - -With existing 64-bit Python 3.11+, no PySide6 installation or graphical desktop is required. From the repository root in Command Prompt: - -```bat -py -3 -m venv .venv -.venv\Scripts\python.exe -m pip install "jsonschema>=4.21,<5" -mkdir reports -.venv\Scripts\python.exe -m samsara.collectors.windows --output reports\windows-inventory.json -``` - -Use a new filename; the CLI will not overwrite an existing file. The CLI uses the same supervised collector, server identity gate and schema validation as the desktop. Copy the JSON manually to the reporting desktop and choose **Saved inventory file**, then Analyze. Imports retain timestamps, safe error codes and the presence of permission limitations, remove raw diagnostics/source claims, and recalculate risk. No remote transport is implemented. - -If Python is unavailable, the PowerShell-only driver can produce an inventory with no Python/Qt dependency, subject to your existing script policy. From a local Windows PowerShell 5.1 prompt in the checkout root: - -```powershell -$json = & .\powershell\Get-ServerInventory.ps1 -if ($LASTEXITCODE -ne 0) { throw 'Local inventory collection failed.' } -[IO.File]::WriteAllText((Join-Path $PWD 'windows-inventory.json'), $json, (New-Object System.Text.UTF8Encoding($false))) -``` - -Choose a new destination in the final line to retain earlier evidence. This direct path is **not supervised by Python's time/output/cancellation bounds**; prefer the Python CLI where available. Import validates the returned schema before desktop analysis. - -### Coverage and missing evidence - -| Section | Collected | Known limitations | +| Section | Default scope | Omitted or unverified | | --- | --- | --- | -| System | OS caption, Server release/build, boot, IPs/adapters, domain membership | Requires readable CIM identity; AD enrichment not performed | -| Roles | Installed roles/features, state and feature type | Local ServerManager/module access needed; installation does not prove use | -| Services | All visible states, startup type, account, PID | Includes running services; command lines and secrets deliberately excluded | -| Networking | TCP listeners/connections, endpoints, PID/name | Snapshot only; direction, historical use, UDP and protected processes may be unknown | -| Tasks | Visible task names/paths, state, enabled setting, principal, safe executable basename and selected trigger fields | No arguments, XML, inline scripts, event query details or execution history; visibility depends on account | -| Software | Machine uninstall registrations in both registry views | No Win32_Product; per-user, portable, container and unregistered applications not covered | -| Storage / shares | Local volumes, capacity/free space, configured SMB share paths including administrative shares | No contents/ACLs/backups; optional read-only open-file aggregate counts identify business-share activity only when the share root can be correlated without an administrative alias | -| Certificates | Subject, issuer, expiry, thumbprint and machine store | No private keys, user stores, file-based certificates or application bindings | +| System | Fixed OS family/release/build, boot, literal IPs, adapter labels, domain-joined flag | Domain/host identity unless exact hostname approved; no AD enrichment | +| Roles | Installed identifiers/state | Free-text names; installation does not prove use | +| Services | Canonical families/opaque labels, states, startup, PID | Accounts, descriptions, executable paths/arguments | +| Network | TCP endpoints, state, PID, canonical process family | Snapshot direction/history may be unknown; no UDP/application content | +| Tasks | Opaque identity, maintenance category, state and inferred enabled state | Actions, commands, principals, trigger details and history; partial scope | +| Software | Canonical or approved names and numeric versions from machine registry | Publisher, dates, uninstall commands, per-user/portable software | +| Storage/shares | Fixed local drive metadata, capacities, configured shares and exact approved roots | Labels, child paths, filenames, contents, open-file/session activity; partial scope | +| Certificates | Not collected by default | Certificate identities/extensions/objects and private keys are not queried | -Each check includes status, UTC start/end times, sanitized errors, permission limitations and scope limitations. `complete` means the stated query scope succeeded; it does not prove complete retirement evidence. Sensitive checks run non-elevated are conservatively partial. Some Windows providers can silently filter objects by ACL; even elevated results need administrator review. Partial accessible data are retained. Invalid/unsupported Server identity stops the assessment; failed other checks never receive demo replacements. +Check timestamps/status/errors are recorded. `complete` describes the documented +query scope, not complete retirement evidence. Privacy omissions lower confidence +and trigger review; they do not add workload risk points. -### Required real-host validation (still outstanding) - -On a supported, authorized Server, use the headless environment above and a new output directory: +Required lab validation, using the configured private directory and new output names: ```bat .venv\Scripts\python.exe -m unittest discover -s tests -v -.venv\Scripts\python.exe tools\validate_windows_host.py --output-dir reports\windows-validation-01 +.venv\Scripts\python.exe tools\validate_windows_host.py --output-dir C:\SamsaraReports ``` -The second command performs **real local collection**, validates the inventory schema and writes `windows-assessment.json`, `windows-assessment.html` and `validation-summary.json`. Review the full report privately. The summary omits hostnames, service accounts, IPs, paths and certificate identifiers and is suitable for sharing validation status after review. It reports per-section status/error codes and row counts; it does not claim retirement approval. Run under the intended least-privilege account and, only if authorized, repeat in an elevated session with a different directory to assess visibility differences. +The second command runs **real local Windows collection**, writes minimized +`windows-assessment.json`, `windows-assessment.html` and `validation-summary.json`, +and prints categorical validation status. It refuses existing files and arbitrary +output directories. Review results privately. Repeat under intended least privilege +and separately authorized elevation, using separate provisioned directories/policy. +All four Server releases still require real-host acceptance evidence. -| Release | Python synthetic fixtures / orchestration / UI | Native PowerShell runtime | Actual host inventory | -| --- | --- | --- | --- | -| Server 2016 | Passed | Not run here | Pending | -| Server 2019 | Passed | Not run here | Pending | -| Server 2022 | Passed | Not run here | Pending | -| Server 2025 | Passed | Not run here | Pending | +The direct PowerShell driver remains available, but is not supervised by Python's +output/time limits or private-file writer. Do not use shell redirection or arbitrary +file writes as a substitute for the protected CLI workflow. -The implementation is **not considered complete** until real-host evidence is obtained. Do not describe the pending rows as tested Windows support. Packaged Windows installers are also unvalidated. +## Linux collection -## Linux collection and common safety +Local Linux collection observes OS/version/boot, literal addresses, loaded systemd +service/timer state, TCP metadata, RPM/dpkg packages and mount metadata. It does not +access mounted remote filesystems. Missing commands/access produce failed checks. +Default cron files and detailed timer properties are no longer read: schedule timing, +commands, user schedulers and execution history need an approved separate review. +Certificates, share exports/consumers and directory integration are uncollected. -The Linux collector observes OS/kernel/boot information, addresses, loaded systemd services and timers (including available calendar/next-run metadata), readable system cron entries, TCP snapshots, RPM or dpkg software and mounted filesystems. Missing commands or restricted permissions produce visible failed checks. Service accounts, user crontabs/timers, anacron/periodic-script configuration, certificate locations, share exports, disk capacity/free space and directory integration remain incomplete or uncollected. Results reflect the execution process's namespace and permissions. Remote mounts are not accessed. - -Collection is read-only, requires no sudo and does not restart services, configure remoting, install software or read private keys, secret-bearing command lines or environment variables. Cancel stops the active collector and retains the previous assessment. Commands have time and output limits. - -The summary highlights name-matched server software and running application services; this is not a complete application classifier. Scheduled entries show configured work, not confirmed execution, and cron timezones are unverified. Full inventory and collection limitations remain available in the tabs and exported report. - -## Retirement risk and confidence - -Scoring model v2 scores grouped workload evidence, not inventory counts. The final 0–100 score is the **strongest workload score**; services, ports and clients supporting the same workload are not added. Standard Windows services/features, default administrative shares, browser/Steam client activity, unknown traffic and collection failures do not automatically receive risk points. Installed/stopped software is not active-use evidence. - -Coverage percentage and evidence confidence are displayed separately. Failed task/certificate checks lower confidence and require review without implying an active application. Low/zero risk never grants retirement clearance: historical usage validation, business ownership and administrator approval are always required. - -Configure scoring through **Settings → Load scoring policy JSON**, starting with `samsara/assets/scoring_policy.json`. The applied policy and exact per-workload score explanation are recorded in JSON and HTML; history keeps its original policy. See [scoring rules, limitations and regression scenarios](docs/SCORING.md). - -To re-score the test server without contacting it again, restart Samsara and open its previous JSON inventory or assessment using **Saved inventory file**. Existing v1 raw inventory is compatible; old scores are ignored and recomputed. The saved timestamp/source remain explicit. The original report JSON was not provided for development, so the 1,565-point regression uses synthetic data matching the reported counts. - -HTML now summarizes actionable findings and confidence rather than dumping hundreds of services. Each HTML export creates a linked, content-addressed inventory JSON file beside it. Keep both files together; the JSON assessment export also retains all inventory. Every routine service, feature, administrative share and connection remains available in the detailed UI tabs and full JSON. - - -## Headless Linux inventory - -The standard-library-only collector can export local inventory without Qt: +For the default application-local private `reports` root: ```bash -python3 -m samsara.collectors.linux --output /tmp/server-inventory.json +python3 -m samsara.collectors.linux --output reports/linux-inventory.json ``` -Use a new filename; existing files are not overwritten. Open the result using **Saved inventory file** on either desktop. Imported check assertions are not independently verified. Import validates the v1 schema, limits input to 10 MiB, removes external diagnostic details and service/task command fields, and recalculates risk. +The root's parent must exist; output is minimized and files are exclusive/private. +Use a configured approved absolute directory for deployment. Collection never uses +sudo or changes remote settings. Native utilities and OS logging/caching behavior +still require operational review; read-only queries do not mean data cannot disclose +sensitive information. -## Development +## Retirement scoring + +Scoring model v2 uses the maximum grouped workload score (0–100), never unrestricted +inventory totals. Routine Windows services/features, administrative shares, +Steam/browser client traffic, unknown connections and collection failures do not +receive automatic workload points. Known application PID/listener/client evidence +can support a workload finding; no activity does not prove a host unused. + +Coverage/confidence are independent from risk. Historical usage validation, business +ownership and administrator approval are always required. Settings loads a bounded +data-only scoring JSON; numeric policy values and exact rationale are preserved. +Policy display names are canonicalized to avoid free-text disclosure. +See [scoring details](docs/SCORING.md). + +## Verification and remaining work ```bash -python -m unittest discover -s tests -v +QT_QPA_PLATFORM=offscreen .venv/bin/python -m unittest discover -s tests -v ``` -Qt tests run offscreen. See [platform support](docs/PLATFORMS.md), [architecture and plan](docs/IMPLEMENTATION_PLAN.md) and [developer guide](docs/DEVELOPMENT.md). Run from source; Windows packaging and real-host collector acceptance remain pending. PowerShell AST/synthetic tests run when a native runtime is available and are explicitly skipped otherwise. On this Linux development host, 61 tests were discovered: 60 passed and the native PowerShell test was skipped; no real Windows inventory has been collected. +Synthetic tests cover identifying canaries in every major free-text path, stale +findings/marker bypasses, exact-root approval, private/exclusive writes, invalid +schemas/timestamps, process output limits/cancellation, UI/history, report escaping +and the seven retirement scenarios. Native PowerShell tests are explicitly skipped +if no runtime is available. No production collector, package installation/update or +external advisory lookup was performed during hardening. + +Outstanding: native Windows lab validation and ACL/race testing, least-privilege +validation, platform/hash locks, native-component SBOM, authorized vulnerability +scan, signed distribution, approved storage/egress/retention controls and CHS +security/privacy approval. These prevent a claim of production readiness or HIPAA +compliance. The old bundle/reports are not automatically updated or scrubbed. + +[Developer guide](docs/DEVELOPMENT.md) · [Platform notes](docs/PLATFORMS.md) · +[Architecture](docs/IMPLEMENTATION_PLAN.md) · [Security hardening](docs/PRIVACY_HARDENING.md) diff --git a/audit/DEPENDENCY_INVENTORY.md b/audit/DEPENDENCY_INVENTORY.md new file mode 100644 index 0000000..8d91f23 --- /dev/null +++ b/audit/DEPENDENCY_INVENTORY.md @@ -0,0 +1,104 @@ +# Dependency and supply-chain inventory + +**Evidence:** `requirements.txt:1-2`, static imports across `samsara/`, `tools/`, `tests/`, local `.venv/lib/python3.12/site-packages/*.dist-info/METADATA`, `.venv/pyvenv.cfg`, PowerShell source and native component filenames. No dependency was imported for evaluation, installed, upgraded or contacted. No advisory database or external repository was queried. Observed local versions are metadata evidence, not proof of wheel authenticity, Windows deployment versions, maintenance status or vulnerability absence. + +## Declared and discoverable Python dependencies + +The only direct declarations are `PySide6>=6.6,<7` and `jsonschema>=4.21,<5`. No `pyproject.toml`, `setup.py`, `setup.cfg`, Pipfile/Poetry lock, requirements lock, first-party package build manifest or SBOM was present. The reviewed venv declares CPython 3.12.3 and `include-system-site-packages = false`; the application documentation targets Python 3.11+. + +| Dependency (local metadata version) | Version constraint | Purpose | Direct / transitive | Security concern | Recommendation | +| --- | --- | --- | --- | --- | --- | +| PySide6 **6.12.0** | `>=6.6,<7` | Desktop Qt UI; QtCore/QtGui/QtWidgets imports at `samsara/ui/window.py:1-5` and main | Direct, GUI only | Broad range; native plugins/codecs and unused components; no complete vendor/native SBOM or runtime egress verification | Pin/review a platform-compatible exact release. Evaluate Essentials-only package after compatibility/licensing/security review; keep headless path Qt-free. | +| jsonschema **4.26.0** | `>=4.21,<5` | v1 inventory/section validation (`samsara/models/inventory.py:5-18`, `collectors/windows.py:14`, `:95-106`) | Direct, GUI/headless Windows | Broad range; validation is not redaction. Optional format enforcement and mutable schema/reference policy need review | Pin/hash, close application schema, explicitly enforce required formats and disallow uncontrolled remote schema resolution. | +| PySide6_Essentials **6.12.0** | `==6.12.0` from resolved PySide6 | Qt runtime/bindings including modules actually imported | Transitive; potential smaller direct alternative | Native library/codecs/plugin dependency closure not captured by pip metadata | Include Qt/native artifacts and platform plugins in SBOM; verify required modules under minimal distribution. | +| PySide6_Addons **6.12.0** | `==6.12.0` from resolved PySide6 | Additional Qt modules | Transitive | No first-party imports of Addons-specific API identified; unnecessary installed capability | Remove only through a separately approved minimization change and regression validation. Installed does not mean executed. | +| PySide6_WebEngine **6.12.0.140** | `==6.12.0.140` from resolved PySide6 | Browser/web-content runtime and helper | Transitive | Not used by first-party UI, which does not embed a browser. Large native web runtime and dependency closure; telemetry/egress not audited | Prefer omitting if not needed; inventory/version native engine/helper and vendor advisories if retained. No finding that it was launched or transmitted data. | +| PySide6_Pdf **6.12.0.140** | `==6.12.0.140` from resolved PySide6 | PDF runtime | Transitive | Application writes HTML/JSON, not PDF; unnecessary parser/plugin capability | Evaluate removal via minimal Qt deployment. Include native PDF code in SBOM if shipped. | +| shiboken6 **6.12.0** | `==6.12.0` from Essentials | Python/C++ binding runtime | Transitive, required for Qt | Native extension and ABI coupling | Pin with matching PySide6 set and verify signed/hash-approved wheels. | +| attrs **26.1.0** | `>=22.2.0` from jsonschema; also used by referencing | Validator/reference implementation support | Transitive | Not separately constrained by project; version selected transitively | Lock entire resolved environment, not just top-level packages. | +| jsonschema-specifications **2025.9.1** | `>=2023.03.6` from jsonschema | Bundled schema specification resources | Transitive | Not separately pinned; dependency provenance | Lock/hash and include resources in SBOM. | +| referencing **0.37.0** | `>=0.28.4` from jsonschema; `>=0.31.0` from specifications | JSON reference machinery | Transitive | Resolver behavior can be network-capable depending on schema/registry; current application schema has no remote `$ref` | Restrict schema provenance/reference registry to approved local resources; test offline. Do not infer current fetches from package capability alone. | +| rpds-py **2026.9.1** | `>=0.25.0` from jsonschema; `>=0.7.0` from referencing | Persistent data structure implementation | Transitive | Native Rust extension supply chain/platform wheel provenance | Lock/hash native wheels; include native component/version evidence. | +| typing_extensions **4.16.0** | `>=4.4.0` from referencing when Python `<3.13` | Runtime typing compatibility | Transitive, selected in reviewed Python 3.12 venv | Environment-marker differences across runtimes | Resolve/lock on every approved Python/platform combination. | +| pip **24.0** | No project constraint | Environment creation/install tooling; not application runtime import | Build/environment tool | External downloads/install execution; bootstrap provenance and tool version unreviewed | Use organization-approved provisioned tooling/mirrors. Do not install/update during a production assessment or this audit. | + +All 13 discoverable installed distributions are represented above. Their metadata files are under `.venv/lib/python3.12/site-packages/`: `pyside6-6.12.0.dist-info`, `pyside6_essentials-6.12.0.dist-info`, `pyside6_addons-6.12.0.dist-info`, `pyside6_webengine-6.12.0.140.dist-info`, `pyside6_pdf-6.12.0.140.dist-info`, `shiboken6-6.12.0.dist-info`, `jsonschema-4.26.0.dist-info`, `attrs-26.1.0.dist-info`, `jsonschema_specifications-2025.9.1.dist-info`, `referencing-0.37.0.dist-info`, `rpds_py-2026.9.1.dist-info`, `typing_extensions-4.16.0.dist-info`, `pip-24.0.dist-info`. + +The transitive `==` pins inside the currently resolved PySide6 family do **not** make project installs reproducible: the top-level range can select another family next time. Installed metadata is not a lockfile or release attestation. + +### Optional dependencies not selected in this environment + +PySide6 metadata advertises conditional `tomli>=2.0.1` below Python 3.11 and optional full examples/binding generator; those are not selected by the documented minimum runtime or declared requirements. Addons advertises an optional WebEngine extra, while the meta-package already selects WebEngine in the observed resolution. + +jsonschema advertises optional format extras involving fqdn, idna, isoduration, jsonpointer, rfc3339-validator, rfc3987, uri-template, webcolors and, for the non-GPL format extra, rfc3986-validator/rfc3987-syntax. These are not requested by `requirements.txt` and are not present in the discovered distribution set. Do not label them installed vulnerabilities. + +**Concrete enforcement gap:** installed `jsonschema/_format.py:395-402` conditionally imports `rfc3339_validator` under `suppress(ImportError)` before registering date-time checking; `:111-129` returns when a checker is unavailable. The module file and installed distribution are absent locally. Thus `FormatChecker()` alone does not establish date-time validation in the reviewed isolated environment. This is source/metadata evidence, not an executed malformed-input test. Explicitly verify required checker availability or implement reviewed dependency-free field validation later. + +### Licensing and maintenance + +Local metadata lists LGPL-3.0-only / GPL-2.0-only / GPL-3.0-only alternatives for the Qt Python distributions, MIT for the validator family/pip, and PSF-2.0 for typing_extensions. This is not a complete native license inventory or a legal opinion. Qt/commercial-license choices, redistribution notices and third-party bundled licenses require organization-approved review. Current maintenance/support/end-of-life status for every package is **Unverified** without authorized vendor/advisory review. No package is declared abandoned or CVE-free based on its version string or project URL. + +## Standard-library and test dependencies + +CPython provides `argparse`, `collections`, `copy`, `ctypes`, `datetime`, `functools`, `hashlib`, `html`, `ipaddress`, `json`, `os`, `pathlib`, `platform`, `re`, `shlex`, `socket`, `struct`, `subprocess`, `sys`, `tempfile`, `time` and `urllib.parse`; tests additionally use `unittest`/`unittest.mock` and `shutil`. These are part of the Python runtime, not independent pip dependencies. `urllib.parse.quote` is URL encoding, not an HTTP client; `socket.gethostname` does not explicitly call connect/DNS in this source. `ctypes` loads native code and extends the trust boundary. Python runtime patches, standard library security fixes and platform DLL dependencies are outstanding release checks. + +No first-party imports of requests/httpx, telemetry SDKs, SQL/database drivers, PySide web widgets, cloud SDKs or remote logging clients were found. Qt SQL/native driver files installed by broad packaging are not evidence of a database integration. No extra third-party unit-test framework is declared. + +## Native runtimes, modules and external executables + +| Dependency | Version constraint / evidence | Purpose / use | Kind | Security concern / recommendation | +| --- | --- | --- | --- | --- | +| Windows PowerShell | `#requires -Version 5.1`, driver `powershell/Get-ServerInventory.ps1:1`; system `powershell.exe` path `samsara/collectors/windows.py:35-64` | Local read-only section execution | External executable/runtime | Exact Windows build/patch/module versions and publisher trust Unverified. Require signed approved deployment; no bypass/profile execution. | +| Windows CIM/WMI service and CimCmdlets | Inbox, unpinned OS component | Win32_OperatingSystem/ComputerSystem/NetworkAdapterConfiguration/Service | Runtime/provider | ACL filtering, provider/log/cache effects; collect only approved properties and validate with least privilege. | +| ServerManager | Inbox, unpinned | Get-WindowsFeature | PowerShell module | Access/servicing/logging behavior not established; validate per Server release. | +| NetTCPIP | Inbox, unpinned | Get-NetTCPConnection | PowerShell module | Endpoint/process linkage is sensitive; no explicit remote session used. | +| ScheduledTasks | Inbox, unpinned | Get-ScheduledTask | PowerShell module | Definitions can contain sensitive actions/triggers; provider acquisition is broader than exported projection. | +| Storage | Inbox, unpinned | Get-Volume | PowerShell module/provider | Storage metadata and external-provider side effects need lab verification. | +| SmbShare | Inbox, unpinned | Get-SmbShare/Get-SmbOpenFile | PowerShell module/provider | Broad open-file metadata privacy exposure and access requirements; default-disabled sensitive source recommended. | +| Microsoft.PowerShell.Management / Utility / Security and certificate provider | Inbox, unpinned | Get-Process/Get-ChildItem, object/JSON utilities, certificate stores | PowerShell modules/providers | Restrict to trusted inbox implementations; exact provider runtime behavior not reviewed. | +| .NET / Microsoft.Win32.RegistryKey, Security.Principal, certificate objects | Windows PowerShell runtime, unpinned | Registry view access, token inspection, public certificate properties | Native/managed runtime | Read handles do not imply values are privacy safe. Type/expansion/key access semantics need approval. | +| kernel32.GetSystemDirectoryW, shell32.SetCurrentProcessExplicitAppUserModelID | Windows system APIs | Executable resolution; process/taskbar identity | OS APIs | Source does not change persistent configuration; actual system DLL integrity is OS responsibility. | +| Qt Core/Gui/Widgets, shiboken, platform/image plugins | Resolved PySide family above; full native closure unknown | GUI/event loop, file dialogs, JPEG logo rendering | Native transitive runtime | Image/plugin loading, system IPC, caches and file dialog egress Unverified. Application source does not disable Qt/native capabilities globally. | +| Qt WebEngine/PDF/Network/SQL libraries and helper | Filename evidence under `.venv/.../PySide6/Qt/`; exact embedded component versions Unverified | Installed broad runtime; not explicitly used by first-party UI | Native transitive, partly unnecessary | Observed `libQt6WebEngineCore.so.6`, `libexec/QtWebEngineProcess`, PDF/network/SQL libraries and database plugins. Inventory/remove unnecessary components; do not claim they ran. | +| ICU, OpenSSL/TLS backend, image codecs and other native closure | Filename evidence includes `libicu*.so.73`, `libqopensslbackend.so`, JPEG plugin and crypto/SSL stubs | Qt native support | Native transitive | Filenames do not prove patched upstream versions, actual loaded backend or complete licenses. Need native SBOM and binary/advisory review. | +| Linux display/OpenGL/X11/Wayland libraries, cursor library | `samsara/core/startup.py:14`; desktop packages described by README | Qt desktop integration | OS/runtime | Version/patch inventory not pinned. Loading the named library is native code execution; verify OS baseline. | +| systemctl / systemd | Host-managed, unpinned | Enumerate services/timers, read selected properties | Linux external executable + IPC | Fixed paths/options, no service changes by source; cache/IPC side effects Unverified. | +| ip / ss (iproute2) | Host-managed, unpinned | Interfaces and numeric TCP table | Linux external utilities | Permission visibility and endpoint privacy; no explicit active probes. | +| rpm OR dpkg-query | Host-managed, unpinned | Package inventory query only | Linux external utility/database | Not an installer action; exact database locking/caches need validation. | +| sleep | Test-only system utility | Cancellation/timeout tests (`tests/test_linux.py:87-89`) | Development executable | A real process is started/killed by tests; do not execute the suite blindly on production. | +| Git | Development/artifact transport, not production import | Repository/bundle management | Development tool | Source/release provenance needs verification; no remote Git calls made in this audit. | + +Native filename inspection also found SQL driver plugins and network-related libraries. The complete native dependency closure, bundled third-party subcomponents, Windows CRT/system DLLs and exact Windows module versions are **not** established. A Python-only SBOM is insufficient. + +## Network, telemetry and download assessment + +- Application source has no explicit telemetry/update/upload integration. That is not a vendor/runtime telemetry attestation. Dependency source/native behavior was not exhaustively audited or observed. +- Manual README pip and OS package commands can access configured indexes/repositories and install code. No hash-locked approved wheelhouse is supplied. +- Headless Windows instructions avoid Qt but still require approved existing Python/jsonschema or the PowerShell-only path. Installing runtimes or dependencies on a production server is outside a read-only collection claim and needs separate authorization. +- The current schema has a public `$schema` identifier, not a remote `$ref`. There is no identified current schema fetch; a substituted schema/resolver/deployment is outside the integrity assurance. + +## Outstanding vulnerability checks and exact later commands + +**No CVEs or vulnerability results are asserted. No scan was run because network access and dependency changes were prohibited.** The source cannot establish which components are vulnerable or patched. + +The following commands are for a **later authorized build/security environment only**, with approved pinned scanner/tool versions already provisioned. They write output and some query advisory services/send package names and versions; they must not run in the production assessment process or alongside PHI/report files. Confirm approved advisory/mirror endpoints and privacy for private package names before execution. Do not provision tools automatically on an assessed server. + +```text +python -m pip inspect --local > pip-inspect.json +python -m pip freeze --all > installed-packages.txt +python -m pip_audit --local --strict --format json --output pip-audit-installed.json +python -m pip_audit --requirement requirements-windows-py311.lock --strict --format json --output pip-audit-lock.json +python -m cyclonedx_py environment --output-file sbom-python.cdx.json +syft dir:release-folder -o cyclonedx-json=sbom-native.cdx.json +grype sbom:sbom-native.cdx.json -o json > grype-native.json +``` + +`release-folder` must be a clean, approved release staging directory without collected reports. `requirements-windows-py311.lock` must be produced/reviewed first; replace its name with the matching approved target-platform lock. Pip auditing does not cover Windows/Qt native closure, and Syft/Grype coverage is not guaranteed for every bundled binary. Review scanner collection failures and vendor advisories, not just nonzero CVE counts. If advisory egress is prohibited, obtain an approved offline advisory database/export and document its freshness, provenance and uncovered components instead of claiming the online scan succeeded. + +## Reproducible build and SBOM acceptance approach + +1. Resolve separately on each approved OS/Python architecture. With approved pip-tools already provisioned: `python -m piptools compile --generate-hashes --output-file requirements-windows-py311.lock requirements.txt`. Repeat on the approved Linux runtime. Generation/resolution may download metadata and requires later authorization. +2. Review exact versions, licenses, native manifests and hashes; curate a signed approved wheelhouse. Later approved staging commands: `python -m pip download --require-hashes --requirement requirements-windows-py311.lock --dest wheelhouse`, then `python -m pip install --no-index --find-links wheelhouse --require-hashes --requirement requirements-windows-py311.lock` on the matching clean build environment. No installation was performed here. +3. Generate CycloneDX/SPDX Python and native SBOMs tied to the release commit, source hashes, OS/module patch baseline and platform-specific binary hashes. Include scripts/schema/assets, bundled plugins/codecs and toolchain provenance; retain licenses and verification evidence. +4. Complete approved vulnerability/maintenance/vendor review and record exceptions with owners, expiry and compensating controls. Scan failures/incomplete native identification block a clean result. +5. Distribute signed/attested, ACL-protected artifacts through approved infrastructure. Disable assessment-time package installation, downloading and update checks; prove offline operation for the packaged runtime in an isolated synthetic test. diff --git a/audit/PHI_DATA_FLOW.md b/audit/PHI_DATA_FLOW.md new file mode 100644 index 0000000..8edb1b4 --- /dev/null +++ b/audit/PHI_DATA_FLOW.md @@ -0,0 +1,90 @@ +# PHI exposure and data flow + +**Offline source review, 2026-10-30.** Findings reference the working-tree snapshot identified in SECURITY_AUDIT.md. No collectors, patient datasets or production reports were executed/read. **Potential exposure is not an assertion that actual PHI was present.** Infrastructure identifiers can be sensitive without being PHI; privacy classification depends on patient linkage and healthcare context. No field-name allowlist or HTML escape proves a value PHI-free. + +## Lifecycle and trust boundaries + +```mermaid +flowchart TD + W[Windows CIM / registry / tasks / SMB / certificates] --> P[PowerShell provider objects in memory] + P --> J[Selected JSON fields on stdout] + J --> T[Python TemporaryFile on execution host] + T --> V[JSON parse / schema / shallow field projection] + I[User-selected JSON inventory] --> V + L[Linux metadata / full cron text / utility output] --> M[Python collection memory and temp output] + M --> A[Assessment evidence and summaries] + V --> A + A --> U[Qt widgets and session history] + A --> H[HTML actionable report] + A --> F[Full JSON assessment] + V --> R[Automatic full-inventory JSON sidecar] + H --> D[User-selected filesystem destination] + F --> D + R --> D + P -. runtime unverified .-> X[OS transcripts / EDR / dumps / pagefile] + U -. runtime unverified .-> X +``` + +This is a code-derived diagram, not a captured runtime trace. Filesystem destinations may be local or network-backed. The diagrams' arrows do not indicate a confirmed external disclosure. No application-managed privacy boundary exists between retained free text and report output. + +## Windows collector-by-collector inventory + +For all rows below: the section driver serializes selected fields at `powershell/Get-ServerInventory.ps1:31-54`; the Python worker captures stdout in temporary storage at `samsara/collectors/windows.py:60-86`; projection occurs at `:175`, not at acquisition. Raw ErrorRecords/provider objects remain local process memory; exact native memory, logging and persistence behavior is Unverified. + +| File / function and source lines | Actual data source and fields selected | Potential PHI / severity | Filtering and memory versus export | Recommended disposition | +| --- | --- | --- | --- | --- | +| `powershell/Get-ServerSystem.ps1:1-34`, `Get-SamsaraSystem` | `[Environment]::MachineName`; local `Win32_OperatingSystem` Caption/Version/BuildNumber/LastBootUpTime/ProductType; `Win32_ComputerSystem` Domain/PartOfDomain; `Win32_NetworkAdapterConfiguration` Description/IPAddress for IPEnabled adapters | **Medium**, potentially High where patient/device naming is used: hostname, domain, adapter description and IP/patient-device linkage | Explicit CIM properties reduce scope. OS identity/build values are constrained indirectly, but names/descriptions/addresses are not privacy-filtered. Selected strings reach PowerShell memory, temp JSON, Python, UI and full JSON. OS name/hostname also appear in HTML. | Prefer approved opaque host/interface identifiers and numeric OS attributes. Require naming-policy assurance; suppress unapproved descriptions and raw addresses where not essential. | +| `powershell/Get-ServerRoles.ps1:1-10`, `Get-SamsaraRoles` | Local `Get-WindowsFeature`, Installed filter; Name/DisplayName/InstallState/FeatureType | **Low** PHI likelihood for built-in catalogue entries; still unrestricted provider strings and confidential role inventory | Projects fields and selects installed entries. All values enter temp/memory/raw JSON; findings may repeat role names in HTML. No content redaction. | Approve fixed feature IDs/enums, not arbitrary display text. Keep catalogue and integrity controls. | +| `powershell/Get-ServerServices.ps1:1-11`, `Get-SamsaraServices` | Local CIM `Win32_Service` Name/DisplayName/State/StartMode/StartName/ProcessId | **High** conditional exposure: custom service names/display names and account names may identify a patient or patient-specific workflow | Explicit property query excludes Description and PathName. No service executable arguments requested. Retained names/accounts are unredacted in memory/temp/full JSON; analyzer evidence embeds service/account strings (`samsara/analyzers/dependencies.py:118-126`). | Use approved service IDs, known catalogue aliases and role-based identities; otherwise omit/tokenize controlled identifiers. Do not label accounts automatically PHI or automatically safe. | +| `powershell/Get-ServerNetwork.ps1:1-16`, `Get-SamsaraNetwork` | Local `Get-Process` Id/ProcessName; `Get-NetTCPConnection` State/LocalAddress/LocalPort/RemoteAddress/RemotePort/OwningProcess | **Medium**, potentially High with patient-named executables or bedside/patient endpoint correlation | No packet payload, DNS lookup, process command line or `StartInfo` is explicitly accessed. Process objects are received before name/PID projection. Names/endpoints flow through temp/memory and full JSON; evidence can repeat endpoint pairs in HTML (`dependencies.py:220-240`). | Approve endpoint granularity, mask/tokenize addresses if not required, constrain process names and retain aggregate activity where possible. A port/process heuristic is not privacy screening. | +| `powershell/Get-ServerTasks.ps1:1-20`, `Get-SamsaraTasks` | Local `Get-ScheduledTask`; TaskName/TaskPath, Settings.Enabled, State, Principal.UserId, Actions.Execute, selected trigger class/start/enabled/interval/day/repetition fields | **High**: task folder/name/account/action basename can contain patient identifiers; full task definitions may also contain PHI-bearing arguments, descriptions or event subscriptions | Whole task/Actions/Triggers objects precede projection; exact provider materialization of omitted fields is Unverified. Arguments/XML/subscriptions are not serialized by the collector. Execute is reduced to a regex-approved basename (`Inventory.Common.ps1:29-34`), but allowed filenames can themselves contain identifiers. Selected values enter temp/memory/JSON/HTML evidence. | Disable unrestricted task-definition acquisition until approved. Minimize at source, avoid event payloads/arguments, map to approved task IDs and approved trigger enums. Redacting after retrieval does not meet a no-PHI-processing claim. | +| `powershell/Get-ServerSoftware.ps1:1-37`, `Read-SamsaraSoftwareEntries` / `Get-SamsaraSoftware` | HKLM uninstall registrations under explicit Registry64 and Registry32; subkey names read to enumerate; DisplayName/Publisher/DisplayVersion/InstallDate; registry view label | **High** conditional exposure: custom registration display strings can carry identifiers or secrets | Only four named values are selected, not uninstall commands/product repair data; no `Win32_Product`. Registry handles read-only/disposed. No value/type/content privacy rule; `GetValue` default handling of expandable registry strings needs review. Selected values enter temp, memory and raw JSON; name hints can enter HTML. | Fixed approved product identifiers/version formats; reject unapproved types and expandable substitutions; omit custom free text unless classified and approved. | +| `powershell/Get-ServerStorage.ps1:1-12`, `Get-SamsaraStorage` | Local `Get-Volume` label/drive letter or path, filesystem, Size/SizeRemaining | **High** conditional exposure through filesystem labels or mounted path names | Does not enumerate files or read volume contents. Labels/paths remain unrestricted and enter temp/memory/full JSON. | Collect numeric capacity and approved volume IDs; suppress free-text labels/full paths unless necessary and approved. | +| `powershell/Get-ServerStorage.ps1:14-47`, `Get-SamsaraShares` share inventory | Local `Get-SmbShare` Name/Path/Special; type label and capacity placeholders | **High**: share name/path may itself identify a patient or clinical case | Includes administrative shares in raw inventory; exclusion from scoring is not exclusion from collection/export. Full configured paths are retained in temp/memory/full JSON and can enter actionable evidence/HTML. No content privacy filtering. | Approved opaque share IDs and activity summaries; explicit authorization for detailed share paths; do not confuse low risk-score impact with low privacy exposure. | +| `powershell/Get-ServerStorage.ps1:22-43`, `Get-SamsaraShares` activity query | **Unfiltered local `Get-SmbOpenFile`** objects; accesses full Path, ShareRelativePath and ClientComputerName. Reduces matched entries to observed_open_files, distinct observed_client_count, activity_source/time | **High**, even when final output is aggregate: patient-related file/relative names and client metadata can enter memory before filtering | `$openFiles` retains all returned objects during processing, including entries outside selected business shares. ClientUserName/other fields may be returned by the provider though not projected or used. Full paths/client names are not copied into selected JSON; only counts/time/source and configured share paths survive. PowerShell errors/OS memory/logging remain potential secondary sinks. | If no PHI processing is required, disable this query or use a separately approved upstream aggregation source that does not expose patient-bearing records. If retained, treat as sensitive processing; prove native field acquisition, aggregation correctness and logging behavior with synthetic canaries. | +| `powershell/Get-ServerCertificates.ps1:1-17`, `Get-SamsaraCertificates` | `Get-ChildItem Cert:\LocalMachine`, then each returned store PSPath; Subject/Issuer/NotAfter/Thumbprint/store name | **High** conditional exposure: subject/issuer can contain patient identity or clinical context | No explicit private-key/Extensions property read or key export. Public certificate objects precede projection, so public extension/SAN materialization in provider memory is Unverified. Selected full subject/issuer strings are not redacted; enter temp/memory/raw JSON. | Prefer expiry/count/store/approved purpose IDs. Omit subject/issuer or require a reviewed certificate naming/data policy. Do not claim certificate metadata inherently excludes PHI. | +| `powershell/Inventory.Common.ps1:4-26`, safe-error helpers | Catch provider ErrorRecord; inspect category/exception type/HResult; retain static scope, code and fixed message | **Medium** processing potential; low application-serialized-error exposure for current fixed helper calls | Raw ErrorRecord exists in PowerShell and can remain in `$Error`/engine memory. Only fixed categories/messages are serialized. Direct driver/module-load failures and external logging are not completely covered. | Keep categorical errors; prohibit raw exception/TargetObject/stack serialization; verify transcripts/EDR and all failure branches with synthetic strings. | + +## Sources not explicitly queried by current Windows collectors + +| Requested area | Source-review conclusion | Remaining caveat | +| --- | --- | --- | +| Windows event logs / application logs | No `Get-WinEvent`, `Get-EventLog`, generic log-file reader or event-record collector identified | Task definitions may contain event-query metadata; OS logging is a separate output concern, not an implemented input collector. | +| SQL Server clinical/application data | No SQL client, query, database connection, table/listener probing or clinical database metadata collector | SQL/service/software names are examined as strings; those names can still be identifying. | +| Process command lines | Not explicitly collected; service PathName and task Arguments are not serialized | Task definition objects and error contexts precede projection. Do not generalize output exclusion into proof of no in-memory access. | +| Arbitrary Windows file contents | No general file reader against assessed volumes | SMB open-file **metadata** and paths are queried; app/import/report/policy paths can point to arbitrary operator-readable files. | +| Secrets/private keys | No explicit credential prompt, password store or private-key export | Inherited environment, free text, registry expansion, task provider objects and files can carry secrets incidentally. | + +## Linux, imported data and downstream processing + +| Path | Fields / processing | PHI exposure and recommendation | +| --- | --- | --- | +| `samsara/collectors/linux.py:100-195` | Hostname, `/etc/os-release`, `/proc/stat`, `ip -j address`, systemd service descriptions/state, TCP `ss -p`, RPM/dpkg text, `/proc/self/mountinfo` | **High conditional** exposure through names/descriptions/mount sources/paths. Utility output is written to temporary files (`:29-49`). No packet payload or mounted remote file contents intentionally read. Apply the same approved field and source rules as Windows. | +| `samsara/collectors/linux_schedules.py:9-74` | Reads full `/etc/crontab` and regular files in `/etc/cron.d`; parses complete command strings with `shlex`; exports safe executable/script names, account, source path/line, schedule | **High processing** potential: arguments, inline code and environment content are in memory before omission. Filename character checks are not PHI detection. Preliminary symlink checks are not an atomic no-follow guarantee. Do not run this Linux path on healthcare hosts under a no-PHI-processing claim without redesign/approval. | +| `samsara/collectors/linux_schedules.py:77-95` | Reads system timer IDs, calendar/next occurrence, associated service | **Medium** conditional free-text names; no jobs executed. Approve identifiers; keep source data bounded. | +| `samsara/collectors/importer.py:10-53` | Reads up to 10 MiB from arbitrary file, deserializes full report wrapper/inventory, then normalizes diagnostics and shallowly projects fields | **High**: the complete input enters raw bytes/JSON memory before redaction. Permitted free text and check names remain; some nested allowed values lack a closed schema. Accept only approved data contracts; reject unknown structures, remote paths and unsafe values before normal processing as far as feasible. | +| `samsara/models/inventory.py:22-41`; schema | Property-name selection, not privacy/value enforcement | **High control gap**: an allowed field can contain patient text or, where weakly typed, a nested unapproved object. Close schemas and field-value contracts; data minimization must also occur upstream. | +| `samsara/analyzers/dependencies.py:93-243`; `assessment.py:9-78`; `summary.py:9-37` | String normalization/classification; evidence includes names/accounts/share paths/network endpoints; JSON contains policy and evidence references | **High onward exposure**: analysis copies/derives information rather than de-identifying it. Privacy projection must precede evidence construction and apply to every output view. Risk classification is not PHI classification. | +| `samsara/analyzers/policy.py:13-44` | User-selected JSON; free policy name plus constrained numeric rules | **Medium**: imported policy text reaches memory/UI/report; not an assessment collector but a possible unapproved free-text channel. Limit policy provenance/approved names and protect deployable files. | +| `samsara/ui/window.py:401-479` | Raw data and assessments remain in memory/history; widgets display strings and detail tables | **High conditional** exposure to users, screenshots, accessibility tooling, dumps/pagefiles; no session retention bound/privacy view. Plain-text rendering reduces injection, not disclosure. | +| `samsara/reporting/export.py:15-29`, `:39-86` | Complete raw inventory sidecar, complete JSON assessment, selected HTML evidence and check text | **High**: HTML is not the complete disclosure surface. Raw sidecar/JSON retain more data. `html.escape` does not redact PHI. Export no approved root/ACL/encryption/retention policy. | +| `tools/validate_windows_host.py:24-45` | Full private inventory/report; separate release/status/count summary printed and saved | Full outputs remain **High** conditional exposure. The summary deliberately excludes direct host/account/IP/path/certificate identifiers but is not formally de-identified or independently privacy-reviewed. Do not advertise any generated artifact as safe to publish without review. | +| Bundled demos/tests | Synthetic fixtures and explicit fake-secret test strings | No actual PHI established in the reviewed fixtures. A `synthetic` flag, filename or sample label is not a technical privacy control for modified/imported content. | + +## Secondary sinks and data residency + +1. **PowerShell/provider memory:** includes broad query results and caught ErrorRecords. Provider buffering, automatic `$Error`, transcription and EDR behavior are not under source-only assurance. +2. **Python memory:** bytes, decoded JSON, dictionaries, evidence strings and history can coexist; closing a worker does not prove secure erasure. +3. **Temporary stdout:** on the local execution host, before final projection. Default deletion is not evidence of secure erasure or approved storage. +4. **UI/desktop:** collection data are readable by the interactive operator and potentially desktop support/accessibility/screen-capture tools subject to host policy. +5. **Export files:** full JSON, sidecars and HTML have normal inherited permissions; content hash is not encryption/pseudonymization. Copies, backup, indexing and retention are unmanaged by the app. +6. **Operating-system logs/dumps:** Unverified. Do not disable required security logging as a privacy workaround; validate approved logging destinations and prevent unapproved data acquisition first. +7. **Network filesystems:** user-selected input/output paths can relocate data and cause OS authentication/network traffic without an HTTP library in Samsara. + +## PHI-safe design recommendation + +Adopt one explicit CHS-approved model: either restrict acquisition to pre-approved non-patient-bearing fields/sources so patient values never enter the application, or formally treat Samsara as a potentially sensitive/PHI-processing tool with approved safeguards. The latter does not satisfy an absolute promise of no PHI processing. + +A safer architecture uses source-level minimal numeric/enumerated observations, approved opaque infrastructure identifiers, default-disabled high-risk SMB/task/certificate free-text acquisition, strict typed/depth-limited contracts, centralized privacy projection before analysis and all exports, and a separately authorized raw-evidence workflow. If pseudonymization requires reading a sensitive original value, acknowledge that original processing and protect key management outside the repository. Pattern-based redaction alone cannot prove PHI absence. Test with unmistakably synthetic privacy canaries, not real patients; inspect memory/temp/EDR/report surfaces under the intended host policy before claiming the control works. + +A further diagnostic path is `samsara/core/detection.py:41-49` plus `samsara/ui/window.py:31-52`: OS/profile errors include the detected OS name and ValueError text in UI status. Current explicit validation messages are largely fixed, but this path can repeat a source-provided OS-name string. There is no dedicated application file logger; external PowerShell/OS/desktop logs are not thereby excluded. Generic unexpected worker exceptions are replaced with fixed text. diff --git a/audit/READ_ONLY_VERIFICATION.md b/audit/READ_ONLY_VERIFICATION.md new file mode 100644 index 0000000..7c8ae7b --- /dev/null +++ b/audit/READ_ONLY_VERIFICATION.md @@ -0,0 +1,111 @@ +# Read-only, write-path and least-privilege verification + +**Method:** static source trace only, 2026-10-30. No operations listed below were executed against an assessed host. Labels describe actual invoked APIs and selected properties, not an assumption based on function names. Native provider/cache/logging effects remain **Unverified**. + +## Definitions and conclusion + +- **R:** an explicit metadata/configuration read with no source-requested target mutation. +- **O:** user-requested report/diagnostic output write. Allowed by the requested audit model only when the destination is approved; the app does not enforce that approval. +- **T:** temporary/cache/resource lifecycle write; not a server configuration change, but incompatible with a literal zero-write claim. +- **P:** in-process state change or collector-process creation/termination; not a service/task/registry configuration change. +- **U:** native/external side effects not established by static inspection. + +**Configuration read-only intent: Pass, narrowly scoped to the reviewed first-party commands. Fully read-only/zero-state-change assurance: Unverified, and a literal no-filesystem-write claim is contradicted by T/O paths.** Because Windows collection is local, execution host and assessed server are the same machine. Calling a file write “local” does not make it separate infrastructure. No confirmed intentional persistent service/task/registry/certificate modification was identified. + +## Windows command/API register + +| File / function / source lines | Invocation and execution condition | Class | Assessed-state effect / caveat | +| --- | --- | --- | --- | +| `powershell/Get-ServerSystem.ps1:4`, `Get-SamsaraSystem` | `[Environment]::MachineName` during system section | R | Local identity read; not network discovery. | +| `powershell/Get-ServerSystem.ps1:11-21` | `Get-CimInstance Win32_OperatingSystem` with explicit properties | R/U | Read caption/build/product type/boot. No `Invoke-CimMethod`. Provider/OS audit/cache effects unverified. | +| `powershell/Get-ServerSystem.ps1:24-26` | `Get-CimInstance Win32_ComputerSystem -Property Domain,PartOfDomain` | R/U | Local domain-membership metadata, no AD configuration or remoting parameters. | +| `powershell/Get-ServerSystem.ps1:29-31` | `Get-CimInstance Win32_NetworkAdapterConfiguration -Filter 'IPEnabled = True'` | R/U | Addresses/descriptions only; no adapter methods or setters. | +| `powershell/Get-ServerRoles.ps1:4-7`, `Get-SamsaraRoles` | `Get-WindowsFeature`, installed filter | R/U | No feature installation/removal. ServerManager servicing/log/cache behavior must be measured, not presumed absent. | +| `powershell/Get-ServerServices.ps1:5-8`, `Get-SamsaraServices` | `Get-CimInstance Win32_Service` explicit metadata properties | R/U | No service methods, configuration, start/stop/restart calls. PathName not requested. | +| `powershell/Get-ServerNetwork.ps1:5`, `Get-SamsaraNetwork` | `Get-Process` then Id/ProcessName projection | R/U | No process command lines, process injection or termination; process enumeration can race exits. | +| `powershell/Get-ServerNetwork.ps1:8-13` | `Get-NetTCPConnection` | R/U | Reads TCP table; no socket connection/listen/probe is initiated by source. Cmdlet has broader capability, but no remote session parameter is supplied. | +| `powershell/Get-ServerTasks.ps1:4-17`, `Get-SamsaraTasks` | `Get-ScheduledTask`; inspect settings/principal/actions/triggers | R/U | No register/unregister/enable/disable/start/set task. Retrieves definitions, not clinical data by intent; privacy implications remain. | +| `powershell/Get-ServerSoftware.ps1:22-32`, `Get-SamsaraSoftware` | `RegistryKey.OpenBaseKey(HKLM, Registry64/Registry32)`, `OpenSubKey(...,$false)` | R | Child keys explicitly opened non-writable. No SetValue/CreateSubKey/DeleteSubKey. Default value-expansion behavior is a privacy caveat, not registry mutation. | +| `powershell/Get-ServerSoftware.ps1:3-15`, `Read-SamsaraSoftwareEntries` | GetSubKeyNames/OpenSubKey(false)/GetValue; Dispose handles | R/P | Read four named values; close handles. No installer invocation and no `Win32_Product`. Handle disposal does not delete keys. | +| `powershell/Get-ServerStorage.ps1:4-9`, `Get-SamsaraStorage` | `Get-Volume` metadata/capacity | R/U | No volume/mount/partition resize or format. Storage-provider/SAN/cache effects are not verified. | +| `powershell/Get-ServerStorage.ps1:17-20`, `Get-SamsaraShares` | `Get-SmbShare` | R/U | No share/ACL creation/removal/change; only configuration metadata. | +| `powershell/Get-ServerStorage.ps1:23-43` | `Get-SmbOpenFile`; match paths and compute counts | R/U | Reads open-file metadata; does not call Close/ForceClose/Open on client files or read file contents. Broad PHI-capable metadata acquisition still occurs. | +| `powershell/Get-ServerCertificates.ps1:5-13`, `Get-SamsaraCertificates` | `Get-ChildItem Cert:\LocalMachine` and returned stores | R/U | No Import/Export-PfxCertificate/Remove-Item or key/store setters. Only selected public metadata is accessed by source; provider behavior not instrumented. | +| `powershell/Get-ServerInventory.ps1:9-10` | Assign child process PSModulePath; set Console.OutputEncoding using `New-Object UTF8Encoding` | P | Process environment/encoding change, not registry/system configuration. Direct invocation also changes these within the hosting PowerShell process's session scope; no persistent environment setter. | +| `powershell/Get-ServerInventory.ps1:19-23` | GetCurrent WindowsIdentity, construct WindowsPrincipal, IsInRole; Dispose | R/P | Inspects existing token, no UAC launch, LogonUser, credential acquisition or privilege enablement. Disposes an identity handle. | +| `powershell/Get-ServerInventory.ps1:15-17` | Dot-source fixed module filenames under PSScriptRoot | P/U | Executes trusted-on-deployment code; no authentication/hash validation of that code in source. A substituted script is not constrained by metadata-read intentions. | +| `powershell/Inventory.Common.ps1:20-26`, `:37-55` | Invoke a scriptblock chosen by fixed collector map | P | Not user command interpolation. Its safety depends on reviewed code/deployment integrity. | +| `powershell/Get-ServerStorage.ps1:39-43` | `Add-Member` on collected PSCustomObjects | P | Mutates in-memory result objects, not SMB shares or infrastructure. | +| `powershell/Inventory.Common.ps1:4-18`, `:58-65`; driver `:33`, `:53-56` | Build safe diagnostics, ConvertTo-Json, stdout/stderr, exit | P/O/U | Serialized stdout is data output; parent directs it to temp file. Direct-host transcripts/redirects may persist it; no app-controlled logging policy. | + +**Negative scan, bounded conclusion:** no source call to `Set-ItemProperty`, `Set-Content` against assessed configuration, `Set-Service`, `Start-Service`, `Stop-Service`, `Restart-Service`, `Register-ScheduledTask`, `Set-ScheduledTask`, `Remove-Item` against stores, `Enable-PSRemoting`, `Set-ExecutionPolicy`, `Invoke-CimMethod`, `Invoke-WebRequest`, `Invoke-RestMethod`, `Invoke-Command` or session creation was found in the production collectors. `New-Object` and `Add-Member` hits above are memory operations, not evidence of persistent infrastructure writes. A scan is not proof of native provider behavior or protection against a tampered checkout. + +## Python execution and all first-party write/state-change paths + +| File / function / lines | What can change | Condition, impact and safeguards | +| --- | --- | --- | +| `samsara/collectors/windows.py:35-48`, `powershell_executable` | R: GetSystemDirectoryW, file-existence checks | Validates Windows/64-bit Python and derives system PowerShell path instead of PATH/SystemRoot. No installation. Loaded executable/native signatures not checked by app. | +| `samsara/collectors/windows.py:51-64`, `run_section` | P/T: copy environment, create TemporaryFile, launch PowerShell | Every local Windows section. Fixed argument list, fixed section enum, absolute driver, NoProfile/NonInteractive, shell=False, limited PSModulePath. Full other environment inherited. Temp data written before projection. | +| `samsara/collectors/windows.py:69-92` | T/P: stdout writes, kill collector process, wait/reap, close/delete temp resource | Polls timeout/cancellation/output size. No target service PID is used for termination; kill acts on the `Popen` handle created by this function. Does not establish that provider services/caches are unaffected. Limits can overshoot between polls. | +| `samsara/collectors/windows.py:178-189`, `main` | O: create JSON file, write full inventory | Explicit `--output`; `open(...,'x')` rejects an existing file. No approved-root/ACL/network-path enforcement. Does not create parent directories itself. | +| `samsara/collectors/linux.py:20-53`, `run_command` | P/T: create temp output, launch fixed utility, kill/wait, cleanup | Local Linux checks. Selects executable from system directories and supplies minimal environment, shell=False. Public helper accepts an argument list, but production callers supply fixed commands; no UI command text is executed. Symlink/binary integrity is deployment responsibility. | +| `samsara/collectors/linux.py:198-211`, `main` | O: exclusive JSON file creation/write | User-requested CLI output with same destination-policy gap. | +| `samsara/reporting/export.py:15-16`, `export_json` | O: create or truncate JSON output | GUI/API caller supplies path; follows ordinary OS path resolution. No file extension, approved-root, ACL, no-follow or encryption enforcement in this function. | +| `samsara/reporting/export.py:19-29`, `inventory_artifact` | O/R: create sidecar with `xb`, write bytes, or read existing sidecar for comparison | Automatic on HTML export, not an independent raw-export consent. Refuses a differing existing artifact. Existing-file read has no size/no-follow restriction. Hash checks content equality, not publisher authenticity or privacy. | +| `samsara/reporting/export.py:39-41`, `:86`, `export_html` | O: sidecar then create/truncate HTML | Report destination user-selected. Writes are non-transactional; a failure after sidecar creation can leave that file behind. HTML can overwrite an existing path; no symlink/reparse or approved-root policy. | +| `tools/validate_windows_host.py:20-31`, `:43-45`, `main` | O: mkdir parents, JSON/HTML/sidecar, summary file; terminal output | Manual validation invocation on a supported host. Directory existence checked before long collection then mkdir(exist_ok=False); ancestor links/network paths remain possible. Summary excludes direct identifiers but still requires classification. | +| `samsara/ui/branding.py:9-17`, `configure_taskbar_identity` | P: SetCurrentProcessExplicitAppUserModelID | Windows GUI startup. Changes process/shell identity only; no source request to write registry/shortcuts. Native shell side effects not measured. | +| `samsara/main.py:10-20`, UI callbacks `samsara/ui/window.py:361-408` | P/U: Qt objects, threads/event loop, queued data, history, dialogs | GUI startup/Analyze actions. `app.exec()` is an event loop, not Python `exec`. Qt signal `.connect` calls are callbacks, not network connects. Native plugin/cache/portal behavior unverified. | +| `samsara/ui/window.py:401-479` | P: retain data/history, build widgets, deleteLater | Session results retained without a bounded retention/privacy-clear control. deleteLater is widget lifecycle, not file deletion or evidence of secure memory erasure. | +| `samsara/core/startup.py:7-20` | R/U: inspect display variables, dynamically load cursor library | May print an installation suggestion. Does **not** execute sudo/apt. Native library initialization/search-path behavior not verified here. | +| Python imports/runtime, no explicit first-party line invoking write | T/U: possible bytecode/native/font/module-analysis caches, logs/dumps/pagefile | Ordinary app launches are not configured with `-B`/a no-cache policy. Whether these writes occur on a given host is unverified. Source only cannot promise zero OS writes. | +| README setup commands (`README.md:8-13`, `:42-45`, `:56-60`, `:67-70`) | Manual environment/package creation/downloads, mkdir, direct WriteAllText | These are instructions, not automatic application behavior. `[IO.File]::WriteAllText` can overwrite; pip/venv install modifies the host if an operator runs it. Never silently include setup in a read-only production assessment claim. | + +No first-party production `chmod`, ACL setter, registry setter, system service/task/certificate modifier, arbitrary shell execution, automatic software installer or credential store was identified. All file writes above remain significant because approved output directories are not actually enforced. + +## Linux utility/read register + +| Source | Invocation / read | Classification and caveat | +| --- | --- | --- | +| `samsara/collectors/linux.py:106`, `:131-145` | socket.gethostname; `/etc/os-release`, `/proc/stat`; `ip -j address show` | R; no explicit DNS or socket connection. Utility/OS side effects unverified. | +| `samsara/collectors/linux.py:147-155` | `systemctl list-units --all --type=service/timer --no-legend --plain --no-pager`; `list-unit-files ...` | R/U; local systemd IPC, no start/stop/enable or task execution. | +| `samsara/collectors/linux_schedules.py:77-95` | `systemctl show` selected properties with validated timer names after `--` | R/U; names limited to 200; no arbitrary shell interpolation. | +| `samsara/collectors/linux_schedules.py:46-74` | enumerate `/etc/cron.d`, read regular non-symlink files and `/etc/crontab` | R; reads whole configuration text up to bounded length. Preliminary symlink check can race replacement; not atomic no-follow. | +| `samsara/collectors/linux.py:162-176` | `ss -H -n -t -a -p`; `rpm -qa --qf ...` or `dpkg-query -W -f=...` | R/U; package queries, not installation. Numeric ss output avoids explicit name resolution. Native utilities' caches/locking not traced. | +| `samsara/collectors/linux.py:177-186` | `/proc/self/mountinfo` read and string decoding | R; code intentionally does not stat/traverse mounted remote filesystems for capacity. Still collects potentially sensitive mount names/paths. | +| `samsara/collectors/importer.py:10-22`; `models/inventory.py:10-18`; `analyzers/policy.py:13-25`; `collectors/demo.py:9-13` | JSON/schema/policy/demo file reads | R; arbitrary selected paths may be network-backed. Policy metadata reads are not schema privacy approval. Current schema has no remote `$ref`. | + +## Development/test operations (not executed) + +| File / lines | Side effects if tests are run | +| --- | --- | +| `tests/test_core.py:37`; `tests/test_import.py:17-20` | TemporaryDirectory creation, synthetic report/JSON writes and automatic deletion. | +| `tests/test_linux.py:36-65` | Calls collector with mocked runner, but direct OS/proc/cron reads remain reachable; not a completely synthetic-host test. | +| `tests/test_linux.py:78-89` | Temporary reports; launches the real system `sleep` executable to test timeout/cancellation and kills/reaps it. Does not restart assessed services. | +| `tests/test_powershell.py:9-16` | Finds a PowerShell executable through PATH and executes the test script; captures stdout/stderr and may print diagnostics on assertion failure. Test-only behavior has different executable resolution from production. | +| `tests/powershell/Test-Collectors.ps1:5-20`, `:68-83` | Parses/dot-sources source; mocks provider functions, creates fake registry objects and modifies them with Add-Member. No live registry mutation is requested by these mocks. This does not certify production provider behavior. | +| `tests/test_scoring.py:174-214` | Temp report/sidecar/JSON writes, intentional synthetic sidecar overwrite for collision test, cleanup. | +| `tests/test_ui.py:2`, `:79-84`, `:138-162` | Sets process offscreen environment, starts Qt, writes synthetic temp exports/policy, cleans up. Native desktop runtime may still initialize libraries. | +| `tests/test_windows.py:125-133`, `:179-219` | Temporary export artifacts and fake stdout writes. Process lifecycle mocks mutate fake objects; production helper still creates temp files in these tests. | + +## Least-privilege assessment + +The code **does not require Domain Administrator**, request credentials, call `runas`/ShellExecute elevation, enable token privileges or configure remoting. It reads the existing token and conservatively marks selected non-elevated checks partial (`powershell/Get-ServerInventory.ps1:19-23`; `Inventory.Common.ps1:50-53`). This is an observation flag, not proof of required or sufficient permissions. + +| Scope | Expected access characteristic; exact production permission **Unverified** | Recommended account approach | +| --- | --- | --- | +| OS/build/basic domain and adapters | Many local CIM properties commonly readable by standard users; local WMI namespace/host policy may restrict them | Start with standard/delegated read access; verify required property set. | +| Services, TCP/process names | Basic enumeration may work without elevation; protected objects can be hidden | Grant only necessary enumeration rights; preserve partial states and avoid broad diagnostic privileges. | +| Roles/features | ServerManager availability/servicing policies may require greater local access | Verify separately; make optional rather than silently elevating. | +| Scheduled tasks | Visibility/read depends on each task's security descriptor | Delegate read on approved task folders, not task execution/modification. | +| Software HKLM | Read access to both views/entries needed; custom ACLs may deny | Read-only registry rights, no installer or write permissions. | +| Volumes/shares/open files | Storage and especially SMB open-file enumeration may need elevated/delegated server permissions | Approve privacy scope first; disable open-file query when not permitted. Do not grant blanket admin solely to remove a partial status. | +| Public certificate metadata | Store enumeration/public read may be possible; store-specific ACLs vary | Public-store access only; no private-key ACL grants. | + +A dedicated restricted assessment account can help if its scope, interactive access, output location, logging and lifecycle are approved. Determine delegation in an isolated lab, not by copying broad administrator membership. In-process metadata privacy, external logging and deployment integrity still matter even under least privilege. + +## Required future verification + +Only after CHS authorization: use an isolated, synthetic-data Windows lab, a signed exact release, standard-user and narrowly delegated tokens. Capture registry/service/task/certificate/configuration state before/after, enumerate filesystem/process/network changes and distinguish allowed report/temp locations from forbidden writes. Inspect PowerShell/module-analysis/transcript/EDR behavior and native cache paths. Use deliberately synthetic free-text canaries and do not disable enterprise logging. No such verification was performed in this audit; successful static scans alone cannot close the read-only assurance gap. + +Ordinary metadata reads may also cause host-managed access-time, antivirus, cache or audit activity. Whether those writes happen, where they are stored and whether they carry collected data depend on the actual filesystem/provider/host policy; no source-only conclusion here certifies their absence. No application or setup command was executed to test those effects. diff --git a/audit/REMEDIATION_PLAN.md b/audit/REMEDIATION_PLAN.md new file mode 100644 index 0000000..68a0829 --- /dev/null +++ b/audit/REMEDIATION_PLAN.md @@ -0,0 +1,133 @@ +# Remediation and production acceptance plan + +**Audit only:** none of the following changes was implemented. Priorities are approval gates, not CVSS scores: a **P0 production blocker** can arise from an unverified assurance without a demonstrated critical exploit. The source-only audit does not authorize production testing, package changes, policy changes or network access. Each future action needs its own CHS-approved scope. + +## P0 — Critical production blockers + +### P0-1: Resolve the PHI-processing model before deployment + +- **Findings:** S-01, S-02, S-03. **Affected:** all `powershell/Get-Server*.ps1`; `samsara/collectors/linux.py`, `linux_schedules.py`, `importer.py`; `samsara/models/inventory.py`; `samsara/analyzers/dependencies.py`; `samsara/reporting/export.py`. +- **Suggested implementation:** obtain CHS privacy/security/data-owner approval of every source and field. If management requires no PHI collection/processing, disable unrestricted SMB open-file/task/certificate/free-text acquisition by default and redesign around pre-approved numeric/enumerated or upstream-minimized information. Approve source naming/data standards rather than assuming an infrastructure string is safe. If sensitive acquisition is essential, explicitly reclassify the application/workflow as potentially processing PHI and obtain the corresponding organizational controls; do not retain a contradictory PHI-free claim. +- **Verification:** isolated synthetic host with privacy-canary strings in service/task names, accounts, share/file paths, certificate subjects, registry fields and provider errors. Trace provider acquisition as well as outputs, temp files, process memory exposure and native logs. No actual patient data. Review full task/certificate object materialization and SMB result contents. +- **Acceptance:** a signed field/source register and data-flow approval. For a no-processing claim, unapproved raw fields never enter the application/provider acquisition scope. If that cannot be established, claim remains Unverified and the workflow cannot be approved under that claim. A clean-looking HTML report or regex replacement alone does not pass. + +### P0-2: Create one enforceable privacy contract across processing and outputs + +- **Findings:** S-01, S-03, S-08. **Affected:** `schemas/inventory-v1.schema.json`; `samsara/models/inventory.py`; `collectors/windows.py`, `importer.py`, `linux.py`; analyzer/summary/report/UI boundaries. +- **Suggested implementation:** explicit field approval, complete scalar types/enums/ranges, bounded strings/arrays/depth, closed nested objects and fixed check identifiers. Reject or quarantine unapproved fields; do not quietly preserve them inside allowed containers. Apply a central reviewed privacy projection before evidence construction and every UI/export/log path, including direct exporter/API callers and sidecars. Do not treat schema validation or HTML escaping as PHI redaction. Ensure the importer does not claim that filtering after deserialization prevented initial sensitive processing. +- **Verification:** synthetic canaries in every allowed free-text key, nested data in weakly typed retained keys, arbitrary check names, unknown/error fields and direct export function inputs. Attempt malformed/large/deep JSON and demonstrate fail-closed handling without logging rejected values. Verify required timestamp format checks actually exist under the installed locked dependency set. +- **Acceptance:** approved inputs only; no unapproved value reaches assessment, UI, temp outputs or exports. Rejection reports contain fixed diagnostic codes only. All exporter entry points enforce the same contract. Any unavoidable pre-redaction acquisition is documented and governed under P0-1. + +### P0-3: Approve and enforce storage, temporary data and network destinations + +- **Findings:** S-03, S-04, S-05, S-09. **Affected:** `samsara/collectors/windows.py:51-92`; `linux.py:20-53`; `samsara/reporting/export.py`; UI file dialogs; CLI output handlers; `tools/validate_windows_host.py`. +- **Suggested implementation:** an approved bounded IPC or protected spool design; approved encrypted output/spool roots; restrictive inherited and newly created ACLs; canonical path validation and safe handle-based creation. Prohibit unapproved UNC, mapped, mounted-remote and reparse/symlink routes or put them behind a separately approved transfer workflow. Make full raw export an explicit privileged choice instead of an automatic companion to minimal HTML. Bound history/retention, apply output classification and document backup/cleanup behavior. Environment controls can be compensating controls only when documented and verified for this exact deployment. +- **Verification:** synthetic-only path matrix: allowed/denied roots, UNC/mapped shares, symlinks/reparse points, existing-file collisions, hostile writable directories, access failures, interrupted exports, temporary cleanup and restricted users. Verify DACLs and encrypted-at-rest posture under intended accounts. Trace egress and OS authentication without using real clinical data. +- **Acceptance:** full inventory cannot be written or transmitted outside approved destinations through application paths; all artifacts and temporary copies are protected by verified policy. Failure leaves no uncontrolled partial sensitive artifact. Security/privacy owners approve retention, backup and transfer. Closing a file is not accepted as secure-erasure evidence. + +### P0-4: Establish the release being approved and complete independent acceptance + +- **Findings:** S-07, S-10 and scope/provenance limitation. **Affected:** release packaging/deployment, Python/PowerShell/schema/assets; available `.git` placeholder and `Samsara.bundle`; organizational approval process. +- **Suggested implementation:** identify the actual upstream release/commit, reconcile any newer task/certificate changes and audit the exact bytes. Deploy an immutable ACL-protected reviewed release, with organizationally approved signatures/provenance and execution controls. Provide a clear read-only operational definition (no intentional configuration changes, separately approved report/temp/log writes). No blind assessment-time dependency installation or policy bypass. +- **Verification:** authorized isolated Windows Server matrix with before/after registry/service/task/certificate/configuration and filesystem/process/network traces; compare standard/delegated tokens. Verify tamper/unsigned-code rejection and metadata-only behavior under actual script signing, AppLocker/WDAC and logging policy. No production execution in this audit. +- **Acceptance:** CHS security, privacy and operations review the exact release's evidence, granted permissions, residual risks and rollback/incident plan. No production approval based solely on mock tests or the source's “read-only” label. Latest remote source/history is verified without overwriting unreviewed changes. + +## P1 — Required before production approval + +### P1-1: Reproducible dependency lock, minimal distribution and complete SBOM + +- **Findings:** S-07. **Affected:** `requirements.txt`, future target-specific locks/build manifests; Qt/native distribution; approved build/deployment pipeline. +- **Suggested implementation:** pin/hash the entire resolved dependency set for each approved Windows/Linux/Python target. Evaluate replacing the broad Qt meta-package with only required components, preserving existing UI/branding compatibility. Include native Qt/WebEngine/PDF/codecs/runtime libraries and OS/PowerShell module baseline in CycloneDX/SPDX. Verify provenance, license obligations and scanner/vendor coverage. +- **Verification:** clean approved offline install from a reviewed wheelhouse; no unresolved dependencies or undeclared downloads. Later authorized advisory scans and binary SBOM review using DEPENDENCY_INVENTORY.md commands. Review collection failures, vendor maintenance and native component gaps. +- **Acceptance:** reproducible installation and startup on approved platforms, every shipped component identified/justified, no unexplained runtime downloads, signed inventory/provenance and approved vulnerability/license exceptions. No invented CVE or “zero vulnerabilities” claim from an incomplete scan. + +### P1-2: Demonstrate offline operation and authorized egress boundaries + +- **Findings:** S-09. **Affected:** file-path handlers, `samsara/main.py`, Qt/native runtime, schemas, PowerShell/OS providers and deployment network policy. +- **Suggested implementation:** define approved destinations and deny other egress, including remote filesystem/authentication paths. Restrict schema references to approved local resources and protect schema provenance. Inventory native dialogs, IPC, code-signature validation, revocation lookups and host security tooling separately from direct application requests. Keep required enterprise security logging enabled. +- **Verification:** isolated synthetic runtime with network observation and denied egress; exercise GUI/local/CLI/import/policy/report/failure flows and dependency bootstrap separately. Attribute any connection to executable, destination, payload category and host policy. Do not infer no traffic merely from absence of requests/httpx imports. +- **Acceptance:** zero unauthorized assessment-data or credential/authentication egress, documented necessary OS/IPC traffic and working assessment/reporting without general Internet access. Any future remote collector requires a separate threat model/authorization. + +### P1-3: Least-privilege execution and environment/deployment integrity + +- **Findings:** S-06, S-07, S-10. **Affected:** `samsara/collectors/windows.py:35-64`; PowerShell driver/helpers/modules; deployment directories; `samsara/core/startup.py` native loading. +- **Suggested implementation:** tested minimum Windows child environment; no unrelated credential variables. Explicit registry value-type/non-expansion handling. Keep NoProfile, fixed arguments, system executable resolution, no bypass and no auto elevation. Define provider/task/store/registry read access by scope. Protect code/schema/policy/assets and search/plugin paths from modification by less-trusted accounts. Do not assume a self-hash inside editable Python is a security boundary. +- **Verification:** synthetic secret environment and REG_EXPAND_SZ canaries; standard/delegated tokens; missing/inaccessible modules/scopes; malicious executable/plugin/profile/module path attempts in an isolated lab. Verify no canary enters reports/logs/temp beyond the approved model. +- **Acceptance:** permissions matrix signed by operations; no Domain Administrator requirement; non-elevated assessments fail/mark partial safely; no inherited unrelated secrets or untrusted code path. Sensitive open-file collection disabled unless specifically approved. + +### P1-4: Protected artifact creation and integrity + +- **Findings:** S-03, S-04. **Affected:** `samsara/reporting/export.py`; CLI output; validation helper; UI save workflow. +- **Suggested implementation:** safe approved file handles, restrictive ACLs, explicit overwrite authorization, transactional report/artifact creation and bounded existing-artifact reads. Separate content hashes from authenticity; use organizational signing/attestation where required without storing signing keys in the project. Data minimization takes precedence over preserving unsafe raw content. +- **Verification:** link/race/collision tests, interrupted write, low-disk/access failure, tampered sidecar/HTML, unprivileged reader and unauthorized recipient scenarios with synthetic content. +- **Acceptance:** no unauthorized read/write/clobber, no unintended leftover full inventory, complete artifact set with clear classification and integrity provenance. A SHA-256 printed in an editable HTML file is not accepted as an authenticity guarantee. + +### P1-5: Complete structural, format and resource-bound validation + +- **Findings:** S-05, S-08. **Affected:** `samsara/models/inventory.py`; `schemas/inventory-v1.schema.json`; `samsara/collectors/importer.py`, `windows.py`; `samsara/analyzers/policy.py`; PowerShell full-envelope driver. +- **Suggested implementation:** reliable date/type validation and bounded JSON depth/collection sizes; explicit safe handling of RecursionError/invalid encodings/unknown keys. Bound direct PowerShell full mode as well as supervised Python use or remove unsupported unbounded paths from production instructions. Make byte/memory/time limits enforceable and account for temporary-output overshoot/provider enumeration cost. +- **Verification:** exact locked environment tests for malformed timestamps, nested retained fields/check identifiers, large provider results/JSON, output bursts, cancellation and native process cleanup. Test adversarial inputs only in an isolated lab. +- **Acceptance:** invalid input rejected with fixed safe diagnostics; guaranteed documented resource envelope; no uncontrolled collection or UI hang/partial artifact outside the approved policy. + +### P1-6: Logging, incident handling and operational authorization + +- **Findings:** S-01-S-05, S-09-S-10. **Affected:** error/status handling, console/validation outputs, deployment logging/EDR/transcription, operational documentation and release procedure. +- **Suggested implementation:** metadata-only audit events for operator/approved scope/tool version/status/artifact disposition, without raw inventory/error/PHI. Document OS logging and crash/pagefile behavior, incident response, retention and authorized assessment windows. Ensure user-interface labels do not substitute for actual host ownership/data authorization. Obtain privacy/security review of the exact validation-summary contents. +- **Verification:** synthetic canaries through all success/error/cancel/import/report paths, configured transcripts/EDR/dumps and approved log collectors. Verify expected attribution without leaking raw values. Simulate privacy incident handling without real patients. +- **Acceptance:** meaningful access/activity audit evidence with no unapproved content or destinations; agreed response/escalation and retention ownership. No HIPAA compliance assertion without separate organizational determination. + +## P2 — Recommended security improvements + +### P2-1: Make the test harness completely host-isolated + +- **Affected:** `tests/test_linux.py:36-65`, `tests/test_powershell.py:9-16`, native mocks, future privacy regression tests. +- **Implementation:** inject all host filesystem/provider boundaries, isolate runtime binary resolution and separate explicitly authorized integration tests from unit tests. Never rely on a comment that a test is synthetic. Preserve existing behavioral/risk tests. +- **Verification:** deny real `/proc`/cron/registry/network/provider access while unit tests run against fixture-only data; ensure forbidden attempts fail visibly. +- **Acceptance:** unit test suite produces only test-owned artifacts and no real host inventory; integration tests require explicit opt-in and approved lab scope. Tests were not run during this audit. + +### P2-2: Reduce display/session exposure + +- **Affected:** `samsara/ui/window.py:401-479`; summary/evidence models. +- **Implementation:** privacy-reduced summary by default, explicit authorized detailed view, bounded session history, clear-session controls and data lifetime documentation. Avoid claiming secure erasure of immutable runtime strings. +- **Verification:** synthetic data through navigation/history/cancel/close/screenshots/accessibility and OS dump policies. +- **Acceptance:** no unnecessary sensitive display/retention beyond the approved operator workflow; ownership of external screen-sharing/support policies documented. + +### P2-3: Harden offline HTML and sharing workflow + +- **Affected:** `samsara/reporting/export.py`; report distribution documentation. +- **Implementation:** preserve escaping/relative encoded links, add appropriate restrictive content policy where meaningful for offline reports, clear classification banners and approved recipient/workflow guidance. Minimal reports should not silently depend on full raw data. +- **Verification:** synthetic HTML/URL/control-character injection attempts and offline browser behavior on approved clients; test all artifact links and no remote content fetch. +- **Acceptance:** inert escaped content, no unauthorized active/remote content and reviewed file-sharing/retention behavior. Browser policy is not a substitute for data minimization. + +### P2-4: Continuous provenance and security checks + +- **Affected:** future CI/release process, Python/PowerShell/schema/policy artifacts and security documentation. +- **Implementation:** signed release manifests, recurring dependency/native advisory review, artifact/secret scans of current source and history, and explicit review of changed collector fields/commands. Treat clinical-output artifacts as excluded from source control and verify staging rather than relying on `.gitignore`. +- **Verification:** tamper, unsigned release, changed dependency and attempted sensitive-artifact staging tests in CI. +- **Acceptance:** traceable release/control owner, blocked unauthorized changes and current signed evidence for each promoted build. + +## P3 — Optional enhancements + +### P3-1: Approved external aggregation adapter + +- **Affected:** future collector interface and organizational monitoring integration; not the current application. +- **Implementation:** assess a read-only interface to already approved aggregated activity/asset identifiers, avoiding raw clinical filenames/task definitions. Any new connection requires separate security/privacy review and explicit destination controls. +- **Verification:** synthetic contract, least privilege, no patient-bearing source fields or unauthorized egress. +- **Acceptance:** the adapter reduces raw acquisition without adding unapproved telemetry, credentials or infrastructure configuration. + +### P3-2: Structured control evidence package + +- **Affected:** future audit/report tooling and deployment documentation. +- **Implementation:** machine-readable control/field registers, signed SBOM/provenance and automated acceptance summaries without raw host/patient data. Keep assessment risk separate from security/privacy compliance status. +- **Verification:** independent reviewers can reproduce the release/control mapping using approved artifacts. +- **Acceptance:** useful review evidence, no automatic “HIPAA compliant,” “PHI-free” or “safe to retire” certification. + +## Approval gates and final disposition + +1. Reconcile and identify the exact release and intended local execution model. +2. Privacy/data owners resolve P0-1 and approve the data boundary; an absolute no-PHI claim must be withdrawn unless actually established. +3. Close P0/P1 code and environment control gaps in a separately authorized change effort. +4. Validate the exact artifact on a synthetic isolated Windows matrix with least-privilege, filesystem/configuration/egress/logging evidence; complete native and dependency/security review. +5. CHS information security, privacy and operations accept documented residual risks and approve any narrowly scoped pilot. Any later production use still follows organizational authorization/change/incident requirements. + +**Current disposition: do not approve healthcare production deployment on the available evidence.** No actual PHI incident, malicious code, intentional destructive collector or verified CVE has been established. Equally, absence of such evidence is not proof of PHI-free processing, zero writes, no network activity or HIPAA compliance. diff --git a/audit/SECURITY_AUDIT.md b/audit/SECURITY_AUDIT.md new file mode 100644 index 0000000..93574a1 --- /dev/null +++ b/audit/SECURITY_AUDIT.md @@ -0,0 +1,248 @@ +# Samsara security and healthcare-data audit + +**Audit date:** 2026-10-30. **Method:** offline, source-only review. **Overall assessment:** High risk against the stated healthcare production requirements; **not ready for production approval**. This is not a HIPAA compliance certification, legal determination, penetration test, vulnerability-database scan, or evidence that an actual patient-data breach occurred. + +## Scope, provenance and limitations + +Reviewed the available working-tree Python application, PowerShell modules, schema, dependency declarations, tooling, tests, fixtures, documentation and artifact metadata. All application entry points and first-party source files were examined; static AST/import/call inventories and text searches were used without importing or running the application. Read installed distribution metadata and limited validator source to establish dependencies; the vendored third-party codebase/native binaries were not exhaustively audited. + +The local `.git` directory is an empty read-only environment placeholder, not a usable Git checkout. `Samsara.bundle` advertises `57e716b71052a5c2e06bd2cdd121973c6c3f0359`. That is an artifact reference, **not proof the working tree is a clean checkout of that commit**. The separately reported upstream `7fed3b9` reliability changes cannot be certified as included: no network request was made and remote Git history was not fetched. Audit the actual proposed release again after verifying its provenance. The snapshot manifest below identifies the bytes reviewed. + +No collectors, application GUI, unit tests, PowerShell, package installers, scanners, production queries or network clients were executed. Tests were inspected as source, not used as runtime evidence. Only the new `audit/` documents were written. No production reports or patient data were requested. Fixture flags identify synthetic test data; flags are not a general guarantee that arbitrary imported data are non-sensitive. Git-object history, opaque bundle contents beyond reference metadata, production ACLs, OS logging, native module behavior, binary provenance, telemetry and current CVEs remain **Unverified**. + +**Status convention:** Pass confirms a narrowly scoped source-level control; Fail means code contradicts a requested assurance/control; Unverified means source evidence does not establish the runtime or organizational claim. A confirmed exposure-capable path is not confirmation that its actual values are PHI. Names, accounts and IP addresses are not automatically PHI; unrestricted values can become PHI when they identify a patient in a healthcare context. + +## Executive summary + +1. **Samsara cannot currently be described as PHI-free.** Names, paths, identities, certificate subjects and other strings flow into memory, UI, temporary output and reports without a patient-data exclusion policy. +2. **Open-file aggregation is not collection minimization.** `Get-SmbOpenFile` obtains objects before aggregate projection; filenames and client/user metadata can enter PowerShell memory even when absent from final JSON. +3. **Configuration-changing Windows collector commands were not identified.** This supports a narrow read-only configuration intent. It does not establish zero host writes: temporary output, report files, process creation/termination, interpreter/native caches and OS logging are separate issues. +4. **No explicit first-party HTTP upload, telemetry, database connection or remote Windows collection was identified.** Arbitrary filesystem paths can nevertheless reach UNC/mapped/network-backed destinations; native dependencies/OS behavior have not been egress-tested. +5. **Dependency assurance is incomplete.** Broad direct version ranges, no lockfile/hashes/SBOM, unneeded installed Qt components and unverified native/transitive vulnerability status prevent approval. +6. **Existing redaction is selective.** Error-category normalization and omission of command arguments/private keys are valuable controls, but do not remove sensitive values from approved free-text fields or undo earlier processing. + +## Entry points and traced execution + +| Entry | Execution and trust boundary | +| --- | --- | +| `samsara/main.py:5-24` | Creates Qt application, configures process identity, imports the existing UI. Native Qt plugins and desktop integration enter the trusted runtime. | +| `samsara/ui/window.py:19-54`, `:361-408` | User action starts `CollectionJob`; local OS determines the Windows/Linux backend, a file invokes JSON import, and samples read bundled fixtures. The UI receives a complete data dictionary and derives assessment results. | +| `samsara/collectors/windows.py:129-175` | Fixed section dispatch -> system PowerShell executable -> JSON in temporary stdout -> section validation -> field projection -> final v1 validation. The data have already been collected and written to temporary storage before final projection. | +| `powershell/Get-ServerInventory.ps1:1-56` | Independent fixed section or full-envelope dispatch. Modules are dot-sourced from the checkout. Full mode lacks the Python timeout supervisor. | +| `samsara/collectors/windows.py:178-193` | Headless local Windows collection and exclusive creation of a user-selected inventory file. | +| `samsara/collectors/linux.py:100-215` | Local metadata/configuration reads and fixed utility invocations; separate CLI writes JSON. No remoting parameters. | +| `tools/validate_windows_host.py:14-51` | Intentionally performs real local collection, creates directories, exports JSON/HTML/sidecar, writes a diagnostic summary and prints status metadata. **Not executed in this audit.** | +| `samsara/reporting/export.py:15-86` | Public export functions write all supplied data without enforcing a privacy profile or approved output root. GUI calls them at `samsara/ui/window.py:486-498`. | +| `tests/test_powershell.py:9-16`, `tests/test_linux.py:36-65` | Development-only paths. Native test launcher executes PowerShell when present; some Linux tests mock commands but still invoke functions that read real local metadata/cron. Do not assume the suite is wholly host-isolated. | + +## Confirmed findings + +### S-01 — PHI-capable free-text collection and export — High — Fail + +**Evidence:** `powershell/Get-ServerServices.ps1:5-7`; `Get-ServerTasks.ps1:12-16`; `Get-ServerCertificates.ps1:9-12`; `Get-ServerSoftware.ps1:8-12`; `Get-ServerStorage.ps1:17-19`; `Get-ServerSystem.ps1:24-31`. All paths are under `powershell/`. + +Service/task names, account identities, share paths, certificate subject/issuer and registry display fields are copied as strings. `samsara/models/inventory.py:22-41` selects property names, not privacy-approved values. `samsara/analyzers/dependencies.py:118`, `:145-167`, `:220-240` embeds names, accounts, paths and endpoints in evidence; `samsara/reporting/export.py:15-25`, `:50-86` exports them. A healthcare identifier in any retained field can therefore reach reports and temporary collector output. + +**Impact:** potential PHI processing/disclosure through metadata, not evidence that a patient record was actually collected. **Action:** define a CHS-approved field/source policy. Suppress high-risk strings at source or explicitly classify the workflow as potentially processing PHI and apply approved controls. Post-query redaction cannot prove that PHI never entered memory. + +### S-02 — Unrestricted provider objects precede redaction — High — Fail + +**Evidence:** `powershell/Get-ServerStorage.ps1:22-43` retrieves all open-file objects, processes full paths/relative filenames and client computer values, then exports counts. Administrative shares are excluded only after the broad query. `Get-ServerTasks.ps1:4-16` receives full task definitions before selecting fields. `Get-ServerCertificates.ps1:5-12` receives provider certificate objects before selecting metadata. + +**Impact:** the raw SMB result includes potentially identifying file/client metadata in PowerShell/provider memory. Task arguments/descriptions/event-trigger payloads and certificate extension material may also be present in provider objects; exact provider materialization is **Unverified** without native inspection. Their omission from JSON is not proof of non-collection. **Action:** disable unapproved sources by default; redesign acquisition to avoid retrieving patient-bearing fields where possible, or approve it as controlled sensitive processing. Never use real PHI to test this boundary. + +### S-03 — Exports/history retain more information than the main report shows — High — Fail + +**Evidence:** `samsara/reporting/export.py:15-29`, `:39-41`, `:81-86`; `samsara/ui/window.py:401-417`, `:460-479`. + +An HTML save automatically creates a full inventory JSON sidecar. JSON assessment export includes complete inventory and evidence. Full data and assessments accumulate in `self.records` for the session; there is no data TTL, history size limit or explicit secure disposal. Neither export function independently validates/redacts its supplied dictionaries. HTML escaping prevents markup execution, not PHI disclosure. SHA-256 is neither anonymization nor encryption. + +**Action:** approved minimal exports by default, separate privileged authorization for raw artifacts, central privacy projection before every output, bounded session retention, restricted storage and a documented purge policy. Label/hash/signature and access controls must not imply de-identification. + +### S-04 — Arbitrary output/input paths; no approved destination or file-permission policy — High — Fail + +**Evidence:** `samsara/ui/window.py:330-359`, `:486-498`; `samsara/reporting/export.py:16-28`, `:86`; `samsara/collectors/windows.py:185-186`; `samsara/collectors/linux.py:207-208`; `tools/validate_windows_host.py:20-43`. + +Paths may be local, network-backed, symlinks/reparse points or destinations outside an approved directory. HTML/JSON `write_text` truncates an existing file and follows ordinary path resolution. Sidecar exclusive creation is a positive control, but its existing-file `read_bytes` comparison follows the existing path and is unbounded. There is no explicit restrictive ACL/mode, encryption, destination allowlist or retention enforcement. Ordinary CLI `x` mode prevents overwriting an existing path, not network use or unsafe newly created permissions. + +**Impact:** unauthorized disclosure/clobbering under the operator's rights; use of a writable hostile directory creates additional race/link risk. These are conditional, not observed exploits. **Action:** canonical approved roots, safe handle-based creation, reparse/symlink policy, least-privilege ACLs, explicit overwrite decisions and encrypted approved storage. Treat remote destinations as a separately authorized feature. + +### S-05 — Collection writes unredacted stdout to host temporary storage — High — Fail for a PHI-free/zero-write claim + +**Evidence:** `samsara/collectors/windows.py:60-92`; `samsara/collectors/linux.py:29-53`. + +Subprocess output is directed to a disk-backed temporary file before JSON parsing/projection. For Windows, this runs on the assessed local execution host. Deletion/closing in `finally` is beneficial but is not secure erasure and does not cover pagefiles, crash dumps, storage snapshots, EDR or transcription. Temporary-file ACLs and actual placement have not been tested on Windows. The 10 MiB size check is polled, so it is not a hard disk quota; collection can overshoot between polls. + +**Action:** an approved bounded IPC/spooling design, no raw patient-bearing acquisition, controlled temporary storage if unavoidable, OS protection/retention and documented exceptions. Do not promise reliable zeroization of Python/PowerShell strings or SSD files. + +### S-06 — Full parent environment is copied into the Windows child — Medium — Fail for strict data minimization + +**Evidence:** `samsara/collectors/windows.py:57-64`; `powershell/Get-ServerInventory.ps1:9`. Only `PSModulePath` is narrowed. Other inherited variables can include secrets. The application does not intentionally dump the environment into reports, but copies it in memory and passes it to PowerShell. Also review default registry `GetValue` expansion for expandable strings (`powershell/Get-ServerSoftware.ps1:8-11`); whether production values expose substituted environment data is **Unverified**. + +**Action:** a tested minimum child-environment allowlist; preserve required Windows operation without carrying unrelated credentials. Read approved registry types with explicit non-expansion semantics where appropriate. Validate with synthetic secret canaries. + +### S-07 — Dependency/release integrity is not reproducible — High — Fail + +**Evidence:** `requirements.txt:1-2`; `samsara/collectors/windows.py:19`, `:45-64`; `powershell/Get-ServerInventory.ps1:15-17`; `samsara/core/startup.py:14`; `samsara/ui/branding.py:20-22`. + +There are no exact dependency locks, required hashes, build manifest/SBOM or first-party release verification in the available tree. Absolute script location and a system PowerShell path reduce search-path risk, but the checkout's Python/scripts/schema/assets remain trusted executable/configuration inputs without application-enforced release authenticity. A writable elevated deployment could execute altered code with that token. Qt/native image/plugin dependencies extend this boundary. No exploitation or compromised dependency was established. + +The local PySide6 meta-package also installs WebEngine, PDF and Addons components not used by the application's imports. Installed capability is not evidence that these components are launched or transmit telemetry. + +**Action:** immutable reviewed deployment ACLs, organization-approved signatures/publisher enforcement, platform-specific hashed locks, approved wheelhouse and full native/Python SBOM. Evaluate a minimal Qt distribution after functional/security testing. + +### S-08 — Allowlist/schema validation is not fully fail-closed — Medium — Fail + +**Evidence:** `schemas/inventory-v1.schema.json:25-80`, `:81-145`, `:167-190`, `:216-250`; `samsara/models/inventory.py:15-41`; `samsara/collectors/importer.py:10-53`. + +Nested objects generally permit extra properties; some retained keys (e.g. `server.last_boot`, optional network process/PID/direction metadata) lack complete type/value constraints. Projection is shallow, so an object in a retained but weakly typed field can preserve nested unapproved data. Arbitrary check names survive import and enter evidence/report text. Imports first deserialize all content, including discarded fields; live Windows projection occurs after temporary output has already been written. Arrays/strings/depth lack comprehensive structural limits despite the 10 MiB input cap. + +The local jsonschema `_format.py:395-402` registers date-time validation only when `rfc3339_validator` can import; that module/distribution is absent in the reviewed isolated venv metadata. `_format.py:111-129` silently returns for an unavailable checker. Effective format enforcement is consequently not assured by merely constructing `FormatChecker`; alternative externally injected module availability is **Unverified**. + +**Action:** close nested schemas, type/bound every retained field, enumerate check identifiers, reject unapproved structures before normal processing, enforce depth/count/value limits and explicitly assert the required format validators are available. Privacy classification must go beyond types. + +### S-09 — Offline/no-exfiltration is not enforced — High — Fail for an unconditional no-network claim + +**Evidence:** arbitrary `Path.open`/writes in `samsara/collectors/importer.py:11`, `samsara/analyzers/policy.py:19-23`, `samsara/reporting/export.py:16-28`, `:86`, and `tools/validate_windows_host.py:20-43`. + +No explicit first-party HTTP/API upload was found. Nevertheless UNC/mapped/network-backed filesystem paths can cause OS network reads/writes and authentication. Destination restrictions or egress policies do not exist in the source. Qt native dialogs/desktop integration, certificate validation, OS providers, endpoint security and dependencies have not been observed offline. Public URLs in documentation and the schema identifier do not themselves demonstrate network activity. + +**Action:** approved local-only path policy, deny-by-default host/network egress as appropriate, and isolated synthetic runtime tracing. Do not disable enterprise audit/security logging to conceal data; minimize what is collected and establish approved logging destinations. + +### S-10 — No independently verified read-only/least-privilege release acceptance — Medium — Unverified + +**Evidence:** local subprocess paths and all PowerShell modules; token check `powershell/Get-ServerInventory.ps1:19-23`; conservative permissions handling `powershell/Inventory.Common.ps1:45-54`; native test launcher `tests/test_powershell.py:9-16`. + +No explicit assessed registry/service/task/certificate mutation was found. There is no automatic privilege elevation or remoting enablement. Those are positive controls, not proof of all native provider side effects or required access rights. Exact permission needs, silent provider filtering, module caches/logs and release behavior on each Windows Server version remain unverified in this audit. The application checks OS/permissions, not CHS change authorization or host ownership. + +**Action:** a signed exact-release lab acceptance plan with synthetic data; standard-user and narrowly delegated accounts; registry/task/service/certificate/configuration diffs and filesystem/process/egress traces; organizational host/scope authorization. See the read-only and remediation documents. + +## Positive controls confirmed by source + +- Production subprocesses use argument lists with `shell=False`; Windows has a fixed section enum, `-NoProfile`, `-NonInteractive`, a repository script path and no execution-policy bypass (`samsara/collectors/windows.py:35-64`). Linux callers use fixed arguments and timer-name validation (`samsara/collectors/linux.py:20-32`, `linux_schedules.py:77-82`). This does not protect a tampered deployment itself. +- Software reads both registry views and opens child keys read-only; no `Win32_Product` query (`powershell/Get-ServerSoftware.ps1:6-12`, `:22-32`). Registry handles are disposed. +- No explicit credential prompt, password storage, service restart, scheduled-task modification, registry write, certificate/private-key export, WinRM/SSH setup, clinical database query, packet capture or event/application-log collector was identified. +- Provider errors are converted to fixed categories rather than raw exception serialization (`powershell/Inventory.Common.ps1:4-18`). Import maps error text/codes and removes source-label assertions (`samsara/collectors/importer.py:25-53`). Raw errors still can exist in process/OS memory or external logs. +- HTML values and relative artifact links are escaped/encoded (`samsara/reporting/export.py:32-36`, `:40`, `:82`). UI text labels are plain text (`samsara/ui/window.py:158-164`). Generated HTML contains no script or external content source. +- Failed checks remain explicit and coverage is separate from workload risk (`samsara/analyzers/assessment.py:37-78`). Retirement review is always required. This is not a validated business-dependency or privacy classifier. + +## Network-capable paths + +| Path | Destination / payload | Conditions and disablement | Assessment | +| --- | --- | --- | --- | +| Report/CLI/tool filesystem writes | User-selected UNC/mapped/mounted path; full inventory/evidence or summary | User selects path; no local-only/approved-root enforcement. Constrain via future code/OS policy. | Capability confirmed; actual connections not observed. | +| Inventory/policy input and existing sidecar reads | User-selected filesystem destination; remote file read and possibly OS authentication | File selection/path argument or existing sidecar comparison; no remote-path prohibition. | Capability confirmed. Not an HTTP integration. | +| `samsara/main.py:14-20` Qt/native file dialogs | Desktop IPC/portal/file-dialog providers; app identity, displayed UI and path metadata; possible network browsing | Desktop launch/native dialogs. No dedicated Samsara network-disable switch. | IPC/native behavior Unverified here. Qt signal `.connect` calls are not sockets. | +| Windows OS/CIM/storage/certificate providers | Local provider calls by source; possible downstream provider/OS/CRL/EDR destinations depend on host | Local collection/executable load. No `-ComputerName` or `-CimSession` supplied. | Native downstream effects Unverified. Do not infer remote queries merely from cmdlet capability. | +| Deployment on a network share | Python imports, scripts, schema and assets read from a network-backed checkout; OS file/authentication traffic | Source root is derived from `__file__`; no local-only deployment restriction. Approved immutable local deployment can avoid this route. | Capability conditional on deployment; actual destination Unverified. | +| Dependency setup in README | Configured pip index/mirrors or OS package repositories; package/version/platform metadata | Manually executed setup only; not GUI auto-update. Offline approved wheelhouse is possible but not provided. | Documented network-capable workflow; not executed. | +| Schema/URLs and helpers | Current schema uses an identifier URL, no remote `$ref`; `urllib.parse.quote` encodes strings, `socket.gethostname` obtains local name | Local parse/hostname. No current first-party resolver/connect/upload call. | No direct network request identified in these uses. | + +A source/credential-pattern search found only synthetic credential canaries in tests, not a confirmed live hardcoded secret. This is not a complete history/binary secret scan. No network requests or captured assessment data were sent during the audit. + +## Final management assessment + +| Question | Status | Evidence-based answer | +| --- | --- | --- | +| 1. Can Samsara potentially collect PHI? | **Fail** against the PHI-free requirement | Yes. S-01/S-02: unrestricted metadata can carry patient information into memory, temporary stdout and reports. Field names do not establish privacy safety. | +| 2. Are there confirmed paths that collect or expose PHI? | **Unverified** for actual PHI occurrence | Confirmed **PHI-capable paths** include service/task/share/certificate strings and broad SMB objects (S-01/S-02/S-03). No actual patient values or production reports were examined; no actual PHI incident is established. | +| 3. Is it fully read-only with respect to assessed Windows Servers? | **Unverified** | Source-level configuration mutation scan passes narrowly. Local execution still creates/terminates collector processes and writes temporary/report files; providers/caches/logging are not instrumented. An absolute zero-state-change claim would fail. | +| 4. What can modify local files or other state? | **Fail** for approved-write enforcement | Export JSON/HTML/sidecars, CLI inventory output, validation directories/summary, temporary creation/deletion, child-process lifecycle and shell identity. Implicit Python/Qt/OS caches/logs are Unverified. Approved directory/ACL restrictions are missing (S-03-S-05). | +| 5. Does it make external network connections? | **Unverified** | No direct application HTTP upload/telemetry/database connection was found. Actual runtime egress was not observed. Network-backed paths are allowed and can transmit inventory/authentication; the unconditional no-network control fails, and native/OS/dependency behavior is unverified (S-09). | +| 6. Are all dependencies identified and justified? | **Fail** | Direct and discoverable Python dependencies are inventoried; unused Qt components, native closure, exact Windows versions/provenance and CVEs remain unresolved. No reproducible release lock/SBOM (S-07). | +| 7. What prevents production approval? | **Fail — approval not supportable** | PHI-capable acquisition/exports, unapproved storage/egress, incomplete release/dependency integrity and native/least-privilege evidence; no completed organizational privacy/security review. | +| 8. What must change before proposing CHS deployment? | **Fail — not ready** | Close P0/P1 items in REMEDIATION_PLAN.md, define the allowed-data/read-only model, validate exact release in a synthetic isolated lab and obtain CHS security/privacy/operations approvals. Successful code tests alone cannot establish HIPAA compliance. | + +## Deliverables and evidence assurance + +- [Collector data flow and PHI exposure](PHI_DATA_FLOW.md) +- [Read-only command, write and privilege verification](READ_ONLY_VERIFICATION.md) +- [Dependencies, native runtime and outstanding scans](DEPENDENCY_INVENTORY.md) +- [Prioritized remediation and acceptance plan](REMEDIATION_PLAN.md) + +The final audit integrity check compares pre-existing source/artifact hashes against the initial snapshot; its result is recorded below. No remediation has been implemented as part of this audit. + + +## Snapshot manifest and no-change verification + +Verified **74 pre-existing first-party/source/artifact files unchanged** against the initial audit snapshot. Only the five requested Markdown documents under `audit/` were created. The hash scope excludes the environment-owned `.git/.aws/.codex/.agents` placeholders, the third-party `.venv`, bytecode caches and the new audit documents. Those exclusions are scope boundaries, not proof of their contents or integrity. + +Snapshot SHA-256: `a8fc056071b1d519097a2b07bcf2d7fd3678408b3124972260eb5a593718ca1c`. Reproduction: compute SHA-256 of each listed file's bytes, serialize the path-to-hash dictionary using Python `json.dumps(files, sort_keys=True, separators=(',', ':')).encode()`, then SHA-256 that serialization. These hashes identify the reviewed snapshot; they are not a digital signature or evidence of release publisher authenticity. + +| Reviewed file/artifact | SHA-256 | +| --- | --- | +| `.gitignore` | `4586fa19892a2c7b5edd2f13882c6b0243e629a55b85478cdd4563fb236cf6f3` | +| `App Logo/Samsara Logo.jpg` | `469796ad788c77a8c4e5cc30e36f6884c4d76182b45d3894413554ad9d0ec421` | +| `README.md` | `ca505acd1777e7e8f2c8e025823980dc7a529bb3b291b89b0ad8c33ce3dc8986` | +| `Samsara.bundle` | `95f6c32d41f81b245c0c854210c94af6ebd7ec17c0fdcc72c49130d8fe50ce00` | +| `docs/DEVELOPMENT.md` | `9775086dd05281fd0312e00bdb756baa855057f3e268421494656e59f9a6aa88` | +| `docs/IMPLEMENTATION_PLAN.md` | `bbca88844a99de02a79d1c0515d9aacdaeb26a986fab4d85df850036862b3ab4` | +| `docs/PLATFORMS.md` | `544d0d3703bf77fe0021477f15e6290eb0aab4b4d9ab2b9cf143bf88f4246a04` | +| `docs/SCORING.md` | `81899ab358ed8a3042b9dcde04aa303df0e6762a857b9cfe6f3b56f459ff034e` | +| `powershell/Get-ServerCertificates.ps1` | `dc54aa83d7814b22319a83fca64266927ead7a53a1d2416ec12e79afe6ea4d49` | +| `powershell/Get-ServerInventory.ps1` | `cd5cea83271018370b13007e6a5ffcbc40d2a5828b58a9a6af60a8c9ebf84206` | +| `powershell/Get-ServerNetwork.ps1` | `dacee9c73e1f622e42285c920e3d0c912623bcb668a9fe2e384137923edd3caa` | +| `powershell/Get-ServerRoles.ps1` | `fa34a5d98d6bc7c78dda7dab0d0184be43d961e159c2f13a4d37a290f47e4999` | +| `powershell/Get-ServerServices.ps1` | `7a0a63c456ab21f465aaf608a36eb586021235a4abad44247100c0508384e5ff` | +| `powershell/Get-ServerSoftware.ps1` | `30e69b8cdc05c9c844b6a3c820ab22d674ef3d14a47ef0bd9c960b23c90f78d5` | +| `powershell/Get-ServerStorage.ps1` | `513c90a98e2ee7ada4be37be9c08b57488edcee47968b074dbf6d4a210478c7b` | +| `powershell/Get-ServerSystem.ps1` | `82a0bd12c30aafa7b9edfbe77d6dca655dec8019118f9290c53a22bd3657f9ea` | +| `powershell/Get-ServerTasks.ps1` | `bd716c72ec1584317702779e4ab9bfdecfc2da5616f1713d6a1ec21cb55844d0` | +| `powershell/Inventory.Common.ps1` | `4a43ebe04e1ed30f47dca3fbaa59aa2e601e7f7a6631563fddb90fe42b4264b1` | +| `powershell/README.md` | `20b9d647b9db95283f2374e6256b8acc605b61964508fe94fcf8671403dcb3e0` | +| `requirements.txt` | `a2c54b88e249231a1d295b2d69aa17bac18f310d53916a8f98eb58abfdbff210` | +| `samsara/__init__.py` | `01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b` | +| `samsara/analyzers/__init__.py` | `01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b` | +| `samsara/analyzers/assessment.py` | `019f8c1686c95a2bebacef05ed10d8029e32cf28b4d212f26cce89fcc48cb549` | +| `samsara/analyzers/dependencies.py` | `46960c00169ed9e591eb3682c126033b76859d39ddc3093005ad3b68c66ada14` | +| `samsara/analyzers/policy.py` | `68fddefc819503f5d6114567806f69c1e5c4db30922a8911f6f094c4ccce4cbf` | +| `samsara/analyzers/summary.py` | `31b66ff0270ffe1917cd5fcc72f1ce7be1159dff30c003dc205e5112e6139b67` | +| `samsara/assets/demo_inventory.json` | `106123be22212cd6f9863ee53be88a29872c52a90211920e05e4ca2d6a82e263` | +| `samsara/assets/demo_linux_inventory.json` | `480007766fc86891caec2b08d316ddbd4da72a9c6d2f71d10563770d087e2919` | +| `samsara/assets/samsara-logo.jpg` | `469796ad788c77a8c4e5cc30e36f6884c4d76182b45d3894413554ad9d0ec421` | +| `samsara/assets/scoring_policy.json` | `070b8b2ebcddb7fb7461ed74ca6d9bcc01cbacdb0cd0c681e18f0964e6a9064b` | +| `samsara/collectors/__init__.py` | `01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b` | +| `samsara/collectors/demo.py` | `d231c66ec17a732f4e0a43dd5b5b3617695fc32348a507d53afebdb81f75d3a5` | +| `samsara/collectors/importer.py` | `459282230e2cee80cbfb026e17ac9013cd20f2ba2999595fe972f310856c6e96` | +| `samsara/collectors/linux.py` | `1b5469b5a65b973f6b636236bd887bcae8b4f9d1559127bee9147ec1011ec748` | +| `samsara/collectors/linux_schedules.py` | `77c112f7904d7fd5c7cc99364edfb53417cf643a9e4b3fc50b67efa413625a61` | +| `samsara/collectors/windows.py` | `d20922cbb72c5aa1d105dcafa75ad5fa696a37730a7e29f1194503503f2e277b` | +| `samsara/core/__init__.py` | `01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b` | +| `samsara/core/detection.py` | `87d5d79b50220d82365108ae746dcc3d6b0d257cc0cb2e3a365fece02bb1efd2` | +| `samsara/core/startup.py` | `7488cb7ed8fddd96989baef52447c6d99ec1cc36d56451191c014b928c92356f` | +| `samsara/core/validation.py` | `0acf902316b6b408dcee064bf85acb5cddbe419c44a6a22bd883f11bf31bff6a` | +| `samsara/main.py` | `8f4ed1b7ca80fc3745f4ab09a9d8ace027cd64827ca98818adc1e94cfab12280` | +| `samsara/models/__init__.py` | `01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b` | +| `samsara/models/inventory.py` | `0d2e2d05fca23fde4f33003cd363f72ce72a1c7402c1e688eee01ee15eba88eb` | +| `samsara/reporting/__init__.py` | `01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b` | +| `samsara/reporting/export.py` | `248057ada2607039e9606745b72a942a9b3e3cecf15e6a918744a2ca09649a49` | +| `samsara/ui/__init__.py` | `01ba4719c80b6fe911b091a7c05124b64eeece964e09c058ef8f9805daca546b` | +| `samsara/ui/branding.py` | `9d488c96491edfdb30e9df7537b52f4e21a98c46f6616cf67de48936059215bf` | +| `samsara/ui/window.py` | `4e4a063c43b55244dce60a9a3a436652638ca54fd87be568b2f4fda032eeda56` | +| `schemas/inventory-v1.schema.json` | `e747c7c90cbd939da4d5fa4cd08993feec1d07635af1e07ef8747c264467ef11` | +| `tests/fixtures/scoring/active_file_server.json` | `ffe6ec28840320b9574ac88dcf92cf49840c5f661725abf7a284cf2e2f6928c6` | +| `tests/fixtures/scoring/active_iis_server.json` | `5a636dd1274983ac6e1841430fe5e16682282ad016be716be964539bb81e3483` | +| `tests/fixtures/scoring/active_sql_server.json` | `0e79ff728a31bb3839baaf81103b358c3a8d5a541ec9389154faf7737438dd6c` | +| `tests/fixtures/scoring/clean_windows.json` | `f80d20573af20cc646f7096e5e6e5e467e08e866dbc9280151eae58c0a35249f` | +| `tests/fixtures/scoring/domain_controller.json` | `4a1a92418d4eafd5ab80fbafa9e01f611e946fb28aa408d5ddad1141ea621c9d` | +| `tests/fixtures/scoring/inactive_game_server.json` | `183d84029bdc3c24666123c29690d676273a595e85a88bf33b414f2845c084a9` | +| `tests/fixtures/scoring/incomplete_collection.json` | `abeb6d55a6d78e0fe7f05277abb759357ad0d624929c2e1a2d8c1953671714b7` | +| `tests/fixtures/synthetic_inventory.json` | `921a7841858cd9205ece3fc8d7d18a63b76c7c0a2179dfb4ed7085fcca91b55f` | +| `tests/fixtures/windows/server-2016.json` | `d2735abdcc597b503c72889e75232ca9012b886d9a9d7a535d782d434f1341f4` | +| `tests/fixtures/windows/server-2019.json` | `0c33bab3ad781af897ff21797ac6ca63eeb41cab8e9b5c86deab1e97ac2e44f7` | +| `tests/fixtures/windows/server-2022.json` | `dae9016a891b01ffafdbd66dfc0e50d39a4a2c9fa7225188abff1f97012814ca` | +| `tests/fixtures/windows/server-2025.json` | `47ee9d459ebb165b1def7b44fe745c71c193c0626d8a1a7fba7576f091bea308` | +| `tests/powershell/Test-Collectors.ps1` | `36b4ffc7a95e21663ac956c2e2a2c6b64802441f113f2be3e56f031ef1709331` | +| `tests/test_core.py` | `308afb153858871cf627bbfdbf3522ac002d40e8a24a181c8c2153113f054cf3` | +| `tests/test_detection.py` | `7325d65fbe46b95dbf14e6e7a2fa979cbe155e4394acdfa21f27794246d5f7e6` | +| `tests/test_import.py` | `ea9f4a9bd4ea5f6a24a053b3058f37ed41050c23e4c8e54b68c00eda14d465fc` | +| `tests/test_linux.py` | `dd6960e86ea60576c78512a7f23e7b34d242947584960ad16d23e40a0ed89987` | +| `tests/test_powershell.py` | `44b7d38cbea0c732380751ff84b7a6d1f9a48e20c290e5843885d39f9ed567e5` | +| `tests/test_schedules.py` | `e8aa0b86d25ee3053d339776bb94946625ace3d404f56a30d40bc3bd4abd31fb` | +| `tests/test_scoring.py` | `a4898b8b612aea29214a391ddfb745983b2bf3be9cc170a16a7ea25e6e6f7736` | +| `tests/test_startup.py` | `1d46d1d183f7cb75998722660bc38e61d580558f8f7941b780ab3ea19f51efa4` | +| `tests/test_summary.py` | `9a87b734dc565711057abbd0fc19ebcafdabf6afe09b49d7ddc1b035c5c2ca96` | +| `tests/test_ui.py` | `3b6de4af474d326d070a0154df706a37a7f663755d46110f0859692d0d91f97e` | +| `tests/test_windows.py` | `b5076af4d95f3530c97313f607578b5f0b98262eb71fdb99e188164ae1c551a7` | +| `tools/validate_windows_host.py` | `dabfae5f7c3948e45dc4bef9d4a00138979cc2b1cf9fd13b3bbaea417aa40213` | + diff --git a/constraints-tested-linux.txt b/constraints-tested-linux.txt new file mode 100644 index 0000000..d96f75b --- /dev/null +++ b/constraints-tested-linux.txt @@ -0,0 +1,9 @@ +# Observed development environment, not a hash-verified cross-platform lockfile. +attrs==26.1.0 +jsonschema==4.26.0 +jsonschema-specifications==2025.9.1 +PySide6_Essentials==6.12.0 +referencing==0.37.0 +rpds-py==2026.9.1 +shiboken6==6.12.0 +typing_extensions==4.16.0 diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index e440ea2..5343de7 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -1,3 +1,7 @@ +# Current security behavior + +The privacy hardening in [PRIVACY_HARDENING.md](PRIVACY_HARDENING.md) supersedes the earlier collector/export descriptions below. Default collection omits accounts, task commands and triggers, certificate enumeration, SMB open-file activity and cron commands. Imports, UI, analysis and reports use `core/privacy.py`; exports use `core/storage.py`. Reports no longer preserve raw inventory or create raw sidecars. `core/process.py` uses bounded memory instead of temporary stdout files. Pin changes do not replace native Windows/supply-chain validation. Earlier architecture notes follow as implementation history. + # Developer guide Use Python 3.11+ and requirements.txt. Run `python -m samsara.main` from the repository root. The desktop requires a Qt-capable graphical session; tests run offscreen. diff --git a/docs/PLATFORMS.md b/docs/PLATFORMS.md index 09a1b6a..f38825d 100644 --- a/docs/PLATFORMS.md +++ b/docs/PLATFORMS.md @@ -1,3 +1,5 @@ +Privacy hardening changes current collector coverage and output requirements. Read [PRIVACY_HARDENING.md](PRIVACY_HARDENING.md) first: certificates, task action/trigger data, cron commands and SMB open-file queries are disabled by default; the Windows output root requires protected ACLs. Current Windows runtime validation remains pending. Historical platform notes below do not override these restrictions. + # Platform support Samsara targets retirement assessment of Windows Server 2016/2019/2022/2025 and enterprise Linux. Collector availability and tested platform support are different claims. diff --git a/docs/PRIVACY_HARDENING.md b/docs/PRIVACY_HARDENING.md new file mode 100644 index 0000000..ed1cd46 --- /dev/null +++ b/docs/PRIVACY_HARDENING.md @@ -0,0 +1,202 @@ +# Privacy hardening and production acceptance + +This document describes implemented controls, not a HIPAA compliance determination. +The documents in `audit/` remain the historical audit of the earlier source snapshot. +Do not replace their findings with an unsupported claim that all issues are resolved. + +## What changed + +- A new `core/privacy.py` builds an explicit field projection before results reach Qt, + analysis/history, imports, HTML, JSON, and local CLI output. Export recomputes + findings from minimized inventory; old findings and a privacy marker cannot bypass + the filter. Unexpected schema fields are rejected at import; legacy command fields + are accepted only to discard them. Timestamps are checked without optional plugins. +- Host/domain/adapter identities, accounts, display descriptions, custom service, + software and task names, certificate identities and unapproved paths are omitted + or replaced with assessment-local numbered labels. Known application families, + OS family/version/build, role identifiers, states, PIDs, ports, numeric IPs, + storage capacities, row counts, timestamps and categorical errors remain useful. + Unknown software is not automatically interpreted as inactive or harmless. +- Windows queries no longer request service account/display-name fields. Task CIM + queries request only TaskName, TaskPath and State; actions, arguments, principals, + triggers and task XML are not requested. Disk queries omit volume labels. + Registry reads disable environment expansion and omit publisher/install-date text. + The PowerShell modules project names and paths before emitting JSON. +- Certificate enumeration and SMB open-file/session enumeration are disabled. + Certificates are explicitly `not_collected`; tasks, storage and shares have + partial scope. Share consumers and schedule timing must be verified separately. + Existing imported aggregate share counts can still support a finding, but their + provenance is unverified. They do not cause the new collector to enumerate files. +- Linux default collection no longer reads cron command files or detailed systemd + timer properties. Timer state remains available; commands and timings need a + separate approved scheduler review. Existing schedule parsers are retained for + synthetic tests, but are not called by the default collection workflow. +- Supervised collector stdout uses bounded memory pipes instead of inventory + temporary files. Stderr is discarded. Windows child environment has an explicit + small allowlist instead of copying all environment variables. Timeouts, + cancellation and overflow terminate/reap the owned process. +- Reports contain minimized data only. HTML does not create raw sidecars. Output + is exclusive (no overwrite), restricted to the configured local report root, and + checks common network mounts, symlinks and Windows reparse points. POSIX root + mode is 0700 and files 0600. Windows roots require a protected DACL allowing only + the owner, SYSTEM and Administrators; child ACLs are checked before payload write. + No automatic privilege elevation or existing ACL changes are performed. +- History retains at most ten assessments by default, and has a clear action. + Clearing references is not secure memory erasure. Existing exported files are not + automatically deleted. Scoring-policy titles are canonicalized to prevent a + second free-text report channel. +- Requirements now use the Widgets-capable `PySide6_Essentials`, rather than the + umbrella package that pulls in WebEngine, PDF and Addons. Direct versions are + pinned; observed development transitive constraints are recorded separately. + +- Remote peer IP addresses are masked: only loopback, this host's own and wildcard + addresses stay numeric; every other peer is reduced to `remote_scope` `private` or + `public`, because client IPs on patient-facing servers can be identifiers. Local + listener addresses and the host's own IPs are still retained. + +## Configure approved metadata and report storage + +Deployment administrators review and protect these bundled files against untrusted +changes: `samsara/assets/privacy_policy.json` and `privacy_catalogue.json`. The +catalogue is a finite vocabulary of infrastructure names, not executable code. +Keep the checkout, Python environment and scripts protected by deployment controls. +An attacker who can replace application code/policy is outside this boundary. + +The policy defaults to no approved names/paths and an application-local `reports` +folder. This is a laboratory default, not a CHS-approved production location. Set +`report_directory` to an approved **absolute local** directory for deployment. +Only files directly inside it can be saved; choose a new `.html` or `.json` filename. +Do not put the directory under OneDrive, another sync client, a network mount or an +unreviewed backup policy. Encrypted local storage and access/retention policy remain +organizational requirements. + +For example, a reviewed Windows configuration may include: + +```json +{ + "version": "1.0", + "report_directory": "C:\\SamsaraReports", + "approved_hostnames": [], + "approved_share_roots": ["D:\\DepartmentShares"], + "approved_service_names": [], + "approved_software_names": [], + "max_session_assessments": 10 +} +``` + +This preserves **only the exact configured root** `D:\DepartmentShares`. It does +not authorize any child path, filename or file-content scan. Empty allowlists are +preferred. Add an exact hostname/custom service/software name only after privacy +review establishes it is approved infrastructure metadata. There is no automatic +patient-name detector: neither a path nor a free-text name can be proven safe from +its format. Known product names are canonicalized without retaining custom suffixes. + +On Linux, the configured root is created with mode 0700 if its parent already +exists. An existing root with group/other permissions is rejected. Fix permissions +on the report directory only after checking that it is the intended directory. + +On Windows, provision a **new** local folder before exporting. The following is a +manual setup example to be reviewed under the organization's normal approval +process, not an action the collector performs on assessed infrastructure: + +```powershell +$reportRoot = 'C:\SamsaraReports' +if (Test-Path -LiteralPath $reportRoot) { throw 'Choose a new report directory.' } +$null = New-Item -ItemType Directory -Path $reportRoot +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +try { + $acl = New-Object Security.AccessControl.DirectorySecurity + $acl.SetOwner($identity.User) + $acl.SetAccessRuleProtection($true, $false) + foreach ($sid in @($identity.User, [Security.Principal.SecurityIdentifier]::new('S-1-5-18'), [Security.Principal.SecurityIdentifier]::new('S-1-5-32-544'))) { + $rule = [Security.AccessControl.FileSystemAccessRule]::new($sid, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow') + $acl.AddAccessRule($rule) + } + Set-Acl -LiteralPath $reportRoot -AclObject $acl +} finally { $identity.Dispose() } +``` + +These commands change the chosen **local output folder's** ACL and must not be run +against data shares. Configure the matching path in the policy. The ctypes ACL +reader and inheritance behavior are implemented but **not tested on Windows here**. +Before production, verify both allowed and denied cases, inherited file ACLs, +standard-user access, reparse/mapped-drive rejection and directory replacement +races on each intended Windows execution environment. Export fails closed when it +cannot establish the configured checks; absence of an exception is not a complete +filesystem-security attestation. + +## Limits and unresolved production blockers + +1. **No PHI-free guarantee.** Names/paths returned by CIM, Get-SmbShare, process + listings and registry values can enter collector memory before reduction. + Legacy JSON is parsed in memory before filtering. Explicit properties reduce + requested fields but provider internals are unverified. Approved names/roots + and retained numeric IPs can themselves be sensitive in organizational context. +2. **No absolute read-only OS guarantee.** Application collection commands request + reads, not infrastructure changes. Native providers/OS can write audit logs, + caches or other incidental state. Python bytecode, OS paging/crash dumps, Qt and + endpoint tools are outside the no-inventory-spool control. A bounded buffer is + not secure memory. Disable/restrict crash dumps and use encrypted storage under + organizational policy, rather than claiming that data never touch disk. +3. **No complete egress guarantee.** There is no first-party upload, telemetry or + remote collector. Filesystem checks reject common remote paths but cannot prove + a disk has no cloud-sync/backup/virtualized backend. Native Windows providers, + revocation behavior, domain services and OS integrations need monitored host + validation. Use enterprise egress policy and approved local output storage. +4. **Runtime validation outstanding.** No Windows execution host or PowerShell + runtime was available. Revised CIM projections, least-privilege visibility, + native synthetic tests and output ACL enforcement must pass on approved lab + hosts. Do not substitute synthetic Python results for that evidence. +5. **Supply chain approval outstanding.** No vulnerability database queries, + package installations/updates or external transmissions were performed. Direct + pins/observed constraints are not a hash-verified platform lockfile, complete + native SBOM, signed release or CVE clearance. Existing development environments + may still contain the former Qt umbrella/Addons packages; changing requirements + does not uninstall them. Build clean environments through approved provisioning. +6. **Organizational review outstanding.** CHS security/privacy approval, least- + privilege grants, retention/deletion policy, approved metadata, endpoint and + backup controls, incident response and HIPAA determination remain external + acceptance gates. A low retirement score always needs historical validation, + application ownership and administrator approval. + +Do not import known patient-bearing legacy inventories into this tool merely to +redact them. Have the data owner handle those artifacts using approved procedures. +Old reports/sidecars/bundles are not scrubbed or removed by this change. The +historical `Samsara.bundle`, if present, may predate these controls; distribute a +reviewed build of the current source rather than an older archive. + +## Verification and later authorized supply-chain checks + +Run synthetic/offscreen tests from the repository root: + +```bash +QT_QPA_PLATFORM=offscreen .venv/bin/python -m unittest discover -s tests -v +``` + +On an authorized Windows **lab** Server with an approved report directory: + +```bat +.venv\Scripts\python.exe -m unittest discover -s tests -v +.venv\Scripts\python.exe tools\validate_windows_host.py --output-dir C:\SamsaraReports +``` + +The second command performs a real assessment of the execution host; it is not a +synthetic test. No real Windows host has been assessed during this change. + +After obtaining approval for tooling/downloads and advisory-database access, in a +separate release environment use, for example: + +```bash +python -m piptools compile --generate-hashes --output-file requirements.lock requirements.txt +python -m pip install --require-hashes -r requirements.lock +python -m pip_audit -r requirements.lock --format json --output vulnerability-results.json +cyclonedx-py environment --output-format JSON --output-file sbom.cdx.json +``` + +Repeat lock generation/clean installs for each target Python/OS/architecture. +Inventory Qt native libraries/plugins and PowerShell/OS modules as well; a Python +package SBOM alone is incomplete. SBOM/advisory checks can expose package metadata +externally: do not run them against production inventory or without authorization. +Triage findings against actual packages, keep advisory timestamps, sign the +reviewed distribution, and retain acceptance evidence. These commands have not +been run as part of the hardening work. diff --git a/docs/SCORING.md b/docs/SCORING.md index 9943c07..0a05e70 100644 --- a/docs/SCORING.md +++ b/docs/SCORING.md @@ -4,7 +4,7 @@ The previous engine added points for every running service, installed role, listener, connection and administrative share. A routine Windows snapshot could accumulate thousands of raw points and saturate at Critical. Model v2 removes that inventory-counting behavior. Collection schema remains v1; assessment metadata is versioned separately as `model_version: 2.0`. -Re-analyze a previous JSON report through **Analyze → Saved inventory file**. Its raw inventory and collection time are retained; the old assessment is ignored and a v2 assessment is calculated with the selected policy. No new Windows access is needed to re-score existing evidence. Imported data remain explicitly unverified. Old HTML scores do not change; generate a new report. Scores from v1 and v2 are not directly comparable. +Re-analyze a previous JSON report through **Analyze → Saved inventory file**. Its inventory is minimized under the privacy policy and its collection time retained; the old assessment is ignored and a v2 assessment is calculated with the selected policy. No new Windows access is needed to re-score existing evidence. Imported data remain explicitly unverified. Old HTML scores do not change; generate a new report. Scores from v1 and v2 are not directly comparable. ## Evidence classifications @@ -58,8 +58,4 @@ Historical monitoring over a representative business cycle, infrequent workloads ## Reporting and validation -HTML leads with actionable workload groups, scoring rationale, confidence and missing checks. Routine inventory is summarized in zero-point context groups rather than repeated as medium-risk dependencies. A content-addressed `.inventory-.json` sidecar preserves every collected inventory row; its SHA-256 and link are in the report. Keep it with the HTML. JSON assessment export also retains full inventory, model/policy and detailed observations. Existing different sidecars are never overwritten. - -Regression fixtures cover a clean Windows Server, an inactive former game server, active SQL, active IIS, a domain controller, business file-share use and incomplete collection. An additional regression reconstructs the reported 1,565-point pattern (86 services, 19 connections, 14 features, 23 listeners, three admin shares and two failed checks): v2 gives zero workload risk and reduced confidence, while retaining all observations. This is a synthetic reproduction of the supplied counts, not a replay of an unavailable original inventory file. - -These tests do not certify business dependency discovery. Re-score the real test server's saved JSON, review results with its owner, and validate any new SMB activity fields on Windows. Native PowerShell tests remain skipped in the Linux development environment. +HTML leads with actionable workload groups, scoring rationale, confidence and missing checks. Routine inventory is summarized in zero-point context groups rather than repeated as medium-risk dependencies. HTML no longer creates raw inventory sidecars. JSON contains only privacy-minimized evidence, model/policy and observations recomputed from that evidence. This changes the earlier raw-preservation behavior deliberately. Existing raw artifacts are not deleted or scrubbed; handle them under organizational retention/privacy policy. See [privacy hardening](PRIVACY_HARDENING.md). diff --git a/docs/SECURITY_REMEDIATION_STATUS.md b/docs/SECURITY_REMEDIATION_STATUS.md new file mode 100644 index 0000000..c734e35 --- /dev/null +++ b/docs/SECURITY_REMEDIATION_STATUS.md @@ -0,0 +1,52 @@ +# Security remediation status — 2026-10-30 + +This is an implementation follow-up to the unchanged historical `audit/` documents. +It is not a new comprehensive audit, vulnerability scan or production approval. + +| Audit finding | Implemented change | Remaining acceptance requirement | +| --- | --- | --- | +| S01 — PHI-capable free text | Explicit projection, opaque labels/canonical products, exact reviewed allowlists before UI/history/export; stale findings discarded | CHS approves retained fields/roots/IPs; metadata can enter source/import memory; no PHI-free assurance | +| S02 — Broad provider objects | No default certificates/open files; task fields narrowed; service account/descriptions and disk labels no longer requested | Windows CIM/provider behavior and permissions must be lab-tested; provider internals not established | +| S03 — Raw retention | No raw sidecar; JSON minimized; bounded session history and clear action | Review legacy artifacts, retention/deletion, memory dumps, encrypted storage and endpoint backups | +| S04 — Output boundaries | Configured private local root, no overwrite, mode 0700/0600, Windows DACL gate, remote/link rejection | Windows ACL/race tests; CHS-approved root/backup/sync policy; unusual backing storage remains unverified | +| S05 — Temporary stdout | Bounded in-memory pipe, sanitized failure codes, discard stderr, terminate/reap owned process | OS swap/dumps/cache/log policy and native-child behavior review; memory is not secure-erased | +| S06 — Environment/registry | Small child environment allowlist; registry DoNotExpandEnvironmentNames; no stored credentials | Trusted interpreter/runtime deployment, inherited indispensable environment and native module behavior review | +| S07 — Supply chain | Minimal Qt Essentials; direct version pins and observed transitive constraints | Hash/platform locks, complete native SBOM, authorized advisory scan and signed reproducible release still outstanding | +| S08 — Contracts | Closed nested schema, bounded rows/strings/depth, independent timestamp validation, repeated export projection | Fuzzing/resource stress and untrusted file race review; legacy input still read before reduction | +| S09 — Egress | No first-party upload added; reject common remote import/output paths before filesystem probes | Monitored Windows/native egress tests, enterprise firewall and approved storage controls | +| S10 — Read-only/least privilege | Collection remains read/query operations; no elevation/remoting/configuration added; output writes isolated | Native read side effects, standard-user filtering, each supported Server OS, CHS least-privilege approval | + +## Test evidence + +`QT_QPA_PLATFORM=offscreen .venv/bin/python -m unittest discover -s tests -v` +discovered 71 tests on the Linux development host: 70 passed and one native +PowerShell test was skipped. Python, synthetic regression, GUI and report tests +passed. Native PowerShell AST/mocked collector tests are skipped because no PowerShell +runtime exists here. No real Windows collection, installation/update, vulnerability +lookup, production access or network transmission was performed. + +New regression cases verify canary removal across major free-text surfaces and old +findings, schema/marker bypass rejection, exact-root approval excluding descendants, +private/exclusive output, memory capture limits and cleanup, and bounded/cleared UI +history. Seven workload scenarios retain scores 0/0/70/70/95/80/0 after projection. +The 80-point file-server case uses **imported synthetic historical aggregates**; +the new live default collector does not query SMB open-file activity and cannot +create that evidence itself. + +## Release gates + +1. Review the actual release provenance. The workspace has no usable `.git` checkout; + the historical bundle is not updated by these source edits. Earlier upstream + `7fed3b9` cannot be verified as included without an authorized source reconciliation. +2. Obtain native Windows lab results, including revised task-CIM queries, registry + read-only behavior, service/role projection, cancellation and output permissions. +3. Build clean target environments with verified dependencies, hash locks, native + SBOM, advisory results and release signing. Existing broad Qt installations are + not changed by editing requirements. +4. CHS security/privacy owners approve the reduced dataset and document residual + PHI possibilities, dedicated least-privilege access, encryption/storage, egress, + retention, audit logging and incident response. HIPAA status is an organizational + determination, not an application-generated pass/fail badge. + +No source fix alone clears these gates. See [the deployment guide](PRIVACY_HARDENING.md) +for exact configuration, manual output-folder provisioning and authorized lab checks. diff --git a/docs/WINDOWS-COLLECTOR-RELIABILITY.md b/docs/WINDOWS-COLLECTOR-RELIABILITY.md index 99c448f..0650fdc 100644 --- a/docs/WINDOWS-COLLECTOR-RELIABILITY.md +++ b/docs/WINDOWS-COLLECTOR-RELIABILITY.md @@ -1,3 +1,8 @@ +> **Superseded in part:** the privacy hardening (see `PRIVACY_HARDENING.md`) disables certificate +> enumeration and narrows task collection to name/path/state. The task and certificate +> collector behavior described below no longer applies; the diagnostic switch, exit-code 3 +> serialization handling and PowerShell 7 module path remain in effect. + # Windows collector reliability validation — 2026-10-30 ## Root causes diff --git a/powershell/Get-ServerCertificates.ps1 b/powershell/Get-ServerCertificates.ps1 index d2ac87e..236b5a6 100644 --- a/powershell/Get-ServerCertificates.ps1 +++ b/powershell/Get-ServerCertificates.ps1 @@ -1,28 +1,6 @@ function Get-SamsaraCertificates { param($Result) - $collection = @{ stores = @() } - Invoke-SamsaraRead $Result 'Local machine certificate store list' { - $collection.stores = @(Get-ChildItem -LiteralPath 'Cert:\LocalMachine' -ErrorAction Stop) - } - # Listing store names is not evidence that any listed store was readable. - if ($collection.stores.Count -gt 0) { $Result.successful_subchecks-- } - foreach ($store in $collection.stores) { - # Certificate provider PSPath may omit LocalMachine (including on 5.1). - # Construct an explicit provider path from the enumerated store name. - $storeName = [string]$store.PSChildName - Invoke-SamsaraRead $Result 'Certificate store query' { - if ($storeName -notmatch '^[A-Za-z0-9_. -]+$') { throw 'Invalid store name' } - $certificates = @(Get-ChildItem -LiteralPath ('Cert:\LocalMachine\' + $storeName) -ErrorAction Stop) - foreach ($certificate in $certificates) { - Invoke-SamsaraRead $Result 'Certificate metadata projection' { - $row = [pscustomobject]@{ subject = [string]$certificate.Subject; issuer = [string]$certificate.Issuer - expires = $certificate.NotAfter.ToUniversalTime().ToString('o'); thumbprint = [string]$certificate.Thumbprint - store = $storeName } - $null = ConvertTo-Json -InputObject $row -Depth 4 -ErrorAction Stop - $Result.data += $row - } - } - } - } - $Result.limitations += 'Public LocalMachine certificate metadata only. Private keys, password-protected files, CurrentUser stores, application bindings and off-store certificates are not read.' + $Result.status = 'not_collected' + $Result.message = 'Certificate enumeration disabled by privacy policy.' + $Result.limitations += 'Subjects, issuers, extensions and certificate objects may contain identifying information. Use a separately approved certificate review; no certificate provider is invoked.' } diff --git a/powershell/Get-ServerInventory.ps1 b/powershell/Get-ServerInventory.ps1 index e7aefed..7de698f 100644 --- a/powershell/Get-ServerInventory.ps1 +++ b/powershell/Get-ServerInventory.ps1 @@ -18,6 +18,7 @@ if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { exit 2 } . (Join-Path $PSScriptRoot 'Inventory.Common.ps1') +Get-SamsaraPrivacyConfiguration foreach ($file in @('System','Roles','Services','Network','Tasks','Software','Storage','Certificates')) { . (Join-Path $PSScriptRoot ('Get-Server' + $file + '.ps1')) } diff --git a/powershell/Get-ServerNetwork.ps1 b/powershell/Get-ServerNetwork.ps1 index 8df2d6f..62903ea 100644 --- a/powershell/Get-ServerNetwork.ps1 +++ b/powershell/Get-ServerNetwork.ps1 @@ -9,7 +9,7 @@ function Get-SamsaraNetwork { $processName = if ($processes.ContainsKey([int]$_.OwningProcess)) { $processes[[int]$_.OwningProcess] } else { 'Unavailable or process exited' } [pscustomobject]@{ state = [string]$_.State; local_address = [string]$_.LocalAddress; local_port = [int]$_.LocalPort remote_address = [string]$_.RemoteAddress; remote_port = [int]$_.RemotePort - pid = [int]$_.OwningProcess; process = $processName; direction = 'Unknown' } + pid = [int]$_.OwningProcess; process = Get-SamsaraPublicName 'processes' $processName ([int]$_.OwningProcess); direction = 'Unknown' } }) } $Result.limitations += 'Point-in-time TCP only. Connection direction, historical activity, UDP and other network compartments are unverified. Process names may race process exit or be hidden.' diff --git a/powershell/Get-ServerRoles.ps1 b/powershell/Get-ServerRoles.ps1 index 7346fcf..d7b79da 100644 --- a/powershell/Get-ServerRoles.ps1 +++ b/powershell/Get-ServerRoles.ps1 @@ -1,8 +1,10 @@ function Get-SamsaraRoles { param($Result) Invoke-SamsaraRead $Result 'Server roles and features' { + $index = 0 $Result.data = @(Get-WindowsFeature -ErrorAction Stop | Where-Object { $_.Installed } | ForEach-Object { - [pscustomobject]@{ name = [string]$_.Name; display_name = [string]$_.DisplayName + $index++ + [pscustomobject]@{ name = Get-SamsaraPublicName 'roles' ([string]$_.Name) $index state = [string]$_.InstallState; feature_type = [string]$_.FeatureType } }) } diff --git a/powershell/Get-ServerServices.ps1 b/powershell/Get-ServerServices.ps1 index d016b19..7b89158 100644 --- a/powershell/Get-ServerServices.ps1 +++ b/powershell/Get-ServerServices.ps1 @@ -2,9 +2,11 @@ function Get-SamsaraServices { param($Result) Invoke-SamsaraRead $Result 'Windows services' { # Do not query PathName: it can include secret-bearing command arguments. - $Result.data = @(Get-CimInstance -ClassName Win32_Service -Property Name,DisplayName,State,StartMode,StartName,ProcessId -ErrorAction Stop | ForEach-Object { - [pscustomobject]@{ name = [string]$_.Name; display_name = [string]$_.DisplayName - state = [string]$_.State; startup = [string]$_.StartMode; account = [string]$_.StartName; pid = [int]$_.ProcessId } + $index = 0 + $Result.data = @(Get-CimInstance -ClassName Win32_Service -Property Name,State,StartMode,ProcessId -ErrorAction Stop | ForEach-Object { + $index++ + [pscustomobject]@{ name = Get-SamsaraPublicName 'services' ([string]$_.Name) $index + state = [string]$_.State; startup = [string]$_.StartMode; account = 'Omitted by privacy policy'; pid = [int]$_.ProcessId } }) } $Result.limitations += 'All visible service states are inventoried, including running services. Executable command lines and credentials are not collected; service accounts do not prove consumers.' diff --git a/powershell/Get-ServerSoftware.ps1 b/powershell/Get-ServerSoftware.ps1 index 777eb1d..bf8be3d 100644 --- a/powershell/Get-ServerSoftware.ps1 +++ b/powershell/Get-ServerSoftware.ps1 @@ -5,11 +5,12 @@ function Read-SamsaraSoftwareEntries { try { $entry = $Uninstall.OpenSubKey($name, $false) if ($null -eq $entry) { throw 'Software entry not readable.' } - $display = [string]$entry.GetValue('DisplayName') + $display = [string]$entry.GetValue('DisplayName', $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) if ($display) { - $Result.data += [pscustomobject]@{ name = $display; publisher = [string]$entry.GetValue('Publisher') - version = [string]$entry.GetValue('DisplayVersion'); installation_date = [string]$entry.GetValue('InstallDate') - registry_view = $View } + $version = [string]$entry.GetValue('DisplayVersion', $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames) + if ($version -match '^(\d{1,5}(?:\.\d{1,5}){0,3})(?![\d.])') { $version = $Matches[1] } else { $version = 'Not collected' } + $Result.data += [pscustomobject]@{ name = Get-SamsaraPublicName 'products' $display ($Result.data.Count + 1) + version = $version; registry_view = $View } } } catch { Add-SamsaraError $Result $_ 'Machine software entry' } finally { if ($entry) { $entry.Dispose() } } @@ -33,5 +34,5 @@ function Get-SamsaraSoftware { } } } - $Result.limitations += 'Machine uninstall registrations in both registry views only. Per-user, portable, container and unregistered software may be absent. Install dates are raw registry values and may be empty. No installer consistency checks are triggered.' + $Result.limitations += 'Machine uninstall registrations in both registry views only. Per-user, portable, container and unregistered software may be absent. Publisher, installation date, uninstall commands and environment expansion are omitted. No installer consistency checks are triggered.' } diff --git a/powershell/Get-ServerStorage.ps1 b/powershell/Get-ServerStorage.ps1 index 028db74..2aee7b0 100644 --- a/powershell/Get-ServerStorage.ps1 +++ b/powershell/Get-ServerStorage.ps1 @@ -1,47 +1,30 @@ function Get-SamsaraStorage { param($Result) - Invoke-SamsaraRead $Result 'Local volumes' { - $Result.data = @(Get-Volume -ErrorAction Stop | ForEach-Object { - [pscustomobject]@{ type = 'Volume'; name = [string]$_.FileSystemLabel - path = if ($_.DriveLetter) { [string]$_.DriveLetter + ':\' } else { [string]$_.Path } + Invoke-SamsaraRead $Result 'Fixed local disk metadata' { + $index = 0 + $Result.data = @(Get-CimInstance -ClassName Win32_LogicalDisk -Filter 'DriveType = 3' -Property DeviceID,Size,FreeSpace,FileSystem -ErrorAction Stop | ForEach-Object { + $index++ + [pscustomobject]@{ type = 'Volume'; name = 'Volume-' + $index + path = Get-SamsaraApprovedRoot (([string]$_.DeviceID) + '\') filesystem = [string]$_.FileSystem; capacity_gib = [math]::Round(([double]$_.Size / 1GB), 2) - free_gib = [math]::Round(([double]$_.SizeRemaining / 1GB), 2) } + free_gib = [math]::Round(([double]$_.FreeSpace / 1GB), 2) } }) } - $Result.limitations += 'Local volume metadata only. Share contents, mounted remote storage, storage-array dependencies, snapshots and backups are not inspected.' + $Result.status = 'partial' + $Result.limitations += 'Fixed drive-letter disks only; labels and child paths not read. Mount-only volumes and external storage dependencies need review.' } function Get-SamsaraShares { param($Result) - Invoke-SamsaraRead $Result 'SMB shares' { + Invoke-SamsaraRead $Result 'Configured SMB share roots' { + $index = 0 $Result.data = @(Get-SmbShare -ErrorAction Stop | ForEach-Object { - [pscustomobject]@{ type = 'SMB Share'; name = [string]$_.Name; path = [string]$_.Path + $index++ + $name = if ([string]$_.Name -match '^(ADMIN|IPC|[A-Z])\$$') { ([string]$_.Name).ToUpperInvariant() } else { 'Share-' + $index } + [pscustomobject]@{ type = 'SMB Share'; name = $name; path = Get-SamsaraApprovedRoot ([string]$_.Path) capacity_gib = $null; free_gib = $null; special = [bool]$_.Special } }) } - Invoke-SamsaraRead $Result 'SMB open-file activity' { - $openFiles = @(Get-SmbOpenFile -ErrorAction Stop) - $observedAt = Get-SamsaraTimestamp - foreach ($share in $Result.data) { - if ($share.name -match '^(ADMIN|IPC|[A-Z])\$$' -or -not $share.path) { continue } - $prefix = $share.path.TrimEnd([char]'\') + '\' - $matching = @($openFiles | Where-Object { - # Path alone also matches a file opened through C$/D$. Require - # Path = share root + ShareRelativePath to distinguish that case. - $_.Path -and $_.ShareRelativePath -and - ([string]$_.Path).Equals(($prefix + ([string]$_.ShareRelativePath).TrimStart([char]'\')), [StringComparison]::OrdinalIgnoreCase) - }) - $adminAliases = @($Result.data | Where-Object { - $_.name -match '^(ADMIN|IPC|[A-Z])\$$' -and ([string]$_.path).TrimEnd([char]'\').Equals($share.path.TrimEnd([char]'\'), [StringComparison]::OrdinalIgnoreCase) - }) - # Only aggregated, path-correlated activity is exported. Never serialize - # open-file objects, file names, session identities or user names. - $share | Add-Member -NotePropertyName observed_open_files -NotePropertyValue $matching.Count - $clients = @($matching | Select-Object -ExpandProperty ClientComputerName -Unique) - $share | Add-Member -NotePropertyName observed_client_count -NotePropertyValue $clients.Count - $share | Add-Member -NotePropertyName activity_source -NotePropertyValue $(if ($adminAliases.Count) { 'Get-SmbOpenFile (administrative alias ambiguous)' } else { 'Get-SmbOpenFile (share-root-correlated)' }) - $share | Add-Member -NotePropertyName activity_observed_at -NotePropertyValue $observedAt - } - } - $Result.limitations += 'Configured SMB shares, including administrative shares, and aggregate open-file counts share-root-correlated to business shares. No file contents, open-file paths, session identities, ACLs or credentials are exported. Zero open files does not prove a share unused. Same-root aliases are not individually distinguishable; an administrative same-root alias is marked ambiguous and never confirms business-share use. All business shares remain one workload group.' + $Result.status = 'partial' + $Result.limitations += 'Configured roots only; exact approved local roots may be retained. No file enumeration, open-file activity, filenames, child paths, sessions or client identities are queried. Active share consumers remain unverified.' } diff --git a/powershell/Get-ServerSystem.ps1 b/powershell/Get-ServerSystem.ps1 index f60a614..e50955b 100644 --- a/powershell/Get-ServerSystem.ps1 +++ b/powershell/Get-ServerSystem.ps1 @@ -1,19 +1,21 @@ function Get-SamsaraSystem { param($Result) $Result.data = [ordered]@{ - hostname = [Environment]::MachineName; platform = 'windows' + hostname = 'Host-0001'; platform = 'windows' collection_source = 'Local Windows read-only collection' operating_system = 'Not collected'; os_version = 'Not collected'; os_build = 'Not collected' product_type = 0; last_boot = 'Not collected'; ip_addresses = @(); network_adapters = @() domain = 'Not collected'; domain_joined = $null } + Get-SamsaraPrivacyConfiguration + if ($script:SamsaraPrivacy.approved_hostnames -ccontains [Environment]::MachineName) { $Result.data.hostname = [Environment]::MachineName } Invoke-SamsaraRead $Result 'OS identity' { - $os = Get-CimInstance -ClassName Win32_OperatingSystem -Property Caption,Version,BuildNumber,LastBootUpTime,ProductType -ErrorAction Stop + $os = Get-CimInstance -ClassName Win32_OperatingSystem -Property Version,BuildNumber,LastBootUpTime,ProductType -ErrorAction Stop $years = @{ '14393' = '2016'; '17763' = '2019'; '20348' = '2022'; '26100' = '2025' } if ([int]$os.ProductType -notin @(2,3) -or -not $years.ContainsKey([string]$os.BuildNumber)) { throw 'Unsupported Windows release or workstation edition.' } - $Result.data.operating_system = [string]$os.Caption + $Result.data.operating_system = 'Windows Server ' + $years[[string]$os.BuildNumber] $Result.data.os_version = $years[[string]$os.BuildNumber] $Result.data.os_build = [string]$os.BuildNumber $Result.data.kernel_version = [string]$os.Version @@ -21,13 +23,13 @@ function Get-SamsaraSystem { $Result.data.last_boot = $os.LastBootUpTime.ToUniversalTime().ToString('o') } Invoke-SamsaraRead $Result 'Computer domain membership' { - $computer = Get-CimInstance -ClassName Win32_ComputerSystem -Property Domain,PartOfDomain -ErrorAction Stop - $Result.data.domain = [string]$computer.Domain + $computer = Get-CimInstance -ClassName Win32_ComputerSystem -Property PartOfDomain -ErrorAction Stop + $Result.data.domain = 'Domain identity omitted' $Result.data.domain_joined = [bool]$computer.PartOfDomain } Invoke-SamsaraRead $Result 'Network adapter configuration' { - $adapters = @(Get-CimInstance -ClassName Win32_NetworkAdapterConfiguration -Filter 'IPEnabled = True' -Property Description,IPAddress -ErrorAction Stop) - $Result.data.network_adapters = @($adapters | ForEach-Object { [string]$_.Description }) + $adapters = @(Get-CimInstance -ClassName Win32_NetworkAdapterConfiguration -Filter 'IPEnabled = True' -Property IPAddress -ErrorAction Stop) + $Result.data.network_adapters = @($adapters | ForEach-Object -Begin { $i = 0 } -Process { $i++; 'Adapter-' + $i }) $Result.data.ip_addresses = @($adapters | ForEach-Object { foreach ($address in $_.IPAddress) { [string]$address } }) } $Result.limitations += 'Identity and addresses are local observations. No DNS probes or Active Directory enrichment were performed.' diff --git a/powershell/Get-ServerTasks.ps1 b/powershell/Get-ServerTasks.ps1 index 733fc6b..5f649be 100644 --- a/powershell/Get-ServerTasks.ps1 +++ b/powershell/Get-ServerTasks.ps1 @@ -1,25 +1,18 @@ function Get-SamsaraTasks { param($Result) - Invoke-SamsaraRead $Result 'Scheduled tasks' { - $tasks = @(Get-ScheduledTask -ErrorAction Stop) - foreach ($task in $tasks) { - Invoke-SamsaraRead $Result 'Scheduled task metadata projection' { - if ($null -eq $task -or [string]::IsNullOrEmpty([string]$task.TaskName)) { throw 'Missing task identity' } - $triggers = @($task.Triggers | ForEach-Object { - $type = if ($_.CimClass) { [string]$_.CimClass.CimClassName } else { 'Unknown trigger' } - # Select only known scheduler metadata; never serialize entire CIM objects. - $repetition = if ($_.Repetition) { [string]$_.Repetition.Interval } else { '' } - '{0}; start={1}; enabled={2}; daysInterval={3}; weeksInterval={4}; daysOfWeek={5}; repeat={6}' -f $type, $_.StartBoundary, $_.Enabled, $_.DaysInterval, $_.WeeksInterval, $_.DaysOfWeek, $repetition - }) - $executables = @($task.Actions | ForEach-Object { Get-SamsaraSafeExecutable ([string]$_.Execute) }) - $row = [pscustomobject]@{ name = [string]$task.TaskName; path = [string]$task.TaskPath - enabled = [bool]$task.Settings.Enabled; state = [string]$task.State - account = if ($task.Principal.UserId) { [string]$task.Principal.UserId } else { [string]$task.Principal.GroupId }; executables = ($executables -join '; ') - triggers = if ($triggers.Count) { $triggers -join ' | ' } else { 'No trigger metadata visible; manual or external invocation remains possible' } } - $null = ConvertTo-Json -InputObject $row -Depth 4 -ErrorAction Stop - $Result.data += $row - } - } + Invoke-SamsaraRead $Result 'Minimal scheduler metadata' { + $index = 0 + $Result.data = @(Get-CimInstance -Namespace 'Root/Microsoft/Windows/TaskScheduler' -ClassName MSFT_ScheduledTask -Property TaskName,TaskPath,State -ErrorAction Stop | ForEach-Object { + $index++ + $routine = ([string]$_.TaskPath).StartsWith('\Microsoft\Windows\', [StringComparison]::OrdinalIgnoreCase) -and + -not ([string]$_.TaskPath).StartsWith('\Microsoft\Windows\PowerShell\ScheduledJobs', [StringComparison]::OrdinalIgnoreCase) + $state = switch ([int]$_.State) { 1 {'Disabled'} 2 {'Queued'} 3 {'Ready'} 4 {'Running'} default {'Unknown'} } + [pscustomobject]@{ name = $(if ($routine) { 'Maintenance-' + $index } else { 'Task-' + $index }) + path = $(if ($routine) { '\Microsoft\Windows\Maintenance\' } else { 'Task folder omitted' }) + enabled = ([int]$_.State -in @(2,3,4)); state = $state + account = 'Omitted by privacy policy'; triggers = 'Schedule details omitted; authorized scheduler review required.' } + }) } - $Result.limitations += 'Visible Task Scheduler registrations only. Arguments, inline scripts, task XML, event queries and execution history are omitted. Non-executable actions may lack an executable name. Trigger fields are descriptive, not a prediction of execution.' + $Result.status = 'partial' + $Result.limitations += 'Only projected task identity/category and state requested. Actions, arguments, principals, triggers and task XML are not requested. Schedule timing and actual execution remain unverified. Provider internals remain outside application guarantees.' } diff --git a/powershell/Inventory.Common.ps1 b/powershell/Inventory.Common.ps1 index 8bab72e..b0e398a 100644 --- a/powershell/Inventory.Common.ps1 +++ b/powershell/Inventory.Common.ps1 @@ -44,16 +44,6 @@ function ConvertTo-SamsaraJson { } } -function Get-SamsaraSafeExecutable { - param([string]$Value) - # Task action arguments, scripts and environment values are deliberately omitted. - # Scheduler action values need not be valid filesystem paths. Path.GetFileName - # throws on malformed/quoted values in .NET Framework (PowerShell 5.1). - $name = (($Value -replace '\\', '/') -split '/')[-1] - if ($name -match '^[A-Za-z0-9_. -]+\.(exe|com|cmd|bat|ps1|vbs|js|wsf|msc)$') { return $name } - return '[executable name omitted]' -} - function Invoke-SamsaraSection { param([string]$Name, [scriptblock]$Collector) $result = [ordered]@{ @@ -84,3 +74,43 @@ function ConvertTo-SamsaraCheck { limitations = @($Section.limitations) } } + +function Get-SamsaraPrivacyConfiguration { + if ($null -eq $script:SamsaraPrivacy) { + $assets = Join-Path (Split-Path $PSScriptRoot -Parent) 'samsara\assets' + $script:SamsaraPrivacy = Get-Content -LiteralPath (Join-Path $assets 'privacy_policy.json') -Raw -ErrorAction Stop | ConvertFrom-Json + $script:SamsaraCatalogue = Get-Content -LiteralPath (Join-Path $assets 'privacy_catalogue.json') -Raw -ErrorAction Stop | ConvertFrom-Json + if ($script:SamsaraPrivacy.version -ne '1.0') { throw 'Invalid privacy policy.' } + } +} + +function Get-SamsaraPublicName { + param([string]$Kind, [string]$Value, [int]$Index) + Get-SamsaraPrivacyConfiguration + $normalized = $Value.ToLowerInvariant() -replace '\.(exe|service)$', '' + if ($Kind -eq 'services' -and $script:SamsaraPrivacy.approved_service_names -ccontains $Value) { return $Value } + if ($Kind -eq 'products') { + if ($script:SamsaraPrivacy.approved_software_names -ccontains $Value) { return $Value } + foreach ($product in $script:SamsaraCatalogue.products) { + if ($Value -match ('^' + [regex]::Escape($product) + '(?![A-Za-z0-9])')) { return $product } + } + } elseif ($script:SamsaraCatalogue.$Kind -contains $normalized) { return $normalized } + if ($Kind -eq 'services') { + foreach ($family in $script:SamsaraCatalogue.families.PSObject.Properties) { + foreach ($prefix in $family.Value.prefixes) { + if ($normalized.StartsWith($prefix, [StringComparison]::Ordinal)) { return $family.Value.representative } + } + } + } + return $Kind + '-' + $Index +} + +function Get-SamsaraApprovedRoot { + param([string]$Value) + Get-SamsaraPrivacyConfiguration + if ($Value -notmatch '^[A-Za-z]:\\' -or $Value -match '(^|[\\/])\.\.([\\/]|$)' -or $Value -match '[\x00-\x1f]') { return 'Omitted by privacy policy' } + foreach ($root in $script:SamsaraPrivacy.approved_share_roots) { + if ($root -match '^[A-Za-z]:\\' -and $Value.TrimEnd([char]'\').Equals(([string]$root).TrimEnd([char]'\'), [StringComparison]::OrdinalIgnoreCase)) { return $Value } + } + return 'Omitted by privacy policy' +} diff --git a/powershell/README.md b/powershell/README.md index 66db419..ee95117 100644 --- a/powershell/README.md +++ b/powershell/README.md @@ -1,29 +1,38 @@ -# Local Windows Server inventory +# Local Windows Server collection -Targets: Windows Server 2016/2019/2022/2025, Windows PowerShell 5.1, 64-bit execution. **Real Windows host validation is still pending in this Linux development workspace.** No remoting or credentials are used. +Read-only collection commands for supported Server release builds 2016/2019/2022/2025. +The Python workflow launches fixed sections using system Windows PowerShell 5.1, +NoProfile, fixed argument lists, bounded memory output and time/cancellation limits. +No remoting/configuration, execution-policy bypass, elevation or credential storage. +Actual Windows runtime/least-privilege acceptance is still pending. -`Get-ServerInventory.ps1` is the fixed JSON driver. With no arguments it emits a full v1 inventory envelope. `-Section system|roles|services|network|tasks|software|storage|shares|certificates` emits an internal per-section JSON result. The Python collector invokes these independently to enforce time/output limits while preserving other sections after failures. +The bundled `samsara/assets/privacy_policy.json` and `privacy_catalogue.json` are +required. Protect the deployment against unauthorized changes. Name/path projection +occurs before PowerShell JSON output and again at Python UI/report boundaries. -| Module | Local read-only source | -| --- | --- | -| Get-ServerSystem.ps1 | CIM OS/product type/build, boot, domain membership, adapter configuration | -| Get-ServerRoles.ps1 | Installed Get-WindowsFeature entries | -| Get-ServerServices.ps1 | Win32_Service metadata and service identities; no PathName/arguments | -| Get-ServerNetwork.ps1 | Get-NetTCPConnection and Get-Process names/IDs | -| Get-ServerTasks.ps1 | Get-ScheduledTask visible definitions, accounts, safe executable basenames, selected triggers | -| Get-ServerSoftware.ps1 | HKLM uninstall registrations using explicit Registry32/Registry64 read-only handles | -| Get-ServerStorage.ps1 | Get-Volume, Get-SmbShare and aggregate Get-SmbOpenFile share-root correlation; volumes/shares share the existing storage inventory category | -| Get-ServerCertificates.ps1 | Public metadata in accessible LocalMachine certificate stores | -| Inventory.Common.ps1 | Section timing, safe errors and permission limitations | +| Module | Default queries | Privacy/coverage limits | +| --- | --- | --- | +| System | Local OS/build/boot, domain-joined boolean, adapter IPs | Fixed OS labels, opaque identities; no domain string/adapter description requested | +| Roles | Get-WindowsFeature installed identifiers | Canonical/opaque names; no active-use assertion | +| Services | Win32_Service Name, State, StartMode, ProcessId | No StartName, DisplayName, PathName; names canonical/opaque | +| Network | Get-Process names/PIDs, Get-NetTCPConnection | No command lines; names canonical/opaque; historical direction unverified | +| Tasks | MSFT_ScheduledTask TaskName, TaskPath, State only | Opaque identities/category; no Actions, Principals, Triggers, Settings, XML or arguments requested; partial | +| Software | Read-only HKLM uninstall registry views | DoNotExpandEnvironmentNames; canonical/opaque display name, numeric version; no installer query | +| Storage | Fixed Win32_LogicalDisk size/free/filesystem/drive metadata | No labels, mount-only volumes or child paths; partial | +| Shares | Get-SmbShare configured roots | Only exact reviewed local roots retained; no Get-SmbOpenFile or session enumeration; partial | +| Certificates | No provider call | not_collected pending a separately approved minimal design | -No Win32_Product, installer operations, private-key export, task execution, service changes, registry writes or remoting setup. No execution-policy bypass; use approved script signing/distribution if enterprise policy blocks scripts. Non-elevated sensitive checks are conservatively partial even if their visible query succeeds. Elevation is never attempted automatically. Domain Administrator privileges are not required; assess with the least-privileged authorized account and treat omitted evidence explicitly. +Provider-returned strings may enter process memory before projection; explicit +properties do not prove that native provider internals process no other metadata. +The application cannot promise PHI-free memory or zero incidental OS writes. +Permission failures have categorical errors, timestamps and limitations; no raw +exception messages are returned. Missing evidence must never imply inactivity. -The Windows Server gate uses CIM ProductType 2/3 plus the release build map; Windows 10/11 are not Server targets even where their kernel builds overlap. The gate is enforced by Python and by full-envelope PowerShell use. `-Section system` can return failed/partial identity details for diagnostics, but Python will not accept an unverified identity as an assessment. +Prefer the Python CLI and its configured private local report folder. Direct driver +output is not supervised by Python's bounds or secure writer: do not use arbitrary +shell redirects on production hosts. See [privacy setup and native validation](../docs/PRIVACY_HARDENING.md). -Direct full-script use has no Python timeout/cancellation supervisor; use the Python CLI or GUI for bounded execution. Both paths honor local script execution policy. Every script emits only selected public/administrative metadata, never raw CIM objects, exception text, task XML, task arguments or service command lines. - -See README.md for exact GUI, headless and real-host validation instructions. `tests/powershell/Test-Collectors.ps1` parses scripts and mocks their data sources; this is synthetic testing, not real host validation. - -Implementation references: [Get-WindowsFeature](https://learn.microsoft.com/en-us/powershell/module/servermanager/get-windowsfeature), [Win32_Service](https://learn.microsoft.com/en-us/windows/win32/cimwin32prov/win32-service), [Get-NetTCPConnection](https://learn.microsoft.com/en-us/powershell/module/nettcpip/get-nettcpconnection), [Get-ScheduledTask](https://learn.microsoft.com/en-us/powershell/module/scheduledtasks/get-scheduledtask), [registry software inventory](https://learn.microsoft.com/en-us/powershell/scripting/samples/working-with-software-installations), [certificate provider](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.security/about/about_certificate_provider). - -Scoring v2 excludes default administrative shares, but keeps them in inventory. Business-share client confirmation requires share-root-correlated open-file counts, not just TCP 445 activity. The read-only activity query exports no file names, session users or client names; same-root administrative aliases remain ambiguous. See docs/SCORING.md. This new query still requires native Windows validation. +`tests/powershell/Test-Collectors.ps1` parses ASTs and invokes synthetic mocked +providers only. It makes forbidden certificate, open-file, full-task and label +enumeration fail. Run on an authorized lab host with its existing script policy; +no real collector runs in that test. A passing mock test is not real-host acceptance. diff --git a/requirements-headless.txt b/requirements-headless.txt new file mode 100644 index 0000000..3ee1ea1 --- /dev/null +++ b/requirements-headless.txt @@ -0,0 +1 @@ +jsonschema==4.26.0 diff --git a/requirements.txt b/requirements.txt index 5ff022a..b9f3fc9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,2 @@ -PySide6>=6.6,<7 -jsonschema>=4.21,<5 +PySide6_Essentials==6.12.0 +jsonschema==4.26.0 diff --git a/samsara/analyzers/dependencies.py b/samsara/analyzers/dependencies.py index 38ac0a3..3b6257b 100644 --- a/samsara/analyzers/dependencies.py +++ b/samsara/analyzers/dependencies.py @@ -189,6 +189,8 @@ def classify_dependencies(data): state = str(row.get('state','')).lower() proc = normal_name(row.get('process','')) remote,local = address(row.get('remote_address')), address(row.get('local_address')) + has_remote = remote is not None or row.get('remote_scope') in ('private','public') + peer = row.get('remote_address') or (f"{row['remote_scope']} peer (address withheld)" if row.get('remote_scope') else '') matching = [] if state in ('established','estab') and local is not None and known_pid(row): wildcard = ipaddress.ip_address('0.0.0.0' if local.version==4 else '::') @@ -207,7 +209,7 @@ def classify_dependencies(data): basis = 'Direction supplied by inventory source; not independently verified.' elif state in ('established','estab') and matching: direction,basis = 'Inbound (inferred)','Local address/port and nonzero PID match a listener. Client initiation is inferred, not observed.' - elif state in ('established','estab') and remote and type(row.get('local_port')) is int and row['local_port']>=49152 and 0 < row.get('remote_port',0)<49152: + elif state in ('established','estab') and has_remote and type(row.get('local_port')) is int and row['local_port']>=49152 and 0 < row.get('remote_port',0)<49152: direction,basis = 'Outbound (inferred)','Ephemeral local port to a non-ephemeral remote port without an attributed listener; heuristic only.' family = row_family(row) classification, rationale = UNKNOWN, 'No attributed application workload. No score from the connection alone.' @@ -219,12 +221,12 @@ def classify_dependencies(data): if direction == 'Listener': evidence(family,'attributed_application_listener',f"{row.get('process','unknown')} PID {row.get('pid','unknown')} listening at {row.get('local_address')}:{row.get('local_port')}; a listener alone is potential exposure.",ref) classification,rationale = POTENTIAL,'Listener attributed to a known application; grouped with service evidence.' - elif state in ('established','estab') and direction.startswith('Inbound') and matching and remote and not remote.is_unspecified: + elif state in ('established','estab') and direction.startswith('Inbound') and matching and has_remote and not (remote and remote.is_unspecified): if family == 'file-sharing': evidence(family,'observed_smb_transport',f"File-sharing transport observed on local port {row.get('local_port')}; specific share/export use is unverified without correlated open files.",ref) classification,rationale = POTENTIAL,'File-sharing transport observed; no named business share attributed.' else: - evidence(family,'observed_application_clients',f"Observed application TCP activity {row.get('local_address')}:{row.get('local_port')} ↔ {row.get('remote_address')}:{row.get('remote_port')}, PID {row.get('pid')}; matches listener(s) {matching}. {basis} Business ownership remains unverified.",ref,True) + evidence(family,'observed_application_clients',f"Observed application TCP activity {row.get('local_address')}:{row.get('local_port')} ↔ {peer}:{row.get('remote_port')}, PID {row.get('pid')}; matches listener(s) {matching}. {basis} Business ownership remains unverified.",ref,True) classification,rationale = CONFIRMED,'Observed transport activity on an attributed application listener; direction inference is recorded separately.' else: note('Other application traffic',UNKNOWN,f"{direction}: {row.get('process','unknown')}; not scored as an inbound consumer. {basis}",ref) @@ -237,7 +239,7 @@ def classify_dependencies(data): classification,rationale=STANDARD,'Baseline OS/management endpoint; no application workload attributed.' note('OS networking',STANDARD,rationale,ref) else: - note('Unattributed networking',UNKNOWN,f"{direction}: {row.get('local_address')}:{row.get('local_port')} ↔ {row.get('remote_address')}:{row.get('remote_port')}. {basis}",ref) + note('Unattributed networking',UNKNOWN,f"{direction}: {row.get('local_address')}:{row.get('local_port')} ↔ {peer}:{row.get('remote_port')}. {basis}",ref) network_observations.append({'inventory_index':index,'direction':direction,'direction_basis':basis, 'classification':classification,'workload':family or '', 'rationale':rationale}) return groups, aggregates, network_observations, task_observations diff --git a/samsara/analyzers/policy.py b/samsara/analyzers/policy.py index 7ec96e7..3490488 100644 --- a/samsara/analyzers/policy.py +++ b/samsara/analyzers/policy.py @@ -41,6 +41,7 @@ def load_policy(source=None): valid = False if not valid: raise ValueError('Invalid scoring policy. Use the bundled v2 policy structure, integer scores 0–100, ordered thresholds and positive coverage weights.') + policy['name']='Deployment scoring policy' return policy diff --git a/samsara/assets/privacy_catalogue.json b/samsara/assets/privacy_catalogue.json new file mode 100644 index 0000000..9404c53 --- /dev/null +++ b/samsara/assets/privacy_catalogue.json @@ -0,0 +1,379 @@ +{ + "services": [ + "acpid", + "apache2", + "auditd", + "bfe", + "bind9", + "bits", + "brokerinfrastructure", + "cbdhsvc", + "cdpsusersvc", + "cdpsvc", + "certsvc", + "chronyd", + "clipsvc", + "containerd", + "coremessagingregistrar", + "cron", + "crond", + "cryptsvc", + "dbus", + "dcomlaunch", + "defragsvc", + "deviceassociationservice", + "dhcp", + "dhcpserver", + "diagnosticshub.standardcollector.service", + "diagtrack", + "dns", + "dnscache", + "docker", + "dps", + "dusmsvc", + "eventlog", + "eventsystem", + "fdphost", + "fdrespub", + "firewalld", + "fontcache", + "gpsvc", + "haproxy", + "hidserv", + "httpd", + "hvhost", + "iphlpsvc", + "irqbalance", + "isc-dhcp-server", + "kea-dhcp4", + "keyiso", + "ktmrm", + "lanmanserver", + "lanmanworkstation", + "lfsvc", + "licensemanager", + "lmhosts", + "lsm", + "mariadb", + "minecraft", + "minecraft-server", + "mpssvc", + "msdtc", + "mssqlserver", + "multipathd", + "mysql", + "mysqld", + "named", + "netlogon", + "netman", + "netprofm", + "nfs-server", + "nginx", + "nlasvc", + "nsi", + "ntds", + "pcasvc", + "plugplay", + "podman", + "policyagent", + "polkit", + "postgresql", + "power", + "profsvc", + "rasman", + "redis", + "redis-server", + "remoteaccess", + "remoteregistry", + "rpceptmapper", + "rpcss", + "rsyslog", + "rustserver", + "samss", + "schedule", + "seclogon", + "securityhealthservice", + "sens", + "sense", + "sessionenv", + "shellhwdetection", + "smb", + "smbd", + "spooler", + "sppsvc", + "sqlserveragent", + "sqlwriter", + "ssdpsrv", + "ssh", + "sshd", + "stisvc", + "storsvc", + "swprv", + "sysmain", + "systemeventsbroker", + "termservice", + "themes", + "timebrokersvc", + "tomcat", + "trustedinstaller", + "tuned", + "ualsvc", + "udisks2", + "uhssvc", + "umrdpservice", + "usosvc", + "valheim", + "vds", + "vmicheartbeat", + "vmickvpexchange", + "vmicshutdown", + "vmictimesync", + "vmicvss", + "vss", + "w32time", + "w3logsvc", + "w3svc", + "was", + "wdiservicehost", + "wdisystemhost", + "wdnissvc", + "wecsvc", + "wersvc", + "windefend", + "winhttpautoproxysvc", + "winmgmt", + "winrm", + "wlidsvc", + "wpnservice", + "wsearch", + "wuauserv" + ], + "processes": [ + "apache2", + "arkascendedserver", + "bedrock_server", + "brave", + "chrome", + "containerd", + "curl", + "dhcpd", + "dockerd", + "firefox", + "haproxy", + "hlds", + "httpd", + "idle", + "iexplore", + "iisexpress", + "kea-dhcp4", + "lsass", + "mariadbd", + "msedge", + "msmpeng", + "mysqld", + "named", + "nfsd", + "nginx", + "opera", + "palserver", + "podman", + "postgres", + "postmaster", + "redis-server", + "rustdedicated", + "services", + "shootergameserver", + "smbd", + "smss", + "spoolsv", + "sqlservr", + "srcds", + "srcds_linux", + "sshd", + "steam", + "steamservice", + "steamwebhelper", + "svchost", + "system", + "tomcat", + "valheim_server", + "w3wp", + "wget", + "wininit", + "winlogon", + "wmiprvse", + "wsmprovhost" + ], + "roles": [ + "ad-domain-services", + "adcs-cert-authority", + "dhcp", + "dns", + "failover-clustering", + "file-services", + "fs-fileserver", + "hyper-v", + "net-framework-45-aspnet", + "net-framework-45-core", + "net-framework-45-features", + "net-framework-core", + "net-framework-features", + "powershell", + "powershell-ise", + "powershell-v2", + "rds-rd-server", + "rsat", + "storage-services", + "telnet-client", + "web-server", + "windows-defender", + "windows-defender-features", + "windows-search", + "wow64-support" + ], + "products": [ + "Microsoft SQL Server", + "PostgreSQL", + "MariaDB", + "MySQL", + "nginx", + "Apache HTTP Server", + "Redis", + "Docker", + "Microsoft .NET", + "Microsoft Visual C++", + "Microsoft Edge", + "Google Chrome", + "Mozilla Firefox", + "Steam", + "Valheim Dedicated Server", + "Minecraft Server" + ], + "families": { + "directory": { + "names": [ + "ntds" + ], + "prefixes": [], + "representative": "ntds" + }, + "sql": { + "names": [ + "mssqlserver", + "sqlserveragent" + ], + "prefixes": [ + "mssql$", + "sqlagent$" + ], + "representative": "mssqlserver" + }, + "iis": { + "names": [ + "w3svc" + ], + "prefixes": [], + "representative": "w3svc" + }, + "web": { + "names": [ + "nginx", + "apache2", + "httpd", + "haproxy", + "tomcat" + ], + "prefixes": [ + "tomcat" + ], + "representative": "nginx" + }, + "postgres": { + "names": [ + "postgresql" + ], + "prefixes": [ + "postgresql-" + ], + "representative": "postgresql" + }, + "mysql": { + "names": [ + "mysql", + "mysqld", + "mariadb" + ], + "prefixes": [ + "mysql", + "mariadb" + ], + "representative": "mysql" + }, + "redis": { + "names": [ + "redis", + "redis-server" + ], + "prefixes": [ + "redis-" + ], + "representative": "redis" + }, + "containers": { + "names": [ + "docker", + "containerd", + "podman" + ], + "prefixes": [], + "representative": "docker" + }, + "dns": { + "names": [ + "dns", + "named", + "bind9" + ], + "prefixes": [], + "representative": "dns" + }, + "dhcp": { + "names": [ + "dhcpserver", + "isc-dhcp-server", + "kea-dhcp4" + ], + "prefixes": [], + "representative": "dhcpserver" + }, + "ca": { + "names": [ + "certsvc" + ], + "prefixes": [], + "representative": "certsvc" + }, + "file-sharing": { + "names": [ + "smbd", + "smb", + "nfs-server" + ], + "prefixes": [], + "representative": "smbd" + }, + "game-server": { + "names": [ + "minecraft", + "minecraft-server", + "valheim", + "rustserver" + ], + "prefixes": [ + "minecraft@", + "valheim-server" + ], + "representative": "minecraft" + } + } +} diff --git a/samsara/assets/privacy_policy.json b/samsara/assets/privacy_policy.json new file mode 100644 index 0000000..224da8e --- /dev/null +++ b/samsara/assets/privacy_policy.json @@ -0,0 +1,9 @@ +{ + "version": "1.0", + "report_directory": "reports", + "approved_hostnames": [], + "approved_share_roots": [], + "approved_service_names": [], + "approved_software_names": [], + "max_session_assessments": 10 +} diff --git a/samsara/collectors/importer.py b/samsara/collectors/importer.py index 82ab610..d30f1a0 100644 --- a/samsara/collectors/importer.py +++ b/samsara/collectors/importer.py @@ -1,53 +1,31 @@ """Offline intake shared by Windows and Linux desktops. No code is executed.""" import json +import os +import stat from pathlib import Path -from samsara.models.inventory import validate_inventory, restrict_inventory_fields +from samsara.core.privacy import minimize_inventory +from samsara.core.storage import require_local_path +from samsara.models.inventory import validate_inventory MAX_IMPORT_BYTES = 10 * 1024 * 1024 def load_inventory(path): - with Path(path).open('rb') as source: + local = require_local_path(path) + flags=os.O_RDONLY|getattr(os,'O_NOFOLLOW',0)|getattr(os,'O_NONBLOCK',0)|getattr(os,'O_BINARY',0) + with os.fdopen(os.open(local,flags),'rb') as source: + if not stat.S_ISREG(os.fstat(source.fileno()).st_mode): + raise ValueError('Only regular local inventory files may be imported.') raw = source.read(MAX_IMPORT_BYTES + 1) if len(raw) > MAX_IMPORT_BYTES: raise ValueError('Inventory exceeds the 10 MiB import limit.') try: data = json.loads(raw.decode('utf-8-sig')) - except (ValueError, UnicodeError): + except (ValueError, UnicodeError, RecursionError): raise ValueError('The selected file is not valid UTF-8 inventory JSON.') from None # A report wrapper is accepted, but its precomputed assessment is never trusted. if isinstance(data, dict) and 'inventory_result' in data: data = data['inventory_result'] validate_inventory(data) - # Keep diagnostic contents out of logs and exported reports. Diagnostics may - # contain command lines or credentials from external collection tools. - safe_errors = { - 'access_denied': 'Current account could not read this scope.', - 'command_unavailable': 'Required command was unavailable.', - 'timeout': 'Section time limit exceeded.', - 'overall_timeout': 'Overall collection time limit exceeded.', - 'output_limit': 'Collection output limit exceeded.', - 'powershell_failed': 'PowerShell could not execute the collector.', - 'invalid_output': 'Invalid section output.', - 'collection_failed': 'A local query failed.' - } - checks = {} - for name, check in data['checks'].items(): - clean = {'status': check['status'], 'message': - 'Imported check status. Source assertions are unverified; raw diagnostics excluded.'} - for field in ('started_at', 'completed_at'): - if field in check: - clean[field] = check[field] - if 'errors' in check: - clean['errors'] = [] - for error in check['errors']: - code = error['code'] if error['code'] in safe_errors else 'collection_failed' - clean['errors'].append({'code': code, 'scope': 'Imported check', 'message': safe_errors[code]}) - if 'permission_limitations' in check: - clean['permission_limitations'] = ['Collector reported limited read access; details excluded.'] if check['permission_limitations'] else [] - if 'limitations' in check: - clean['limitations'] = ['Collector reported scope limitations; consult the inventory owner.'] if check['limitations'] else [] - checks[name] = clean - data['checks'] = checks - return restrict_inventory_fields(data) + return validate_inventory(minimize_inventory(data)) diff --git a/samsara/collectors/linux.py b/samsara/collectors/linux.py index b73c6b9..9220c82 100644 --- a/samsara/collectors/linux.py +++ b/samsara/collectors/linux.py @@ -1,56 +1,26 @@ """Read-only local Linux inventory. No networking, sudo, shells or configuration changes.""" import json -import os from pathlib import Path import platform import re import shlex import socket -import subprocess -import tempfile import time from datetime import datetime, timezone -from samsara.collectors.linux_schedules import read_system_cron, enrich_timers -class CollectionCancelled(Exception): - pass +from samsara.core.process import CollectionCancelled, capture +from samsara.core.privacy import minimize_inventory, load_privacy_policy def run_command(arguments, cancelled=lambda: False, timeout=8): - """Execute only fixed collector commands from system directories, with bounded output.""" - executable = next((str(Path(folder) / arguments[0]) for folder in - ('/usr/bin', '/usr/sbin', '/bin', '/sbin') - if (Path(folder) / arguments[0]).is_file()), None) - if executable is None: - raise RuntimeError('Required inventory command is unavailable.') - environment = {'PATH': '/usr/bin:/usr/sbin:/bin:/sbin', 'LC_ALL': 'C', - 'LANG': 'C', 'SYSTEMD_PAGER': '', 'SYSTEMD_COLORS': '0'} - with tempfile.TemporaryFile() as output: - process = subprocess.Popen([executable, *arguments[1:]], stdout=output, - stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL, - env=environment, shell=False) - deadline = time.monotonic() + timeout - try: - while process.poll() is None: - if cancelled(): - raise CollectionCancelled('Local collection cancelled.') - if time.monotonic() >= deadline: - raise RuntimeError('Inventory command timed out.') - if os.fstat(output.fileno()).st_size > 10 * 1024 * 1024: - raise RuntimeError('Inventory command exceeded its output limit.') - time.sleep(.05) - if process.returncode: - raise RuntimeError('Inventory command unavailable or access denied.') - output.seek(0) - raw = output.read(10 * 1024 * 1024 + 1) - if len(raw) > 10 * 1024 * 1024: - raise RuntimeError('Inventory command exceeded its output limit.') - return raw.decode('utf-8', errors='replace') - finally: - if process.poll() is None: - process.kill() - process.wait() + executable = next((str(Path(folder)/arguments[0]) for folder in + ('/usr/bin','/usr/sbin','/bin','/sbin') if (Path(folder)/arguments[0]).is_file()), None) + if executable is None: raise RuntimeError('Required inventory command unavailable.') + environment = {'PATH':'/usr/bin:/usr/sbin:/bin:/sbin','LC_ALL':'C','LANG':'C', + 'SYSTEMD_PAGER':'','SYSTEMD_COLORS':'0'} + return capture([executable,*arguments[1:]], env=environment, timeout=timeout, + cancelled=cancelled).decode('utf-8',errors='replace') def parse_units(text, enabled_text, kind='service'): @@ -74,6 +44,12 @@ def parse_units(text, enabled_text, kind='service'): return rows +# ss(8) spellings mapped to the canonical names shared with the Windows collector. +TCP_STATES = {'LISTEN': 'Listen', 'ESTAB': 'Established', 'TIME-WAIT': 'TimeWait', 'CLOSE-WAIT': 'CloseWait', + 'SYN-SENT': 'SynSent', 'SYN-RECV': 'SynReceived', 'FIN-WAIT-1': 'FinWait1', + 'FIN-WAIT-2': 'FinWait2', 'LAST-ACK': 'LastAck', 'CLOSING': 'Closing'} + + def endpoint(value): address, port = value.rsplit(':', 1) return address.strip('[]'), int(port) if port.isdigit() else 0 @@ -85,7 +61,7 @@ def parse_connections(text): fields = line.split(None, 5) if len(fields) < 5: continue - state = {'LISTEN': 'Listen', 'ESTAB': 'Established'}.get(fields[0], fields[0]) + state = TCP_STATES.get(fields[0], fields[0]) local, lp = endpoint(fields[3]) remote, rp = endpoint(fields[4]) process = re.search(r'\("([^"\n]+)",pid=(\d+)', fields[5] if len(fields) > 5 else '') @@ -100,10 +76,11 @@ def parse_connections(text): def collect_local_linux(stage=lambda message: None, cancelled=lambda: False, runner=run_command): if platform.system() != 'Linux': raise ValueError('Local Linux collection requires a Linux execution host.') + privacy_policy = load_privacy_policy() started = time.monotonic() data = {'schema_version': '1.0', 'synthetic': False, 'collected_at': datetime.now(timezone.utc).isoformat(), - 'server': {'hostname': socket.gethostname(), 'platform': 'linux', + 'server': {'hostname': socket.gethostname() if privacy_policy['approved_hostnames'] else 'Host-0001', 'platform': 'linux', 'collection_source': 'Local Linux read-only collection', 'operating_system': platform.system(), 'os_build': platform.release(), 'last_boot': 'Not collected', 'ip_addresses': [], 'network_adapters': [], @@ -151,14 +128,9 @@ def collect_local_linux(stage=lambda message: None, cancelled=lambda: False, run check('services', lambda: units('service'), 'System systemd units observed; per-service accounts, user units and executables not collected.') def timers(): units('timer') - enrich_timers(data['inventory']['tasks'], command) + # Detailed unit properties may contain identifiers; omit by default. check('tasks', timers, 'Loaded system timers and available calendar/next-run metadata observed; unloaded/user timers and application schedulers require review.') - def cron(): - rows, incomplete = read_system_cron(cancelled) - data['inventory']['tasks'].extend(rows) - if incomplete: - raise RuntimeError('Some system cron files inaccessible') - check('cron', cron, 'Readable /etc/crontab and /etc/cron.d entries observed. Arguments omitted. User crontabs, anacron rules and periodic-script contents not inspected; timezone and actual execution unverified.') + data['checks']['cron'] = {'status':'not_collected','message':'Privacy policy: cron commands and script files are not read. Verify schedules separately.'} def network(): data['inventory']['network'] = parse_connections(command(['ss', '-H', '-n', '-t', '-a', '-p'])) check('network', network, 'TCP snapshot only. Process visibility is limited by permissions; direction, historical activity, UDP and other namespaces not verified.') @@ -192,7 +164,7 @@ def collect_local_linux(stage=lambda message: None, cancelled=lambda: False, run }.items(): data['checks'][key] = {'status': 'not_collected', 'message': message} check_cancel() - return data + return minimize_inventory(data, privacy_policy, trusted_source='Local Linux read-only collection') def main(): @@ -204,8 +176,8 @@ def main(): try: result = collect_local_linux() # Exclusive creation prevents accidentally replacing an existing report. - with open(args.output, 'x', encoding='utf-8') as destination: - json.dump(result, destination, indent=2) + from samsara.core.storage import write_private + write_private(args.output, json.dumps(result, indent=2)) print('Local inventory saved. Review per-check statuses before making retirement decisions.') except (OSError, ValueError, RuntimeError): parser.exit(1, 'Collection/export failed. Check Linux platform, destination permissions and whether the destination already exists.\n') diff --git a/samsara/collectors/windows.py b/samsara/collectors/windows.py index e22fa5b..74cb969 100644 --- a/samsara/collectors/windows.py +++ b/samsara/collectors/windows.py @@ -8,13 +8,13 @@ from pathlib import Path import platform import struct import subprocess -import tempfile import time from jsonschema import Draft202012Validator, FormatChecker -from samsara.collectors.linux import CollectionCancelled +from samsara.core.process import CollectionCancelled, ProcessFailure, capture +from samsara.core.privacy import minimize_inventory, load_privacy_policy, safe_time from samsara.core.detection import check_profile -from samsara.models.inventory import inventory_schema, validate_inventory, restrict_inventory_fields +from samsara.models.inventory import inventory_schema, validate_inventory SCRIPT = Path(__file__).resolve().parents[2] / 'powershell' / 'Get-ServerInventory.ps1' SECTIONS = ('system', 'roles', 'services', 'network', 'tasks', 'software', 'storage', 'shares', 'certificates') @@ -56,44 +56,19 @@ def run_section(section, executable, cancelled=lambda: False, timeout=45, diagno args = [str(executable), '-NoLogo', '-NoProfile', '-NonInteractive', '-File', str(SCRIPT), '-Section', section] if diagnostic: args.append('-Diagnostic') - environment = os.environ.copy() - # Only inbox modules are needed. Do not autoload user-supplied modules or profiles. + environment = {key: os.environ[key] for key in ('SystemRoot','WINDIR','TEMP','TMP','COMSPEC','SystemDrive') if key in os.environ} environment['PSModulePath'] = str(Path(executable).parent / 'Modules') - with tempfile.TemporaryFile() as output: - try: - process = subprocess.Popen(args, stdin=subprocess.DEVNULL, stdout=output, - stderr=subprocess.DEVNULL, shell=False, env=environment, - creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0)) - except OSError: - raise WindowsCollectionError('powershell_failed', 'Windows PowerShell could not start. Check executable permissions and local policy.') from None - deadline = time.monotonic() + timeout - try: - while process.poll() is None: - if cancelled(): - raise CollectionCancelled() - if time.monotonic() >= deadline: - raise WindowsCollectionError('timeout', 'The local section exceeded its time limit.') - if os.fstat(output.fileno()).st_size > MAX_OUTPUT: - raise WindowsCollectionError('output_limit', 'The local section exceeded its output limit.') - time.sleep(.05) - if cancelled(): - raise CollectionCancelled() - if process.returncode: - if process.returncode == 3: - raise WindowsCollectionError('serialization_failed', 'PowerShell could not serialize inventory metadata; no successful evidence was substituted.') - raise WindowsCollectionError('powershell_failed', 'The trusted script could not run. Check script signing policy, module availability and read permissions; raw diagnostics are excluded.') - output.seek(0) - raw = output.read(MAX_OUTPUT + 1) - if len(raw) > MAX_OUTPUT: - raise WindowsCollectionError('output_limit', 'The local section exceeded its output limit.') - try: - return json.loads(raw.decode('utf-8-sig')) - except (UnicodeError, ValueError, RecursionError): - raise WindowsCollectionError('invalid_output', 'PowerShell did not return a valid UTF-8 JSON section.') from None - finally: - if process.poll() is None: - process.kill() - process.wait() + try: + raw = capture(args, env=environment, timeout=timeout, cancelled=cancelled, + limit=MAX_OUTPUT, creationflags=getattr(subprocess,'CREATE_NO_WINDOW',0)) + except ProcessFailure as exc: + if exc.returncode == 3: + raise WindowsCollectionError('serialization_failed', 'PowerShell could not serialize inventory metadata; no successful evidence was substituted.') from None + raise WindowsCollectionError(exc.code, str(exc)) from None + try: + return json.loads(raw.decode('utf-8-sig')) + except (UnicodeError, ValueError, RecursionError): + raise WindowsCollectionError('invalid_output','PowerShell did not return a valid JSON section.') from None def validate_section(record, section): @@ -108,7 +83,8 @@ def validate_section(record, section): section_schema = {'type': 'object', 'required': ['section','status','message','started_at','completed_at','errors','permission_limitations','limitations','data'], 'properties': properties} if not Draft202012Validator(section_schema, format_checker=FormatChecker()).is_valid(record): raise WindowsCollectionError('invalid_output', 'PowerShell returned a section that violates the inventory contract.') - if record['completed_at'] < record['started_at']: + start, end = safe_time(record['started_at']), safe_time(record['completed_at']) + if start is None or end is None or end < start: # Both native timestamps use the same UTC format. This is not a trust check. raise WindowsCollectionError('invalid_output', 'PowerShell returned inconsistent section timestamps.') return record @@ -136,6 +112,7 @@ def collect_local_windows(stage=lambda message: None, cancelled=lambda: False, p raise ValueError('Local Windows collection requires an authorized Windows Server execution host.') if profile != 'auto' and not profile.startswith('windows:'): raise ValueError('Select Auto-detect or a Windows Server profile for this execution host.') + load_privacy_policy() executable = powershell_executable() runner = runner or run_section started = time.monotonic() @@ -179,7 +156,7 @@ def collect_local_windows(stage=lambda message: None, cancelled=lambda: False, p raise ValueError('Windows identity collection failed: ' + str(exc)) from None if cancelled(): raise CollectionCancelled() - return validate_inventory(restrict_inventory_fields(result, local_source='Local Windows read-only collection')) + return validate_inventory(minimize_inventory(result, trusted_source='Local Windows read-only collection')) def main(): @@ -190,8 +167,8 @@ def main(): args = parser.parse_args() try: data = collect_local_windows(profile=args.profile, diagnostic=args.diagnostic) - with open(args.output, 'x', encoding='utf-8') as output: - json.dump(data, output, indent=2) + from samsara.core.storage import write_private + write_private(args.output, json.dumps(data, indent=2)) print('Windows inventory saved. Review all failed/partial checks and permission limitations before retirement decisions.') except (OSError, ValueError, WindowsCollectionError): parser.exit(1, 'Windows inventory was not saved. Check supported local Server OS, Python architecture, script policy, permissions and a new writable output filename.\n') diff --git a/samsara/core/detection.py b/samsara/core/detection.py index 8f299cf..ba9c949 100644 --- a/samsara/core/detection.py +++ b/samsara/core/detection.py @@ -46,4 +46,4 @@ def check_profile(detected, profile): raise ValueError('Unknown operating system profile.') family, version = profile.split(':') if detected['family'] != family or (version != 'any' and detected['version'] != version): - raise ValueError(f"Detected {detected['name']}, which does not match the selected profile. Select Auto-detect to assess this computer.") + raise ValueError('Detected operating system does not match the selected profile. Select Auto-detect to assess this computer.') diff --git a/samsara/core/privacy.py b/samsara/core/privacy.py new file mode 100644 index 0000000..74934e0 --- /dev/null +++ b/samsara/core/privacy.py @@ -0,0 +1,259 @@ +"""Minimize identifying metadata before UI, analysis and export. + +This is a disclosure-reduction boundary, not PHI detection or HIPAA certification. +Legacy inputs and provider-returned metadata can enter memory before projection. +Only deployment-reviewed exact allowlists can authorize otherwise omitted names. +""" +from datetime import datetime, timezone +import ipaddress +import json +from pathlib import Path, PureWindowsPath, PurePosixPath +import re + +from samsara.analyzers.dependencies import (STANDARD_SERVICES, STANDARD_PROCESS, CLIENT_PROCESS, + FAMILIES, ROLE_FAMILIES, service_family, normal_name) + +POLICY_PATH = Path(__file__).resolve().parents[1] / 'assets' / 'privacy_policy.json' +PROJECT_ROOT = Path(__file__).resolve().parents[2] +OMITTED = 'Omitted by privacy policy' +SECTIONS = {'system','interfaces','roles','services','network','tasks','cron','software','storage', + 'shares','share_activity','certificates','directory','ad','privacy'} +SCORES = {'complete','partial','failed','not_collected'} +ERROR_CODES = {'access_denied','command_unavailable','timeout','overall_timeout','output_limit', + 'powershell_failed','invalid_output','serialization_failed','collection_failed','cleanup_failed'} +KNOWN_ROLES = set(ROLE_FAMILIES) | {'net-framework-core','net-framework-45-core','net-framework-45-aspnet', + 'net-framework-features','net-framework-45-features','powershell','powershell-v2','powershell-ise', + 'windows-defender','windows-defender-features','storage-services','file-services','fs-fileserver', + 'wow64-support','rsat','telnet-client','windows-search'} +KNOWN_SERVICES = STANDARD_SERVICES | {name for _,names,_,_ in FAMILIES.values() for name in names} +KNOWN_PROCESSES = STANDARD_PROCESS | CLIENT_PROCESS | {name for _,_,_,names in FAMILIES.values() for name in names} +PRODUCTS = ('Microsoft SQL Server','PostgreSQL','MariaDB','MySQL','nginx','Apache HTTP Server', + 'Redis','Docker','Microsoft .NET','Microsoft Visual C++','Microsoft Edge','Google Chrome', + 'Mozilla Firefox','Steam','Valheim Dedicated Server','Minecraft Server') +TASK_STATES = {'Unknown','Disabled','Queued','Ready','Running','active/waiting','active/running','inactive/dead'} +SERVICE_STATES = {'Running','Stopped','Paused','Start Pending','Stop Pending','Unknown','active/exited','inactive/dead','failed/failed'} +STARTUP = {'Auto','Automatic','Manual','Disabled','enabled','disabled','static','indirect','masked','generated','alias','Unknown'} +NETWORK_STATES = {'Listen','Established','Bound','Closed','TimeWait','CloseWait','SynSent','SynReceived','FinWait1','FinWait2','LastAck','Closing','DeleteTCB'} +SOURCES = {'Local Windows read-only collection','Local Linux read-only collection'} + + +def load_privacy_policy(path=POLICY_PATH): + try: + with Path(path).open('rb') as source: + raw = source.read(65537) + if len(raw)>65536: + raise ValueError() + policy = json.loads(raw.decode('utf-8')) + expected = {'version','report_directory','approved_hostnames','approved_share_roots', + 'approved_service_names','approved_software_names','max_session_assessments'} + if not isinstance(policy,dict) or set(policy)!=expected or policy['version']!='1.0': + raise ValueError() + if type(policy['max_session_assessments']) is not int or not 1<=policy['max_session_assessments']<=50: + raise ValueError() + if not isinstance(policy['report_directory'],str) or not policy['report_directory']: + raise ValueError() + for field in expected-{'version','report_directory','max_session_assessments'}: + if not isinstance(policy[field],list) or len(policy[field])>200 or any(not isinstance(v,str) or not v or len(v)>256 or any(ord(c)<32 for c in v) for v in policy[field]): + raise ValueError() + for root in policy['approved_share_roots']: + canonical_share_root(root) # Exact roots only; never a descendant allowlist. + return policy + except (OSError, UnicodeError, ValueError, TypeError, RecursionError): + raise ValueError('Privacy policy is unavailable or invalid. Collection/export is blocked; contact the deployment administrator.') from None + + +def canonical_share_root(value): + if not isinstance(value,str) or not value or any(ord(c)<32 for c in value): + raise ValueError('Invalid approved share root.') + if value.startswith(('\\\\','//')) or '..' in value.replace('\\','/').split('/'): + raise ValueError('Only explicit local share roots may be approved.') + windows = PureWindowsPath(value) + if windows.drive: + if not re.fullmatch(r'[A-Za-z]:',windows.drive) or not windows.is_absolute(): + raise ValueError('Invalid approved local root.') + return str(windows).rstrip('\\').casefold() + posix = PurePosixPath(value) + if not posix.is_absolute(): + raise ValueError('Approved share roots must be absolute.') + return str(posix).rstrip('/') or '/' + + +def safe_time(value): + if not isinstance(value,str) or not re.fullmatch(r'[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{1,7})?(?:Z|[+-][0-9]{2}:[0-9]{2})',value): + return None + try: + parsed=datetime.fromisoformat(value.replace('Z','+00:00')) + return parsed.astimezone(timezone.utc).isoformat() + except ValueError: + return None + + +def numeric_version(value): + if not isinstance(value,str): return 'Not collected' + match=re.match(r'^[0-9]{1,5}(?:\.[0-9]{1,5}){0,3}(?![0-9.])',value) + return match[0] if match else 'Not collected' + + +def safe_ip(value): + if not isinstance(value,str): return '' + try: + return str(ipaddress.ip_address(value.split('%')[0].strip('[]'))) + except ValueError: + return '' + + +REMOTE_SCOPES = {'loopback','host','unspecified','private','public'} + + +def remote_scope(value, own, previous=None): + """Classify a peer address. Only loopback, this host's own and wildcard addresses stay + numeric: other peers (clients, patients, workstations) are reduced to private/public.""" + if not isinstance(value,str) or not value.strip(): + return (previous if previous in ('private','public') else ''), '' + try: + ip=ipaddress.ip_address(value.split('%')[0].strip().strip('[]')) + except ValueError: + return '', '' + if isinstance(ip,ipaddress.IPv6Address) and ip.ipv4_mapped: ip=ip.ipv4_mapped + if ip.is_unspecified: return 'unspecified', str(ip) + if ip.is_loopback: return 'loopback', str(ip) + if ip in own: return 'host', str(ip) + return ('private' if ip.is_private or ip.is_link_local else 'public'), '' + + +def integer(value, maximum=2**32-1): + return value if type(value) is int and 0<=value<=maximum else 0 + + +def service_label(value, index, policy): + if not isinstance(value,str): return f'Service-{index+1:04d}' + if value in policy['approved_service_names']: return value + name=normal_name(value) + if name in KNOWN_SERVICES: return name + family=service_family(value) + if family: + # Preserve family recognition without retaining instance/customer suffixes. + representative=FAMILIES[family][1][0] + return representative + if re.fullmatch(r'(?:vmic|systemd-)[a-z0-9_.-]+',name): + return f'OS service-{index+1:04d}' + return f'Service-{index+1:04d}' + + +def task_is_maintenance(row): + path=str(row.get('path','')).replace('/','\\').lower() + return path.startswith('\\microsoft\\windows\\') and not path.startswith('\\microsoft\\windows\\powershell\\scheduledjobs') + + +def enum_value(value, choices, default='Unknown'): + return value if isinstance(value,str) and value in choices else default + + +def minimize_inventory(data, policy=None, *, trusted_source=None): + policy=load_privacy_policy() if policy is None else policy + if not isinstance(data,dict) or not isinstance(data.get('server'),dict) or not isinstance(data.get('inventory'),dict) or not isinstance(data.get('checks'),dict): + raise ValueError('Invalid inventory container.') + stamp=safe_time(data.get('collected_at')) + if not stamp: raise ValueError('Invalid collection timestamp.') + raw=data['server']; platform=raw.get('platform') + if platform not in ('windows','linux'): raise ValueError('Unsupported inventory platform.') + year=raw.get('os_version') if raw.get('os_version') in ('2016','2019','2022','2025') else None + if platform=='windows' and year is None: + match=re.search(r'\b(2016|2019|2022|2025)\b',str(raw.get('operating_system',''))) + year=match[0] if match else None + linux_name=next((label for pattern,label in ((r'\bred hat\b|\brhel\b','Red Hat Enterprise Linux'),(r'\brocky\b','Rocky Linux'),(r'\balmalinux\b','AlmaLinux'),(r'\boracle linux\b','Oracle Linux'),(r'\bcentos\b','CentOS'),(r'\blinux mint\b','Linux Mint'),(r'\bubuntu\b','Ubuntu'),(r'\bdebian\b','Debian')) if re.search(pattern,str(raw.get('operating_system','')),re.I)), 'Linux') + hostname=raw.get('hostname') if raw.get('hostname') in policy['approved_hostnames'] else 'Host-0001' + server={'hostname':hostname,'platform':platform, + 'operating_system':'Windows Server '+year if year else 'Windows' if platform=='windows' else linux_name, + 'os_version':year or numeric_version(raw.get('os_version','')), + 'os_build':numeric_version(raw.get('os_build','')), + 'last_boot':safe_time(raw.get('last_boot')) or 'Not collected', + 'domain':'Domain identity omitted', + 'ip_addresses':[ip for value in raw.get('ip_addresses',[]) if (ip:=safe_ip(value))], + 'network_adapters':[f'Adapter-{i+1:04d}' for i,_ in enumerate(raw.get('network_adapters',[]))]} + if isinstance(raw.get('os_build'),str) and re.fullmatch(r'[0-9]{1,6}',raw['os_build']): server['os_build']=raw['os_build'] + if raw.get('product_type') in (1,2,3) and type(raw['product_type']) is int: server['product_type']=raw['product_type'] + if type(raw.get('domain_joined')) is bool: server['domain_joined']=raw['domain_joined'] + source=trusted_source if trusted_source in SOURCES else None + if source: server['collection_source']=source + result={'schema_version':'1.0','synthetic':data.get('synthetic') is True,'collected_at':stamp, + 'server':server,'checks':{},'inventory':{key:[] for key in ('roles','services','network','tasks','software','storage','certificates')}, + 'privacy':{'profile':'minimized-metadata-v1','identities_omitted':True,'paths':'approved-roots-only', + 'scope':'Disclosure reduction only; not a PHI-free or compliance attestation.'}} + own={ipaddress.ip_address(ip) for ip in server['ip_addresses']} + for section in result['inventory']: + rows=data['inventory'].get(section,[]) + if not isinstance(rows,list) or len(rows)>20000: raise ValueError('Inventory section exceeds its permitted structure or row limit.') + for i,row in enumerate(rows): + if not isinstance(row,dict): raise ValueError('Inventory row must be an object.') + name=row.get('name','') + if section=='roles': + normalized=str(name).lower() + result['inventory'][section].append({'name':normalized if normalized in KNOWN_ROLES else f'Component-{i+1:04d}','state':'Installed' if row.get('state')=='Installed' else 'Unknown'}) + elif section=='services': + result['inventory'][section].append({'name':service_label(name,i,policy),'state':enum_value(row.get('state'), SERVICE_STATES), + 'startup':enum_value(row.get('startup'), STARTUP),'pid':integer(row.get('pid')),'account':OMITTED}) + elif section=='network': + process=normal_name(row.get('process')) + scope,peer=remote_scope(row.get('remote_address'),own,row.get('remote_scope')) + result['inventory'][section].append({'state':enum_value(row.get('state'), NETWORK_STATES), + 'local_address':safe_ip(row.get('local_address')),'remote_address':peer,'remote_scope':scope, + 'local_port':integer(row.get('local_port'),65535),'remote_port':integer(row.get('remote_port'),65535), + 'pid':integer(row.get('pid')),'process':process if process in KNOWN_PROCESSES else f'Process-{i+1:04d}', + 'direction':row.get('direction') if row.get('direction') in ('Inbound','Outbound','Unknown') else 'Unknown'}) + elif section=='tasks': + routine=task_is_maintenance(row) + result['inventory'][section].append({'name':f'Maintenance-{i+1:04d}' if routine else f'Task-{i+1:04d}', + 'path':'\\Microsoft\\Windows\\Maintenance\\' if routine else 'Task folder omitted', + 'enabled':row.get('enabled') is True,'state':enum_value(row.get('state'), TASK_STATES), + 'account':OMITTED,'triggers':'Schedule details omitted; verify through an authorized scheduler review.'}) + elif section=='software': + label=name if isinstance(name,str) and name in policy['approved_software_names'] else None + if label is None: + label=next((product for product in PRODUCTS if isinstance(name,str) and re.match(re.escape(product)+r'(?![A-Za-z0-9])',name,re.I)),f'Software-{i+1:04d}') + result['inventory'][section].append({'name':label,'version':numeric_version(row.get('version'))}) + elif section=='storage': + kind=row.get('type') if row.get('type') in ('Volume','SMB Share','NFS Export','Samba Share','Remote Mount','Mounted Filesystem') else 'Volume' + admin=isinstance(name,str) and re.fullmatch(r'(?:ADMIN|IPC|[A-Z])\$',name,re.I) + path=OMITTED + try: + canonical=canonical_share_root(row.get('path')) + approved={canonical_share_root(root) for root in policy['approved_share_roots']} + if canonical in approved: path=row['path'] + except ValueError: pass + entry={'type':kind,'name':name.upper() if admin else f'Share-{i+1:04d}' if kind in ('SMB Share','NFS Export','Samba Share') else f'Volume-{i+1:04d}', + 'path':path,'capacity_gib':None,'free_gib':None} + for key in ('capacity_gib','free_gib'): + number=row.get(key) + if type(number) in (int,float) and 0<=number<=10**12: entry[key]=number + if row.get('filesystem') in ('NTFS','ReFS','FAT32','exFAT','ext4','xfs','btrfs','tmpfs','overlay'): entry['filesystem']=row['filesystem'] + if type(row.get('special')) is bool: entry['special']=row['special'] + # Historical aggregate observations can be retained without paths/identities. + if row.get('activity_source') in ('Get-SmbOpenFile (share-root-correlated)','Get-SmbOpenFile (administrative alias ambiguous)'): + entry['activity_source']=row['activity_source'] + for key in ('observed_open_files','observed_client_count'): entry[key]=integer(row.get(key)) + observed=safe_time(row.get('activity_observed_at')) + if observed: entry['activity_observed_at']=observed + result['inventory'][section].append(entry) + elif section=='certificates': + entry={'subject':f'Certificate-{i+1:04d}','issuer':OMITTED} + if safe_time(row.get('expires')): entry['expires']=safe_time(row['expires']) + if row.get('store') in ('My','Root','CA','TrustedPeople','TrustedPublisher','AuthRoot','Disallowed'): entry['store']=row['store'] + result['inventory'][section].append(entry) + for name,check in data['checks'].items(): + if name not in SECTIONS: continue + if not isinstance(check,dict): raise ValueError('Invalid collection check.') + status=enum_value(check.get('status'), SCORES, 'failed') + clean={'status':status,'message':{'complete':'Documented query scope completed.','partial':'Scope incomplete; manual review required.','failed':'Collection check failed; raw diagnostics omitted.','not_collected':'Scope not collected; review required.'}[status], + 'errors':[],'permission_limitations':[],'limitations':['Identifying text is minimized; metadata may have entered source-process memory before projection.']} + for field in ('started_at','completed_at'): + if safe_time(check.get(field)): clean[field]=safe_time(check[field]) + for error in (check.get('errors',[]) if isinstance(check.get('errors',[]),list) else [])[:30]: + code=error.get('code') if isinstance(error,dict) else 'collection_failed' + if not isinstance(code,str) or code not in ERROR_CODES: code='collection_failed' + clean['errors'].append({'code':code,'scope':name,'message':'Collection limitation; raw diagnostics omitted.'}) + if check.get('permission_limitations'): clean['permission_limitations']=['Current token may have incomplete read access.'] + result['checks'][name]=clean + result['checks']['privacy']={'status':'partial','message':'Identifying fields omitted. Source metadata may enter memory; this mode is not a PHI-free attestation.', + 'errors':[],'permission_limitations':[],'limitations':['Names/accounts/path detail are reduced. Numeric IP addresses remain infrastructure-confidential data. Legacy imports are processed before projection.']} + return result diff --git a/samsara/core/process.py b/samsara/core/process.py new file mode 100644 index 0000000..d1183e3 --- /dev/null +++ b/samsara/core/process.py @@ -0,0 +1,62 @@ +"""Bounded child output in memory; never spool inventory to temporary files.""" +import subprocess +import threading +import time + + +class CollectionCancelled(Exception): + pass + + +class ProcessFailure(RuntimeError): + def __init__(self, code, returncode=None): + self.code = code + self.returncode = returncode + super().__init__('Inventory subprocess failed (' + code + '); diagnostics omitted.') + + +def capture(arguments, *, env, timeout, cancelled=lambda: False, limit=10*1024*1024, creationflags=0): + if cancelled(): + raise CollectionCancelled() + try: + child = subprocess.Popen(arguments, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, shell=False, env=env, creationflags=creationflags) + except OSError: + raise ProcessFailure('command_unavailable') from None + buffer = bytearray() + exceeded = threading.Event() + read_failed = threading.Event() + def drain(): + try: + while chunk := child.stdout.read(65536): + if len(buffer)+len(chunk)>limit: + exceeded.set() + break + buffer.extend(chunk) + except (OSError, ValueError): + read_failed.set() + reader = threading.Thread(target=drain, daemon=True) + reader.start() + deadline = time.monotonic()+timeout + try: + while child.poll() is None: + if cancelled(): raise CollectionCancelled() + if exceeded.is_set(): raise ProcessFailure('output_limit') + if time.monotonic()>=deadline: raise ProcessFailure('timeout') + time.sleep(.02) + reader.join(timeout=1) + if reader.is_alive(): raise ProcessFailure('cleanup_failed') + if cancelled(): raise CollectionCancelled() + if exceeded.is_set(): raise ProcessFailure('output_limit') + if read_failed.is_set() or child.returncode: raise ProcessFailure('collection_failed', child.returncode) + return bytes(buffer) + finally: + if child.poll() is None: child.kill() + try: + child.wait(timeout=3) + except subprocess.TimeoutExpired: + raise ProcessFailure('cleanup_failed') from None + # Fixed collectors must not spawn descendants holding stdout open. + # Do not block on closing a buffered stream owned by a stalled reader. + reader.join(timeout=.2) + if not reader.is_alive(): child.stdout.close() diff --git a/samsara/core/storage.py b/samsara/core/storage.py new file mode 100644 index 0000000..d378f4a --- /dev/null +++ b/samsara/core/storage.py @@ -0,0 +1,118 @@ +"""Exclusive private report files in a deployment-configured local directory. + +Network-mounted storage detection is best effort; enterprise egress controls remain +required. Windows roots must be provisioned with a protected, restricted DACL. +""" +import ctypes +import os +from pathlib import Path +import re +import stat + +from samsara.core.privacy import load_privacy_policy, PROJECT_ROOT + + +def require_local_path(path): + value = os.fspath(path) + if not value or value.startswith(('\\\\','//')) or '://' in value: + raise ValueError('Network and URI paths are not permitted.') + candidate = Path(os.path.abspath(value)) + if os.name == 'nt': + kernel = ctypes.WinDLL('kernel32',use_last_error=True,winmode=0x800) + kernel.GetDriveTypeW.argtypes=[ctypes.c_wchar_p] + if kernel.GetDriveTypeW(candidate.anchor) != 3: + raise ValueError('A fixed local drive is required.') + else: + # Inspect mount metadata only; do not probe remote mounts. + for line in Path('/proc/self/mountinfo').read_text().splitlines(): + before,after=line.split(' - ',1) + mount=re.sub(r'\\([0-7]{3})',lambda m: chr(int(m[1],8)),before.split()[4]) + fs=after.split()[0] + if (fs.startswith('fuse.') or fs in {'nfs','nfs4','cifs','smb3','9p','ceph','afs','glusterfs','davfs','fuse.sshfs','fuse.rclone'}) and (candidate==Path(mount) or Path(mount) in candidate.parents): + raise ValueError('Network-backed paths are not permitted.') + for parent in reversed([candidate,*candidate.parents]): + try: + info = parent.lstat() + except FileNotFoundError: + continue + if stat.S_ISLNK(info.st_mode) or getattr(info,'st_file_attributes',0)&0x400: + raise ValueError('Symbolic links and reparse points are not permitted.') + return candidate + + +def _verify_windows_acl(path, *, protected=True): + """Fail closed: protected DACL; allow ACEs only owner, SYSTEM, Administrators. + + No ACL changes, elevation or shell commands. Native Windows validation pending. + """ + from ctypes import wintypes as w + adv=ctypes.WinDLL('advapi32',use_last_error=True,winmode=0x800); kernel=ctypes.WinDLL('kernel32',use_last_error=True,winmode=0x800) + ptr=ctypes.c_void_p + adv.GetNamedSecurityInfoW.argtypes=[w.LPWSTR,w.DWORD,w.DWORD,ctypes.POINTER(ptr),ptr,ctypes.POINTER(ptr),ptr,ctypes.POINTER(ptr)] + adv.GetNamedSecurityInfoW.restype=w.DWORD + adv.GetSecurityDescriptorControl.argtypes=[ptr,ctypes.POINTER(w.WORD),ctypes.POINTER(w.DWORD)] + adv.ConvertSidToStringSidW.argtypes=[ptr,ctypes.POINTER(w.LPWSTR)] + adv.GetAce.argtypes=[ptr,w.DWORD,ctypes.POINTER(ptr)] + kernel.LocalFree.argtypes=[ptr];kernel.LocalFree.restype=ptr + owner=ptr();acl=ptr();descriptor=ptr() + result=adv.GetNamedSecurityInfoW(str(path),1,5,ctypes.byref(owner),None,ctypes.byref(acl),None,ctypes.byref(descriptor)) + if result: raise ValueError('Cannot verify report-directory security.') + def sid_text(sid): + value=w.LPWSTR() + if not adv.ConvertSidToStringSidW(sid,ctypes.byref(value)): raise ValueError('Invalid report ACL.') + try:return value.value + finally:kernel.LocalFree(ctypes.cast(value,ptr)) + try: + control=w.WORD();revision=w.DWORD() + if not adv.GetSecurityDescriptorControl(descriptor,ctypes.byref(control),ctypes.byref(revision)) or (protected and not(control.value & 0x1000)) or not acl.value: + raise ValueError('Report directory requires a protected DACL.') + permitted={sid_text(owner),'S-1-5-18','S-1-5-32-544'} + # ACL header: revision byte, reserved byte, size WORD, ACE count WORD. + count=ctypes.c_ushort.from_address(acl.value+4).value + if count==0: raise ValueError('Report directory has no usable access grants.') + for index in range(count): + ace=ptr() + if not adv.GetAce(acl,index,ctypes.byref(ace)): raise ValueError('Cannot verify report ACL.') + kind=ctypes.c_ubyte.from_address(ace.value).value + if kind!=0 or sid_text(ptr(ace.value+8)) not in permitted: + raise ValueError('Report directory grants access outside owner, SYSTEM and Administrators.') + finally:kernel.LocalFree(descriptor) + + +def report_directory(): + configured=Path(load_privacy_policy()['report_directory']) + root=require_local_path(configured if configured.is_absolute() else PROJECT_ROOT/configured) + if os.name=='nt': + if not root.is_dir(): + raise ValueError('An administrator must provision the approved private report directory first.') + _verify_windows_acl(root) + else: + root.mkdir(mode=0o700,exist_ok=True) + info=root.stat() + if info.st_uid!=os.getuid() or stat.S_IMODE(info.st_mode)&0o077: + raise ValueError('Report directory must be owned by the current user with mode 0700.') + return root + + +def write_private(path, payload): + destination=require_local_path(path) + root=report_directory() + if destination.parent!=root or destination.suffix.lower() not in ('.json','.html'): + raise ValueError('Save reports directly inside the configured private report directory.') + flags=os.O_WRONLY|os.O_CREAT|os.O_EXCL|getattr(os,'O_NOFOLLOW',0) + if os.name=='nt': + # Child inherits the previously verified restricted directory ACL. + fd=os.open(destination,flags|getattr(os,'O_BINARY',0),0o600) + else: + directory_fd=os.open(root,os.O_RDONLY|os.O_DIRECTORY|os.O_NOFOLLOW) + try: + info=os.fstat(directory_fd) + if info.st_uid!=os.getuid() or stat.S_IMODE(info.st_mode)&0o077: + raise ValueError('Report directory permissions changed.') + fd=os.open(destination.name,flags,0o600,dir_fd=directory_fd) + finally:os.close(directory_fd) + with os.fdopen(fd,'wb') as output: + if os.name=='nt': + _verify_windows_acl(destination, protected=False) + output.write(payload if isinstance(payload,bytes) else payload.encode('utf-8')) + return destination diff --git a/samsara/models/inventory.py b/samsara/models/inventory.py index 6202660..7cf7b6f 100644 --- a/samsara/models/inventory.py +++ b/samsara/models/inventory.py @@ -13,6 +13,18 @@ def inventory_schema(): def validate_inventory(data): + # Validate depth and timestamps without optional third-party format plugins. + from samsara.core.privacy import safe_time + def visit(value, depth=0): + if depth>20: raise ValueError('Inventory nesting exceeds its limit.') + if isinstance(value,dict): + for key,item in value.items(): + if key in ('collected_at','started_at','completed_at','activity_observed_at') and safe_time(item) is None: + raise ValueError('Inventory timestamp is invalid.') + visit(item,depth+1) + elif isinstance(value,list): + for item in value: visit(item,depth+1) + visit(data) validator = Draft202012Validator(inventory_schema(), format_checker=FormatChecker()) if not validator.is_valid(data): raise ValueError('Inventory does not match the v1 schema; required fields, types or check statuses are invalid.') @@ -32,10 +44,6 @@ SERVER_FIELDS = {'hostname', 'platform', 'operating_system', 'os_version', 'os_b def restrict_inventory_fields(data, local_source=None): - """Keep only metadata fields; never serialize raw CIM/task/service objects.""" - data["server"] = {key: value for key, value in data["server"].items() if key in SERVER_FIELDS} - if local_source: - data["server"]["collection_source"] = local_source - data["inventory"] = {section: [{key: value for key, value in row.items() if key in ALLOWED[section]} - for row in rows] for section, rows in data["inventory"].items()} - return data + """Compatibility entry point: all callers now receive the privacy projection.""" + from samsara.core.privacy import minimize_inventory + return minimize_inventory(data, trusted_source=local_source) diff --git a/samsara/reporting/export.py b/samsara/reporting/export.py index 19f70d9..8bbf881 100644 --- a/samsara/reporting/export.py +++ b/samsara/reporting/export.py @@ -1,9 +1,11 @@ -"""Actionable assessment report, with full inventory preserved as a separate JSON artifact.""" +"""Actionable reports from privacy-minimized evidence, without raw sidecars.""" import hashlib import html import json from pathlib import Path -from urllib.parse import quote +from samsara.core.privacy import minimize_inventory +from samsara.core.storage import write_private +from samsara.analyzers.assessment import assess from samsara.analyzers.summary import summarize MANUAL_STEPS = ['Confirm application and business owners and obtain administrator retirement approval.', @@ -12,21 +14,30 @@ MANUAL_STEPS = ['Confirm application and business owners and obtain administrato 'Verify custom tasks and share consumers; resolve incomplete checks and confirm rollback/retention requirements.'] +def private_assessment(data, assessment): + # Never trust a marker or cached finding to authorize raw data disclosure. + source=data.get('server',{}).get('collection_source') + clean=minimize_inventory(data, trusted_source=source) + policy=assessment.get('scoring',{}).get('policy') if isinstance(assessment,dict) else None + if policy is not None: + import copy + policy=copy.deepcopy(policy) + policy['name']='Deployment scoring policy' + return clean, assess(clean, policy=policy) + + def export_json(path, data, assessment): - Path(path).write_text(json.dumps({'inventory_result':data,'assessment':assessment},indent=2),encoding='utf-8') + data, assessment=private_assessment(data, assessment) + write_private(path,json.dumps({'inventory_result':data,'assessment':assessment},indent=2)) def inventory_artifact(path, data): - payload = (json.dumps(data,indent=2,ensure_ascii=True)+'\n').encode('utf-8') - digest = hashlib.sha256(payload).hexdigest() - destination = Path(path).with_name(Path(path).stem + '.inventory-' + digest[:12] + '.json') - try: - with destination.open('xb') as output: - output.write(payload) - except FileExistsError: - if destination.read_bytes() != payload: - raise FileExistsError('Existing inventory artifact differs; choose a new report filename.') from None - return destination, digest + """Explicit minimized inventory export; never writes a raw sidecar.""" + data=minimize_inventory(data) + payload=(json.dumps(data,indent=2,ensure_ascii=True)+'\n').encode('utf-8') + destination=Path(path).with_suffix('.inventory.json') + write_private(destination,payload) + return destination, hashlib.sha256(payload).hexdigest() def table(headers, rows): @@ -38,7 +49,7 @@ def table(headers, rows): def export_html(path, data, assessment): escape = lambda value: html.escape(str(value)) - artifact, digest = inventory_artifact(path,data) + data, assessment = private_assessment(data, assessment) source = 'SYNTHETIC DEMO — no real collection performed.' if data['synthetic'] else 'IMPORTED INVENTORY — source assertions not independently verified.' for platform in ('Linux','Windows'): if not data['synthetic'] and data['server'].get('collection_source') == f'Local {platform} read-only collection': @@ -78,9 +89,7 @@ def export_html(path, data, assessment): checks.append((name,row['status'],row.get('started_at','Not supplied'),row.get('completed_at','Not supplied'),row['message'],errors,'; '.join(row.get('permission_limitations',[])),'; '.join(row.get('limitations',[])))) content += table(['Check','Status','started_at (UTC)','completed_at (UTC)','Result','Errors','permission_limitations','Scope limitations'],checks) content += '

Recommended manual verification

    '+''.join('
  • '+escape(step)+'
  • ' for step in MANUAL_STEPS)+'
' - content += '

Complete raw inventory

Every collected inventory row is preserved separately, including routine services and administrative shares. The analyzer has not changed the collected evidence.

' - content += '

Download / open complete inventory JSON

Inventory SHA-256: '+digest+'

' + content += '

Privacy and omitted evidence

Identifying names, accounts, task commands, certificate identities and unapproved paths are omitted. No raw inventory sidecar is generated. JSON export contains the same minimized evidence. Numeric IP addresses remain confidential infrastructure metadata. This report does not establish PHI-free operation or HIPAA compliance.

' content += table(['Inventory section','Rows'],[(name,len(rows)) for name,rows in data['inventory'].items()]) - content += '

JSON assessment export also preserves all raw inventory, classifications, evidence references and policy. Keep the inventory file alongside this HTML when sharing or archiving.

' css = 'body{font:15px system-ui;max-width:1180px;margin:32px auto;padding:20px;color:#233044;line-height:1.55}h1,h2{color:#173c60}.muted{color:#667}.source,.card,details{background:#eef3f8;padding:16px;border-radius:7px}.table{overflow-x:auto}table{border-collapse:collapse;width:100%;margin:12px 0}th,td{border:1px solid #d9e0e8;text-align:left;vertical-align:top;padding:9px;white-space:pre-wrap;overflow-wrap:anywhere}th{background:#eef3f8}pre{white-space:pre-wrap;overflow-wrap:anywhere}a{color:#1263a3}' - Path(path).write_text('Samsara'+content+'',encoding='utf-8') + write_private(path,'Samsara'+content+'') diff --git a/samsara/ui/window.py b/samsara/ui/window.py index 11c7cb1..b224f91 100644 --- a/samsara/ui/window.py +++ b/samsara/ui/window.py @@ -1,3 +1,6 @@ +from samsara.core.privacy import minimize_inventory, load_privacy_policy +from samsara.core.storage import report_directory +from uuid import uuid4 from PySide6.QtCore import Qt, QThread, Signal, QSortFilterProxyModel from PySide6.QtGui import QStandardItem, QStandardItemModel from PySide6.QtWidgets import (QMainWindow, QWidget, QHBoxLayout, QVBoxLayout, QLabel, @@ -30,7 +33,7 @@ class CollectionJob(QThread): if self.source == 'local': self.stage.emit('Detecting operating system…') detected = detect_local_os() - self.stage.emit('Detected ' + detected['name'] + '. Collecting inventory…') + self.stage.emit('Operating system detected. Collecting minimized inventory…') if detected['platform'] == 'windows': data = collect_local_windows(profile=self.profile, stage=self.stage.emit, cancelled=self.isInterruptionRequested) else: @@ -45,6 +48,7 @@ class CollectionJob(QThread): if self.isInterruptionRequested(): raise CollectionCancelled() self.stage.emit('Preparing assessment report…') + data = minimize_inventory(data, trusted_source=data.get('server',{}).get('collection_source')) self.result.emit(data) except CollectionCancelled: self.failed.emit('Cancelled. Your previous assessment has been kept.') @@ -298,6 +302,9 @@ class MainWindow(QMainWindow): self.history_list.itemActivated.connect(self.open_history) self.history_list.itemClicked.connect(self.open_history) history.addWidget(self.history_list) + self.clear_history_button = QPushButton('Clear session assessments') + self.clear_history_button.clicked.connect(self.clear_history) + history.addWidget(self.clear_history_button) self.history_empty = self.label('No assessments yet.', muted=True) history.addWidget(self.history_empty) reports = self.page('Reports', 'Save the current assessment, including evidence and missing checks.') @@ -315,6 +322,7 @@ class MainWindow(QMainWindow): settings.addWidget(self.label('Windows Server 2016 / 2019 / 2022 / 2025\nRed Hat Enterprise Linux 7 (legacy) / 8 / 9 / 10 and compatible distributions')) settings.addWidget(self.label('Local Windows Server and Linux collectors are available. Windows validation on a supported host is pending. Remote collection is a separate follow-up. Saved inventories and samples work on either desktop.', muted=True)) settings.addWidget(self.label('Read-only collection • No credentials stored • Session history only', muted=True)) + settings.addWidget(self.label('Privacy defaults: identifying names and unapproved paths omitted. Task commands, certificate identities and SMB open-file details are not collected. Exports use a configured private local folder. Review docs/PRIVACY_HARDENING.md before real assessments.', muted=True)) settings.addWidget(self.label('Retirement scoring policy')) self.policy_label = self.label('Default policy · ' + policy_id(self.scoring_policy)[:12], muted=True) settings.addWidget(self.policy_label) @@ -376,6 +384,7 @@ class MainWindow(QMainWindow): self.job.start() def set_busy(self, busy): + self.clear_history_button.setEnabled(not busy) self.policy_button.setEnabled(not busy) self.reset_policy_button.setEnabled(not busy) self.analyze_button.setEnabled(not busy) @@ -399,11 +408,20 @@ class MainWindow(QMainWindow): return data['server'].get('collection_source', 'Imported inventory — source not independently verified') def collection_done(self, data): + try: + data = minimize_inventory(data, trusted_source=data.get('server',{}).get('collection_source')) + except ValueError: + self.status.setText('Privacy validation failed; assessment withheld.') + return assessment = assess(data, policy=self.active_policy) self.history.append({'hostname': data['server']['hostname'], 'risk': assessment['level'], 'source': self.source_label(data), 'collected_at': data['collected_at']}) self.records.append((data, assessment)) - self.history_list.addItem(f"{data['server']['hostname']} • {assessment['level'].title()} risk • {self.source_label(data)}") + while len(self.records)>load_privacy_policy()['max_session_assessments']: + self.records.pop(0) + self.history.pop(0) + self.history_list.takeItem(0) + self.history_list.addItem(f"{data['collected_at']} • {data['server']['hostname']} • {assessment['level'].title()} risk") self.history_empty.hide() self.display_assessment(data, assessment) self.nav.setCurrentRow(0) @@ -428,7 +446,7 @@ class MainWindow(QMainWindow): self.scheduled_jobs.setText('\n'.join(f"• {row['name']} — {row['schedule']}" for row in jobs[:5]) + (f"\n+ {len(jobs) - 5} more; see Tasks." if len(jobs) > 5 else '') + '\nSchedules indicate configured work; execution is not confirmed.' if jobs else - 'No nonroutine enabled schedules identified. Full schedules and missing scheduler coverage are in Tasks and Collection checks.') + 'No nonroutine enabled schedules identified. Schedule detail is omitted; review Tasks and Collection checks before concluding no work exists.') self.quick_save.setEnabled(True) self.result_tabs.setCurrentIndex(0) # Discard tables from the previous assessment. Populate detailed tabs on demand. @@ -443,6 +461,26 @@ class MainWindow(QMainWindow): for button in self.report_buttons: button.setEnabled(True) + def clear_history(self): + self.history.clear() + self.records.clear() + self.history_list.clear() + self.history_empty.show() + self.data = self.assessment = None + self.result_title.setText('No assessment loaded') + self.banner.clear() + for label in (self.retirement_score,self.coverage_note,self.os_type,self.os_version,self.important_software,self.scheduled_jobs): + label.clear() + for area in self.detail_areas.values(): + while area.count(): + item=area.takeAt(0) + if item.widget(): item.widget().deleteLater() + area.addWidget(self.label('No assessment loaded.',muted=True)) + self.report_label.setText('Analyze a computer or open an inventory first.') + for button in self.report_buttons: button.setEnabled(False) + self.quick_save.setEnabled(False) + self.status.setText('Session assessment references cleared. This is not secure memory erasure; exported files remain.') + def open_history(self, item): index = self.history_list.row(item) if 0 <= index < len(self.records): @@ -488,16 +526,21 @@ class MainWindow(QMainWindow): def save_report(self, kind): if self.data is None: return - path, _ = QFileDialog.getSaveFileName(self, 'Save assessment', f'samsara-assessment.{kind}', f'{kind.upper()} (*.{kind})') + try: + suggested = str(report_directory() / f'samsara-{uuid4().hex[:12]}.{kind}') + except (OSError, ValueError): + QMessageBox.warning(self, 'Save blocked', 'Provision the private report directory described in docs/PRIVACY_HARDENING.md.') + return + path, _ = QFileDialog.getSaveFileName(self, 'Save minimized assessment', suggested, f'{kind.upper()} (*.{kind})') if not path: return if not path.lower().endswith('.' + kind): path += '.' + kind try: (export_html if kind == 'html' else export_json)(path, self.data, self.assessment) - QMessageBox.information(self, 'Report saved', 'HTML report and separate full inventory JSON saved locally.' if kind == 'html' else 'Assessment and full inventory saved as JSON.') - except OSError: - QMessageBox.warning(self, 'Save failed', 'Unable to write the report. Check the destination and file permissions.') + QMessageBox.information(self, 'Report saved', 'Minimized assessment saved. No raw inventory sidecar was created.') + except (OSError, ValueError): + QMessageBox.warning(self, 'Save failed', 'Use a new filename directly in the approved private report directory. Check its permissions.') def closeEvent(self, event): if self.job is not None and self.job.isRunning(): diff --git a/schemas/inventory-v1.schema.json b/schemas/inventory-v1.schema.json index 77b974d..b848307 100644 --- a/schemas/inventory-v1.schema.json +++ b/schemas/inventory-v1.schema.json @@ -20,7 +20,8 @@ }, "collected_at": { "type": "string", - "format": "date-time" + "format": "date-time", + "maxLength": 8192 }, "server": { "type": "object", @@ -29,7 +30,8 @@ ], "properties": { "hostname": { - "type": "string" + "type": "string", + "maxLength": 8192 }, "platform": { "enum": [ @@ -38,22 +40,28 @@ ] }, "operating_system": { - "type": "string" + "type": "string", + "maxLength": 8192 }, "os_version": { - "type": "string" + "type": "string", + "maxLength": 8192 }, "os_build": { - "type": "string" + "type": "string", + "maxLength": 8192 }, "kernel_version": { - "type": "string" + "type": "string", + "maxLength": 8192 }, "collection_source": { - "type": "string" + "type": "string", + "maxLength": 8192 }, "domain": { - "type": "string" + "type": "string", + "maxLength": 8192 }, "product_type": { "type": "integer" @@ -67,16 +75,25 @@ "ip_addresses": { "type": "array", "items": { - "type": "string" - } + "type": "string", + "maxLength": 8192 + }, + "maxItems": 20000 }, "network_adapters": { "type": "array", "items": { - "type": "string" - } + "type": "string", + "maxLength": 8192 + }, + "maxItems": 20000 + }, + "last_boot": { + "type": "string", + "maxLength": 8192 } - } + }, + "additionalProperties": false }, "checks": { "type": "object", @@ -96,15 +113,18 @@ ] }, "message": { - "type": "string" + "type": "string", + "maxLength": 8192 }, "started_at": { "type": "string", - "format": "date-time" + "format": "date-time", + "maxLength": 8192 }, "completed_at": { "type": "string", - "format": "date-time" + "format": "date-time", + "maxLength": 8192 }, "errors": { "type": "array", @@ -118,30 +138,39 @@ "additionalProperties": false, "properties": { "code": { - "type": "string" + "type": "string", + "maxLength": 8192 }, "scope": { - "type": "string" + "type": "string", + "maxLength": 8192 }, "message": { - "type": "string" + "type": "string", + "maxLength": 8192 } } - } + }, + "maxItems": 20000 }, "permission_limitations": { "type": "array", "items": { - "type": "string" - } + "type": "string", + "maxLength": 8192 + }, + "maxItems": 20000 }, "limitations": { "type": "array", "items": { - "type": "string" - } + "type": "string", + "maxLength": 8192 + }, + "maxItems": 20000 } - } + }, + "additionalProperties": false }, "required": [ "roles", @@ -151,7 +180,8 @@ "software", "storage", "certificates" - ] + ], + "maxProperties": 30 }, "inventory": { "type": "object", @@ -174,19 +204,25 @@ ], "properties": { "name": { - "type": "string" + "type": "string", + "maxLength": 8192 }, "display_name": { - "type": "string" + "type": "string", + "maxLength": 8192 }, "state": { - "type": "string" + "type": "string", + "maxLength": 8192 }, "feature_type": { - "type": "string" + "type": "string", + "maxLength": 8192 } - } - } + }, + "additionalProperties": false + }, + "maxItems": 20000 }, "services": { "type": "array", @@ -198,25 +234,44 @@ ], "properties": { "name": { - "type": "string" + "type": "string", + "maxLength": 8192 }, "state": { - "type": "string" + "type": "string", + "maxLength": 8192 }, "display_name": { - "type": "string" + "type": "string", + "maxLength": 8192 }, "startup": { - "type": "string" + "type": "string", + "maxLength": 8192 }, "account": { - "type": "string" + "type": "string", + "maxLength": 8192 }, "pid": { "type": "integer" + }, + "arguments": { + "type": "string", + "maxLength": 8192 + }, + "executable": { + "type": "string", + "maxLength": 8192 + }, + "PathName": { + "type": "string", + "maxLength": 8192 } - } - } + }, + "additionalProperties": false + }, + "maxItems": 20000 }, "network": { "type": "array", @@ -231,22 +286,52 @@ ], "properties": { "state": { - "type": "string" + "type": "string", + "maxLength": 8192 }, "local_address": { - "type": "string" + "type": "string", + "maxLength": 8192 }, "local_port": { "type": "integer" }, "remote_address": { - "type": "string" + "type": "string", + "maxLength": 8192 }, "remote_port": { "type": "integer" + }, + "pid": { + "type": [ + "integer", + "null" + ], + "minimum": 0 + }, + "process": { + "type": "string", + "maxLength": 8192 + }, + "direction": { + "type": "string", + "maxLength": 8192 + }, + "remote_scope": { + "enum": [ + "", + "loopback", + "host", + "unspecified", + "private", + "public" + ] } - } - } + }, + "additionalProperties": false + }, + "maxItems": 20000 }, "tasks": { "type": "array", @@ -258,31 +343,52 @@ ], "properties": { "name": { - "type": "string" + "type": "string", + "maxLength": 8192 }, "enabled": { "type": "boolean" }, "path": { - "type": "string" + "type": "string", + "maxLength": 8192 }, "account": { - "type": "string" + "type": "string", + "maxLength": 8192 }, "triggers": { - "type": "string" + "type": "string", + "maxLength": 8192 }, "state": { - "type": "string" + "type": "string", + "maxLength": 8192 }, "executables": { - "type": "string" + "type": "string", + "maxLength": 8192 }, "associated_unit": { - "type": "string" + "type": "string", + "maxLength": 8192 + }, + "arguments": { + "type": "string", + "maxLength": 8192 + }, + "executable": { + "type": "string", + "maxLength": 8192 + }, + "PathName": { + "type": "string", + "maxLength": 8192 } - } - } + }, + "additionalProperties": false + }, + "maxItems": 20000 }, "software": { "type": "array", @@ -293,22 +399,29 @@ ], "properties": { "name": { - "type": "string" + "type": "string", + "maxLength": 8192 }, "publisher": { - "type": "string" + "type": "string", + "maxLength": 8192 }, "version": { - "type": "string" + "type": "string", + "maxLength": 8192 }, "installation_date": { - "type": "string" + "type": "string", + "maxLength": 8192 }, "registry_view": { - "type": "string" + "type": "string", + "maxLength": 8192 } - } - } + }, + "additionalProperties": false + }, + "maxItems": 20000 }, "storage": { "type": "array", @@ -321,16 +434,20 @@ ], "properties": { "type": { - "type": "string" + "type": "string", + "maxLength": 8192 }, "name": { - "type": "string" + "type": "string", + "maxLength": 8192 }, "path": { - "type": "string" + "type": "string", + "maxLength": 8192 }, "filesystem": { - "type": "string" + "type": "string", + "maxLength": 8192 }, "special": { "type": "boolean" @@ -356,14 +473,18 @@ "minimum": 0 }, "activity_source": { - "type": "string" + "type": "string", + "maxLength": 8192 }, "activity_observed_at": { "type": "string", - "format": "date-time" + "format": "date-time", + "maxLength": 8192 } - } - } + }, + "additionalProperties": false + }, + "maxItems": 20000 }, "certificates": { "type": "array", @@ -374,25 +495,57 @@ ], "properties": { "subject": { - "type": "string" + "type": "string", + "maxLength": 8192 }, "issuer": { - "type": "string" + "type": "string", + "maxLength": 8192 }, "expires": { - "type": "string" + "type": "string", + "maxLength": 8192 }, "thumbprint": { - "type": "string" + "type": "string", + "maxLength": 8192 }, "store": { - "type": "string" + "type": "string", + "maxLength": 8192 } - } - } + }, + "additionalProperties": false + }, + "maxItems": 20000 } }, "additionalProperties": false + }, + "privacy": { + "type": "object", + "additionalProperties": false, + "required": [ + "profile", + "identities_omitted", + "paths", + "scope" + ], + "properties": { + "profile": { + "const": "minimized-metadata-v1" + }, + "identities_omitted": { + "const": true + }, + "paths": { + "const": "approved-roots-only" + }, + "scope": { + "type": "string", + "maxLength": 8192 + } + } } } } diff --git a/tests/powershell/Test-Collectors.ps1 b/tests/powershell/Test-Collectors.ps1 index 598dbc3..8b50313 100644 --- a/tests/powershell/Test-Collectors.ps1 +++ b/tests/powershell/Test-Collectors.ps1 @@ -12,37 +12,25 @@ foreach ($file in @($files | Where-Object { $_.Name -ne 'Get-ServerInventory.ps1 $script:SamsaraIsElevated = $true $script:SyntheticBuild = '20348' function Get-CimInstance { - [CmdletBinding()] param($ClassName, $Property, $Filter) + [CmdletBinding()] param($ClassName, $Property, $Filter, $Namespace) switch ($ClassName) { 'Win32_OperatingSystem' { [pscustomobject]@{ Caption='Synthetic Windows Server'; Version=('10.0.' + $script:SyntheticBuild); BuildNumber=$script:SyntheticBuild; ProductType=3; LastBootUpTime=[DateTime]::UtcNow.AddDays(-2) } } 'Win32_ComputerSystem' { [pscustomobject]@{ Domain='example.test'; PartOfDomain=$true } } 'Win32_NetworkAdapterConfiguration' { [pscustomobject]@{ Description='Synthetic NIC'; IPAddress=@('192.0.2.20') } } 'Win32_Service' { [pscustomobject]@{ Name='W3SVC'; DisplayName='Synthetic Web'; State='Running'; StartMode='Auto'; StartName='EXAMPLE\svc_web'; ProcessId=123; PathName='SECRET command arguments' } } + 'MSFT_ScheduledTask' { [pscustomobject]@{ TaskName='SECRET'; TaskPath='\Custom\SECRET\'; State=3 } } + 'Win32_LogicalDisk' { [pscustomobject]@{ DeviceID='C:'; Size=100GB; FreeSpace=50GB; FileSystem='NTFS' } } default { throw 'Unexpected synthetic CIM query' } } } function Get-WindowsFeature { [CmdletBinding()] param() [pscustomobject]@{ Name='Web-Server'; DisplayName='Synthetic Web Server'; Installed=$true; InstallState='Installed'; FeatureType='Role' } } function Get-Process { [CmdletBinding()] param() [pscustomobject]@{ Id=123; ProcessName='SyntheticWorker'; CommandLine='SECRET' } } function Get-NetTCPConnection { [CmdletBinding()] param() [pscustomobject]@{ State='Established'; LocalAddress='192.0.2.20'; LocalPort=443; RemoteAddress='192.0.2.21'; RemotePort=50000; OwningProcess=123 } } -function Get-ScheduledTask { - [CmdletBinding()] param() - [pscustomobject]@{ TaskName='Synthetic report'; TaskPath='\Example\'; State='Ready' - Settings=[pscustomobject]@{ Enabled=$true }; Principal=[pscustomobject]@{ UserId='EXAMPLE\svc_report' } - Actions=@([pscustomobject]@{ Execute='C:\Tools\report.exe'; Arguments='--password=SECRET' }) - Triggers=@([pscustomobject]@{ CimClass=[pscustomobject]@{ CimClassName='MSFT_TaskDailyTrigger' }; StartBoundary='2026-10-01T02:00:00'; Enabled=$true; DaysInterval=1; Repetition=$null }) } -} -function Get-Volume { [CmdletBinding()] param() [pscustomobject]@{ FileSystemLabel='Synthetic OS'; DriveLetter='C'; Path='C:\'; FileSystem='NTFS'; Size=100GB; SizeRemaining=50GB } } -function Get-SmbShare { [CmdletBinding()] param() [pscustomobject]@{ Name='SyntheticReports'; Path='C:\Reports'; Special=$false } } -function Get-SmbOpenFile { - [CmdletBinding()] param() - [pscustomobject]@{ Path='C:\Reports\SECRET-file.txt'; ShareRelativePath='SECRET-file.txt'; ClientComputerName='192.0.2.21'; ClientUserName='SECRET' } - [pscustomobject]@{ Path='C:\Reports\SECRET-admin.txt'; ShareRelativePath='Reports\SECRET-admin.txt'; ClientComputerName='192.0.2.21'; ClientUserName='SECRET' } -} -function Get-ChildItem { - [CmdletBinding()] param($LiteralPath) - if ($LiteralPath -eq 'Cert:\LocalMachine') { [pscustomobject]@{ PSPath='Cert:\LocalMachine\My'; PSChildName='My' } } - else { [pscustomobject]@{ Subject='CN=synthetic.example.test'; Issuer='CN=Synthetic CA'; NotAfter=[DateTime]::UtcNow.AddDays(30); Thumbprint='0000000000000000000000000000000000000000'; PrivateKey='SECRET' } } -} +function Get-ScheduledTask { throw 'Forbidden task object enumeration' } +function Get-Volume { throw 'Forbidden volume label enumeration' } +function Get-SmbShare { [CmdletBinding()] param() [pscustomobject]@{ Name='SECRET'; Path='C:\SECRET'; Special=$false } } +function Get-SmbOpenFile { throw 'Forbidden open-file enumeration' } +function Get-ChildItem { throw 'Forbidden certificate enumeration' } function Assert-True { param($Value, [string]$Message) if (-not $Value) { throw $Message } } $collectors = [ordered]@{ system={param($r) Get-SamsaraSystem $r}; roles={param($r) Get-SamsaraRoles $r} @@ -52,12 +40,13 @@ $collectors = [ordered]@{ } foreach ($name in $collectors.Keys) { $section = Invoke-SamsaraSection $name $collectors[$name] - Assert-True ($section.status -eq 'complete') ('Synthetic section failed: ' + $name) + $expected = if ($name -eq 'certificates') { 'not_collected' } elseif ($name -in @('tasks','storage','shares')) { 'partial' } else { 'complete' } + Assert-True ($section.status -eq $expected) ('Synthetic section failed: ' + $name) Assert-True ($section.started_at -and $section.completed_at) 'Missing timestamps' Assert-True (($section | ConvertTo-Json -Depth 12) -notmatch 'SECRET') 'Secret field serialized' - if ($name -eq 'network') { Assert-True ($section.data[0].process -eq 'SyntheticWorker') 'Process correlation failed' } - if ($name -eq 'shares') { Assert-True ($section.data[0].observed_open_files -eq 1) 'Share activity correlation missing' } - if ($name -eq 'tasks') { Assert-True ($section.data[0].executables -eq 'report.exe') 'Executable basename missing' } + if ($name -eq 'network') { Assert-True ($section.data[0].process -eq 'processes-123') 'Process correlation failed' } + if ($name -eq 'shares') { Assert-True ($null -eq $section.data[0].observed_open_files) 'Unexpected open-file evidence' } + if ($name -eq 'tasks') { Assert-True ($null -eq $section.data[0].executables) 'Task action leaked' } } foreach ($build in @('14393','17763','20348','26100')) { $script:SyntheticBuild=$build @@ -66,13 +55,13 @@ foreach ($build in @('14393','17763','20348','26100')) { } # Test registry projection with in-memory objects; never open a real registry hive. $fakeEntry = New-Object PSObject -$fakeEntry | Add-Member ScriptMethod GetValue { param($key) switch ($key) { 'DisplayName' {'Synthetic application'}; 'DisplayVersion' {'1.0'}; 'Publisher' {'Example'}; 'InstallDate' {'20260101'}; default {throw 'Unexpected registry value requested'} } } +$fakeEntry | Add-Member ScriptMethod GetValue { param($key,$default,$options) switch ($key) { 'DisplayName' {'Synthetic application'}; 'DisplayVersion' {'1.0'}; 'Publisher' {'Example'}; 'InstallDate' {'20260101'}; default {throw 'Unexpected registry value requested'} } } $fakeEntry | Add-Member ScriptMethod Dispose { } $fakeUninstall = New-Object PSObject $fakeUninstall | Add-Member ScriptMethod GetSubKeyNames { @('Example') } $fakeUninstall | Add-Member ScriptMethod OpenSubKey { param($name,$writable) if ($writable) {throw 'Writable registry access'}; return $fakeEntry } $section=Invoke-SamsaraSection 'software' {param($r) Invoke-SamsaraRead $r 'Synthetic registry view' { Read-SamsaraSoftwareEntries $r $fakeUninstall 'Registry64' } } -Assert-True ($section.status -eq 'complete' -and $section.data[0].name -eq 'Synthetic application') 'Registry metadata projection failed' +Assert-True ($section.status -eq 'complete' -and $section.data[0].name -eq 'products-1') 'Registry metadata projection failed' function Get-SmbShare { [CmdletBinding()] param() throw [UnauthorizedAccessException]::new('SECRET protected details') } $section=Invoke-SamsaraSection 'shares' {param($r) Get-SamsaraShares $r} Assert-True ($section.status -eq 'partial' -and $section.data.Count -eq 0) 'Denied share enumeration was not partial/empty' @@ -82,63 +71,3 @@ $script:SamsaraIsElevated=$false $section=Invoke-SamsaraSection 'services' {param($r) Get-SamsaraServices $r} Assert-True ($section.status -eq 'partial' -and $section.permission_limitations.Count -gt 0) 'Non-elevated scope not marked partial' Write-Output 'PowerShell AST and synthetic collector tests passed. No real Windows inventory was collected.' -# Reliability cases: mocks never access real tasks, certificates or private keys. -$script:SamsaraIsElevated=$true -$script:SamsaraDiagnostic=$true -function New-TestTask { - param([bool]$Enabled=$true) - [pscustomobject]@{TaskName='Example';TaskPath='\';State='Ready';Settings=[pscustomobject]@{Enabled=$Enabled};Principal=[pscustomobject]@{UserId='SYSTEM'};Actions=@([pscustomobject]@{Execute='"C:\Tools\bad.exe"';Arguments='SECRET'});Triggers=@()} -} -function Get-ScheduledTask { [CmdletBinding()]param() New-TestTask $true; New-TestTask $false } -$r=Invoke-SamsaraSection tasks {param($r) Get-SamsaraTasks $r} -Assert-True ($r.status -eq 'complete' -and $r.data.Count -eq 2 -and -not $r.data[1].enabled) 'Normal/disabled tasks lost' -Assert-True ($r.data[0].executables -eq '[executable name omitted]') 'Malformed executable not sanitized' -function Get-ScheduledTask { [CmdletBinding()]param() } -$r=Invoke-SamsaraSection tasks {param($r) Get-SamsaraTasks $r} -Assert-True ($r.status -eq 'complete' -and $r.data.Count -eq 0) 'Successful empty task query failed' -function Get-ScheduledTask { [CmdletBinding()]param() Write-Error 'SECRET' -Category PermissionDenied } -$r=Invoke-SamsaraSection tasks {param($r) Get-SamsaraTasks $r} -Assert-True ($r.status -eq 'failed' -and $r.data.Count -eq 0) 'Failed task query treated as empty success' -function Get-ScheduledTask { - [CmdletBinding()]param() - New-TestTask - $bad=New-TestTask - $bad | Add-Member ScriptProperty TaskName { throw [UnauthorizedAccessException]::new('SECRET') } -Force - # Throw from ToString to reliably exercise projection failures on both runtimes. - $value=New-Object PSObject - $value | Add-Member ScriptMethod ToString { throw [UnauthorizedAccessException]::new('SECRET') } -Force - $bad.TaskPath=$value - $bad -} -$r=Invoke-SamsaraSection tasks {param($r) Get-SamsaraTasks $r} -Assert-True ($r.status -eq 'partial' -and $r.data.Count -eq 1 -and $r.errors.Count -gt 0) 'Individual task failure discarded healthy records' -Assert-True (($r | ConvertTo-Json -Depth 12) -notmatch 'SECRET') 'Diagnostic leaked secret' -$script:StoreMode='normal' -function Get-ChildItem { - [CmdletBinding()]param($LiteralPath) - if ($LiteralPath -eq 'Cert:\LocalMachine') { - foreach ($n in @('My','Root','Empty')) { [pscustomobject]@{PSChildName=$n;PSPath=('Microsoft.PowerShell.Security\Certificate::'+$n)} } - } elseif ($LiteralPath -eq 'Cert:\LocalMachine\Empty') { return } - elseif ($LiteralPath -in @('Cert:\LocalMachine\My','Cert:\LocalMachine\Root')) { - if ($script:StoreMode -eq 'denied' -or ($script:StoreMode -eq 'partial' -and $LiteralPath -like '*Root')) { throw [UnauthorizedAccessException]::new('SECRET') } - [pscustomobject]@{Subject='CN=Example';Issuer='CN=CA';Thumbprint='00';NotAfter=[datetime]::UtcNow;PrivateKey='SECRET'} - } else { throw 'Incorrect certificate provider path' } -} -$r=Invoke-SamsaraSection certificates {param($r) Get-SamsaraCertificates $r} -Assert-True ($r.status -eq 'complete' -and $r.data.Count -eq 2 -and @($r.data.store | Select-Object -Unique).Count -eq 2) 'Multiple stores/empty store failed' -$script:StoreMode='partial' -$r=Invoke-SamsaraSection certificates {param($r) Get-SamsaraCertificates $r} -Assert-True ($r.status -eq 'partial' -and $r.data.Count -eq 1 -and $r.errors[0].code -eq 'access_denied') 'Partial store failure lost records' -Assert-True (($r | ConvertTo-Json -Depth 12) -notmatch 'SECRET') 'Certificate diagnostics leaked secret' -function Get-ChildItem { [CmdletBinding()]param($LiteralPath) if ($LiteralPath -eq 'Cert:\LocalMachine') { [pscustomobject]@{PSChildName='My'} } else { throw [UnauthorizedAccessException]::new('SECRET') } } -$r=Invoke-SamsaraSection certificates {param($r) Get-SamsaraCertificates $r} -Assert-True ($r.status -eq 'failed' -and $r.data.Count -eq 0) 'Inaccessible stores treated as empty success' -function Get-ChildItem { [CmdletBinding()]param($LiteralPath) } -$r=Invoke-SamsaraSection certificates {param($r) Get-SamsaraCertificates $r} -Assert-True ($r.status -eq 'complete' -and $r.data.Count -eq 0) 'Empty store list failed' -function Get-ScheduledTask { [CmdletBinding()]param() New-TestTask } -function ConvertTo-Json { [CmdletBinding()]param($InputObject,$Depth,[switch]$Compress) throw [InvalidOperationException]::new('SECRET serialization') } -$r=Invoke-SamsaraSection tasks {param($r) Get-SamsaraTasks $r} -Assert-True ($r.status -ne 'complete' -and $r.data.Count -eq 0 -and $r.errors.Count -eq 1) 'Serialization failure accepted as evidence' -Remove-Item Function:\ConvertTo-Json -Write-Output 'Reliability collector tests passed.' diff --git a/tests/report_helpers.py b/tests/report_helpers.py new file mode 100644 index 0000000..f45969a --- /dev/null +++ b/tests/report_helpers.py @@ -0,0 +1,14 @@ +"""Authorize a private temporary root for legacy report rendering tests only.""" +from pathlib import Path +from unittest.mock import patch +from samsara.reporting.export import export_html as _html, export_json as _json + + +def export_html(path, data, assessment): + with patch('samsara.core.storage._verify_windows_acl'), patch('samsara.core.storage.report_directory',return_value=Path(path).parent): + return _html(path,data,assessment) + + +def export_json(path, data, assessment): + with patch('samsara.core.storage._verify_windows_acl'), patch('samsara.core.storage.report_directory',return_value=Path(path).parent): + return _json(path,data,assessment) diff --git a/tests/test_core.py b/tests/test_core.py index c76246e..ce06e9a 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -5,7 +5,7 @@ import unittest from samsara.core.validation import validate_hostname from samsara.collectors.demo import collect_demo, DEMO_HOST from samsara.analyzers.assessment import assess -from samsara.reporting.export import export_html, export_json +from report_helpers import export_html, export_json class CoreTests(unittest.TestCase): def test_hostname(self): diff --git a/tests/test_import.py b/tests/test_import.py index 7cb7e95..045f94f 100644 --- a/tests/test_import.py +++ b/tests/test_import.py @@ -23,7 +23,7 @@ class ImportTests(unittest.TestCase): data = self.load(collect_demo(LINUX_DEMO_HOST)) self.assertEqual(data['server']['platform'], 'linux') result = assess(data) - self.assertTrue(any('/srv/reports' in item['evidence'] for item in result['findings'])) + self.assertTrue(any('Share-' in item['evidence'] for item in result['findings'])) self.assertTrue(any('certificates:' in item['evidence'] for item in result['findings'])) def test_report_wrapper_recomputed_and_sensitive_fields_removed(self): diff --git a/tests/test_linux.py b/tests/test_linux.py index c7e0d4a..ea49721 100644 --- a/tests/test_linux.py +++ b/tests/test_linux.py @@ -8,7 +8,7 @@ from samsara.collectors.linux import (collect_local_linux, parse_connections, parse_units, run_command, CollectionCancelled) from samsara.collectors.demo import collect_demo, LINUX_DEMO_HOST from samsara.analyzers.assessment import assess -from samsara.reporting.export import export_html +from report_helpers import export_html class LinuxTests(unittest.TestCase): @@ -83,7 +83,44 @@ class LinuxTests(unittest.TestCase): @unittest.skipUnless(__import__('sys').platform.startswith('linux'), 'Linux command test') def test_command_timeout_and_cancellation(self): - with self.assertRaisesRegex(RuntimeError, 'timed out'): + with self.assertRaisesRegex(RuntimeError, 'timeout'): run_command(['sleep', '1'], timeout=.05) with self.assertRaises(CollectionCancelled): run_command(['sleep', '1'], cancelled=lambda: True) + + +class RegressionReviewTests(unittest.TestCase): + def test_ss_states_map_to_canonical_names(self): + from samsara.collectors.linux import parse_connections + rows = parse_connections('TIME-WAIT 0 0 10.0.0.5:44321 10.0.0.9:443\nCLOSE-WAIT 0 0 10.0.0.5:1 10.0.0.9:2\nFIN-WAIT-1 0 0 10.0.0.5:3 10.0.0.9:4\n') + self.assertEqual([r['state'] for r in rows], ['TimeWait', 'CloseWait', 'FinWait1']) + + def test_non_ascii_digits_and_cpp_product_label(self): + import json + from pathlib import Path + from samsara.core.privacy import minimize_inventory, numeric_version, safe_time + self.assertEqual(numeric_version('١٢.٣٤'), 'Not collected') + self.assertIsNone(safe_time('٢٠٢٦-٠١-٠١T٠٠:٠٠:٠٠Z')) + data = json.loads(Path('samsara/assets/demo_inventory.json').read_text()) + data['server']['os_build'] = '١٢٣٤٥' + data['inventory']['software'] = [{'name': 'Microsoft Visual C++ 2015-2022 Redistributable (x64)', 'version': '14.38.33135'}] + clean = minimize_inventory(data) + self.assertNotEqual(clean['server']['os_build'], '١٢٣٤٥') + self.assertEqual(clean['inventory']['software'][0]['name'], 'Microsoft Visual C++') + + def test_remote_peer_addresses_are_masked_but_scoring_survives(self): + import copy + from samsara.core.privacy import minimize_inventory + from samsara.models.inventory import validate_inventory + data = json.loads(Path('tests/fixtures/scoring/active_sql_server.json').read_text()) + before = assess(minimize_inventory(copy.deepcopy(data)))['score'] + for row in data['inventory']['network']: + if row.get('remote_address') not in ('', '0.0.0.0', '::'): + row['remote_address'] = '93.184.216.34' + clean = minimize_inventory(data) + validate_inventory(clean) + self.assertNotIn('93.184.216.34', json.dumps(clean)) + self.assertEqual(minimize_inventory(clean), clean) + self.assertEqual(assess(clean)['score'], before) + self.assertNotIn('93.184.216.34', json.dumps(assess(clean))) + self.assertIn('public', {r['remote_scope'] for r in clean['inventory']['network']}) diff --git a/tests/test_privacy.py b/tests/test_privacy.py new file mode 100644 index 0000000..c36eac9 --- /dev/null +++ b/tests/test_privacy.py @@ -0,0 +1,130 @@ +"""Synthetic disclosure regressions. No real inventory or patient data is used.""" +import copy +import json +import os +from pathlib import Path +import stat +import sys +import tempfile +import unittest +from unittest.mock import patch + +from samsara.core.privacy import minimize_inventory, load_privacy_policy, canonical_share_root +from samsara.core.storage import write_private, require_local_path +from samsara.core.process import capture, ProcessFailure, CollectionCancelled +from samsara.models.inventory import validate_inventory +from samsara.reporting.export import export_html, export_json +from samsara.analyzers.assessment import assess + +CANARY='SYNTHETIC_PERSON_CANARY_9f4' + + +def fixture(name='active_sql_server'): + return json.loads(Path('tests/fixtures/scoring',name+'.json').read_text()) + + +class PrivacyTests(unittest.TestCase): + def test_all_free_text_and_stale_findings_are_removed(self): + raw=fixture();original=copy.deepcopy(raw) + raw['server'].update(hostname=CANARY,domain=CANARY,network_adapters=[CANARY]) + raw['inventory']['services'][-1].update(name='MSSQL$'+CANARY,account=CANARY,display_name=CANARY) + raw['inventory']['tasks']=[{'name':CANARY,'path':'\\'+CANARY+'\\','enabled':True,'account':CANARY,'triggers':CANARY,'arguments':CANARY}] + raw['inventory']['software']=[{'name':'Microsoft SQL Server '+CANARY,'publisher':CANARY,'version':'16.0.1 '+CANARY}] + raw['inventory']['storage']=[{'type':'SMB Share','name':CANARY,'path':'C:\\'+CANARY}] + raw['inventory']['certificates']=[{'subject':CANARY,'issuer':CANARY,'thumbprint':CANARY,'expires':'2027-01-01T00:00:00Z'}] + raw['checks']['services'].update(message=CANARY,errors=[{'code':CANARY,'scope':CANARY,'message':CANARY}]) + raw['checks'][CANARY]={'status':'failed','message':CANARY} + raw['privacy']={'profile':'minimized-metadata-v1'} # A marker cannot bypass projection. + snapshot=copy.deepcopy(raw) + clean=minimize_inventory(raw) + self.assertEqual(raw,snapshot) + self.assertNotIn(CANARY,json.dumps(clean)) + validate_inventory(clean) + self.assertEqual(assess(clean)['score'],70) + with tempfile.TemporaryDirectory() as folder: + policy=load_privacy_policy();policy['report_directory']=folder + with patch('samsara.core.storage._verify_windows_acl'), patch('samsara.core.storage.load_privacy_policy',return_value=policy): + assessment=assess(original) + assessment['findings'][0]['evidence']=CANARY + export_html(Path(folder)/'test.html',raw,assessment) + export_json(Path(folder)/'test.json',raw,assessment) + for path in Path(folder).iterdir(): self.assertNotIn(CANARY,path.read_text()) + self.assertEqual(len(list(Path(folder).iterdir())),2) + + def test_exact_roots_only_no_descendant_or_network_approval(self): + policy=load_privacy_policy();policy['approved_share_roots']=['D:\\Approved'] + raw=fixture();raw['inventory']['storage']=[{'type':'SMB Share','name':'private','path':p} for p in ['D:\\Approved','D:\\Approved\\child','D:\\Other']] + clean=minimize_inventory(raw,policy) + self.assertEqual(clean['inventory']['storage'][0]['path'],'D:\\Approved') + self.assertNotIn('child',json.dumps(clean)) + for bad in ['\\\\server\\share','//server/share','D:\\Approved\\..\\Other']: + with self.assertRaises(ValueError): canonical_share_root(bad) + + def test_workload_scores_survive_minimization(self): + for name,score in [('clean_windows',0),('inactive_game_server',0),('active_sql_server',70),('active_iis_server',70),('domain_controller',95),('active_file_server',80),('incomplete_collection',0)]: + with self.subTest(name=name): + clean=minimize_inventory(fixture(name)) + self.assertEqual(assess(clean)['score'],score) + self.assertTrue(assess(clean)['review_required']) + self.assertEqual(minimize_inventory(clean),clean) + + def test_dates_closed_schema_and_oversized_input_fail(self): + raw=fixture();raw['collected_at']='not a date' + with self.assertRaises(ValueError):validate_inventory(raw) + raw=fixture();raw['inventory']['services'][0]['unexpected']={'sensitive':CANARY} + with self.assertRaises(ValueError):validate_inventory(raw) + raw=fixture();raw['inventory']['tasks']=[{}]*20001 + with self.assertRaises(ValueError):minimize_inventory(raw) + + def test_default_collectors_do_not_request_high_risk_scopes(self): + for name,forbidden in [('Get-ServerStorage.ps1','Get-SmbOpenFile'),('Get-ServerCertificates.ps1','Get-ChildItem'),('Get-ServerTasks.ps1','Get-ScheduledTask'),('Get-ServerServices.ps1','StartName')]: + self.assertNotIn(forbidden,Path('powershell',name).read_text()) + self.assertNotIn('TemporaryFile',Path('samsara/collectors/windows.py').read_text()) + self.assertNotIn('TemporaryFile',Path('samsara/collectors/linux.py').read_text()) + + +class StorageTests(unittest.TestCase): + @unittest.skipIf(os.name=='nt','POSIX permission test; native Windows ACL acceptance requires a provisioned lab root.') + def test_private_root_exclusive_files_and_destination_boundary(self): + with tempfile.TemporaryDirectory() as base: + root=Path(base)/'reports';policy=load_privacy_policy();policy['report_directory']=str(root) + with patch('samsara.core.storage.load_privacy_policy',return_value=policy): + output=root/'report.json';write_private(output,'{}') + if os.name!='nt': + self.assertEqual(stat.S_IMODE(root.stat().st_mode),0o700) + self.assertEqual(stat.S_IMODE(output.stat().st_mode),0o600) + with self.assertRaises(FileExistsError):write_private(output,'overwrite') + with self.assertRaises(ValueError):write_private(Path(base)/'outside.json','{}') + self.assertEqual(output.read_text(),'{}') + if os.name!='nt': + root.chmod(0o755) + with self.assertRaises(ValueError):write_private(root/'unsafe.json','{}') + + def test_network_uri_symlink_paths_rejected(self): + for value in ['\\\\server\\share\\data.json','//server/share/data.json','https://host/path']: + with self.assertRaises(ValueError):require_local_path(value) + if os.name!='nt': + with tempfile.TemporaryDirectory() as base: + link=Path(base)/'link';link.symlink_to(Path(base),target_is_directory=True) + with self.assertRaises(ValueError):require_local_path(link/'data.json') + + @unittest.skipIf(os.name=='nt','POSIX FIFO regression') + def test_import_rejects_fifo_without_waiting_for_a_writer(self): + from samsara.collectors.importer import load_inventory + with tempfile.TemporaryDirectory() as base: + pipe=Path(base)/'inventory.json' + os.mkfifo(pipe) + with self.assertRaises(ValueError): load_inventory(pipe) + + +class CaptureTests(unittest.TestCase): + def test_bounded_memory_capture_timeout_cancel_and_sanitized_errors(self): + env={'PATH':os.defpath, **{key:os.environ[key] for key in ('SystemRoot','WINDIR') if key in os.environ}} + self.assertEqual(capture([sys.executable,'-c','print("synthetic")'],env=env,timeout=3),b'synthetic\n') + for code,script,options in [('output_limit','print("x"*200000)',{'limit':100}),('timeout','import time; time.sleep(10)',{'timeout':.05}),('collection_failed','raise RuntimeError("'+CANARY+'")',{})]: + with self.subTest(code=code), self.assertRaises(ProcessFailure) as raised: + capture([sys.executable,'-c',script],env=env,**dict({'timeout':3},**options)) + self.assertEqual(raised.exception.code,code) + self.assertNotIn(CANARY,str(raised.exception)) + with self.assertRaises(CollectionCancelled): + capture([sys.executable,'-c','print(1)'],env=env,timeout=3,cancelled=lambda:True) diff --git a/tests/test_scoring.py b/tests/test_scoring.py index 2e17297..3901c0a 100644 --- a/tests/test_scoring.py +++ b/tests/test_scoring.py @@ -7,7 +7,7 @@ from samsara.analyzers.assessment import assess from samsara.analyzers.dependencies import CONFIRMED, POTENTIAL, STANDARD, UNKNOWN from samsara.analyzers.policy import load_policy from samsara.models.inventory import validate_inventory -from samsara.reporting.export import export_html, export_json +from report_helpers import export_html, export_json from samsara.collectors.importer import load_inventory @@ -191,15 +191,14 @@ class ScoringRegressionTests(unittest.TestCase): self.assertIn('Historical usage validation',text) self.assertNotIn('medium-risk dependencies',text) self.assertLess(text.count(''),60) - inventory=list(Path(folder).glob('*.inventory-*.json')) - self.assertEqual(len(inventory),1) - raw=json.loads(inventory[0].read_text()) - self.assertEqual(raw,data) - self.assertEqual(len(raw['inventory']['services']),86) - # Re-export reuses an identical artifact rather than losing inventory. - export_html(report,data,assess(data)) - export_json(Path(folder)/'assessment.json',data,assess(data)) - self.assertEqual(load_inventory(inventory[0])['inventory'],load_inventory(Path(folder)/'assessment.json')['inventory']) + self.assertEqual(list(Path(folder).glob('*.inventory-*.json')),[]) + with self.assertRaises(FileExistsError): + export_html(report,data,assess(data)) + output=Path(folder)/'assessment.json' + export_json(output,data,assess(data)) + clean=load_inventory(output) + self.assertEqual(len(clean['inventory']['services']),86) + self.assertNotEqual(clean['server']['hostname'], data['server']['hostname']) def test_custom_jobs_not_misclassified_by_substrings_and_zero_points_maintenance(self): data=fixture('clean_windows') @@ -218,9 +217,7 @@ class ScoringRegressionTests(unittest.TestCase): report=Path(folder)/'assessment.html' export_html(report,data,result) self.assertNotIn('