159 lines
7.3 KiB
Python
159 lines
7.3 KiB
Python
"""Metadata-only file checks and local Ubuntu update evidence.
|
|
|
|
No privilege escalation, package refresh, network calls, private-file reads or
|
|
automatic repairs. Findings are review prompts, never exploit confirmations.
|
|
"""
|
|
from datetime import datetime, timezone
|
|
import os
|
|
from pathlib import Path
|
|
import platform
|
|
import re
|
|
import stat
|
|
import time
|
|
|
|
from .reports import utc_now
|
|
|
|
|
|
POLICIES = (
|
|
('/etc/passwd',False), ('/etc/group',False),
|
|
('/etc/shadow',True), ('/etc/gshadow',True),
|
|
('/etc/sudoers',False), ('/etc/ssh/sshd_config',False),
|
|
('/etc',False), ('/etc/ssh',False), ('/etc/sudoers.d',False),
|
|
)
|
|
|
|
|
|
def inspect_path(path, sensitive=False, *, lstat=os.lstat):
|
|
row = {'source':path,'method':'lstat_metadata_only','status':'unknown',
|
|
'observed_at':utc_now(),'reasons':[]}
|
|
try:
|
|
info = lstat(path)
|
|
except FileNotFoundError:
|
|
row.update(status='skipped',error_code='PATH_MISSING')
|
|
return row
|
|
except OSError:
|
|
row['error_code'] = 'METADATA_UNAVAILABLE'
|
|
return row
|
|
mode = stat.S_IMODE(info.st_mode)
|
|
row.update(mode=oct(mode),uid=info.st_uid,gid=info.st_gid)
|
|
if stat.S_ISLNK(info.st_mode):
|
|
row['error_code'] = 'SYMLINK_REQUIRES_REVIEW'
|
|
return row
|
|
if not (stat.S_ISREG(info.st_mode) or stat.S_ISDIR(info.st_mode)):
|
|
row['error_code'] = 'UNEXPECTED_FILE_TYPE'
|
|
return row
|
|
if mode & stat.S_IWOTH:
|
|
row['reasons'].append('WORLD_WRITABLE')
|
|
if mode & stat.S_IWGRP:
|
|
row['reasons'].append('GROUP_WRITABLE_REVIEW')
|
|
if info.st_uid != 0:
|
|
row['reasons'].append('OWNER_NOT_ROOT_REVIEW')
|
|
if sensitive and mode & stat.S_IROTH:
|
|
row['reasons'].append('SENSITIVE_WORLD_READABLE')
|
|
row['status'] = 'review' if row['reasons'] else 'observed'
|
|
row['limitations'] = ['POSIX mode/owner only; ACLs, capabilities, mount options and runtime use not evaluated.',
|
|
'Group access or non-root ownership can be intentional; review in deployment context.']
|
|
return row
|
|
|
|
|
|
def read_small(path):
|
|
try:
|
|
with Path(path).open(encoding='utf-8') as stream:
|
|
value = stream.read(8193)
|
|
return value if len(value) <= 8192 else None
|
|
except (OSError,UnicodeError):
|
|
return None
|
|
|
|
|
|
def update_evidence(*, lstat=os.lstat, read=read_small, now=time.time):
|
|
"""A timestamp is freshness evidence, never a vulnerability inventory."""
|
|
row = {'method':'local_update_cache_only','observed_at':utc_now(),'status':'unknown',
|
|
'pending_updates':None,'security_updates':None,'sources':[],
|
|
'limitations':['No repository refresh or package solver was run.',
|
|
'Missing or stale cache cannot establish whether the system is fully patched.']}
|
|
for path in ('/var/lib/apt/periodic/update-success-stamp',
|
|
'/var/lib/update-notifier/updates-available'):
|
|
evidence = {'source':path,'status':'unknown'}
|
|
try:
|
|
info = lstat(path)
|
|
if not stat.S_ISREG(info.st_mode):
|
|
evidence['error_code'] = 'NOT_REGULAR_FILE'
|
|
else:
|
|
timestamp = info.st_mtime
|
|
age = now()-timestamp
|
|
if age < 0:
|
|
evidence['error_code'] = 'FUTURE_TIMESTAMP'
|
|
else:
|
|
evidence.update(status='observed',age_hours=round(age/3600,2),
|
|
modified_at=datetime.fromtimestamp(timestamp,timezone.utc).isoformat())
|
|
if path.endswith('updates-available'):
|
|
# This notifier summary is optional and may be localized.
|
|
# Never retain its complete text in logs or reports.
|
|
text = read(path)
|
|
match = re.search(r'^\s*(\d+) updates? can be applied immediately\.',text or '',re.M)
|
|
if match:
|
|
row['pending_updates'] = int(match[1])
|
|
row['status'] = 'cached_observation'
|
|
match = re.search(r'^(\d+) of these updates? (?:is|are) (?:a )?(?:standard )?security updates?\.',text or '',re.M)
|
|
if match:
|
|
row['security_updates'] = int(match[1])
|
|
except FileNotFoundError:
|
|
evidence['error_code'] = 'CACHE_MISSING'
|
|
except (OSError,ValueError,OverflowError):
|
|
evidence['error_code'] = 'CACHE_UNAVAILABLE'
|
|
row['sources'].append(evidence)
|
|
return row
|
|
|
|
|
|
def collect(report=None, on_result=None, *, system=None, lstat=os.lstat, read=read_small, now=time.time):
|
|
report = {} if report is None else report
|
|
report.update(schema_version=1,observed_at=utc_now(),status='unsupported',files=[],updates=None,
|
|
limitations=['Read-only hints, not a complete security audit.',
|
|
'Cloud security groups and external reachability cannot be confirmed locally.'])
|
|
if (system or platform.system()) != 'Linux':
|
|
return report
|
|
for path,sensitive in POLICIES:
|
|
report['files'].append(inspect_path(path,sensitive,lstat=lstat))
|
|
if on_result is not None:
|
|
on_result()
|
|
report['updates'] = update_evidence(lstat=lstat,read=read,now=now)
|
|
report['status'] = 'partial'
|
|
report['review_count'] = sum(item['status']=='review' for item in report['files'])
|
|
if on_result is not None:
|
|
on_result()
|
|
return report
|
|
|
|
|
|
def render(report, out):
|
|
tr = out.text
|
|
out.row(tr('权限检查范围','Permission scope'),tr('只读权限元数据','Read-only metadata'),'unknown')
|
|
reasons = {
|
|
'WORLD_WRITABLE':('所有用户可写','Writable by everyone'),
|
|
'GROUP_WRITABLE_REVIEW':('同组可写,需审阅','Group-writable; review'),
|
|
'OWNER_NOT_ROOT_REVIEW':('所有者非 root,需审阅','Non-root owner; review'),
|
|
'SENSITIVE_WORLD_READABLE':('敏感文件所有用户可读','Sensitive file readable by everyone'),
|
|
}
|
|
for row in report['files']:
|
|
if row['status'] == 'review':
|
|
value = '; '.join(tr(*reasons[reason]) for reason in row['reasons'])
|
|
elif row['status'] == 'observed':
|
|
value = tr('模式位未见上述宽松项','No listed mode-bit finding')
|
|
else:
|
|
value = tr('未知/跳过','Unknown/skipped')+' ('+row.get('error_code','UNKNOWN')+')'
|
|
out.row(row['source'],row.get('mode','---')+' · '+value,
|
|
'bad' if row['status']=='review' else 'ok' if row['status']=='observed' else 'unknown')
|
|
update = report.get('updates')
|
|
if update:
|
|
pending = update['pending_updates']
|
|
out.row(tr('缓存待更新数','Cached pending updates'),tr('未知','Unknown') if pending is None else str(pending),'unknown')
|
|
for source in update['sources']:
|
|
if 'age_hours' in source:
|
|
out.row(tr('更新缓存年龄','Update cache age'),f"{source['age_hours']:g} h · "+source['source'],'unknown')
|
|
out.row(tr('安全结论','Security conclusion'),tr('本机只读观测;补丁状态未确认','Local read-only observations; patch status unconfirmed'),'unknown')
|
|
|
|
|
|
def run_console(lang='zh', color='auto', report=None, on_result=None):
|
|
from .console import Console
|
|
report = collect(report,on_result)
|
|
render(report,Console(lang,color))
|
|
return 1 if report.get('review_count') or report['status'] == 'unsupported' else 0
|