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:
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user