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 <noreply@anthropic.com>
This commit is contained in:
2026-11-04 18:50:24 -08:00
parent 7fed3b9ab9
commit 610a3bb447
51 changed files with 2719 additions and 604 deletions

View File

@@ -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')