Du kan inte välja fler än 25 ämnen Ämnen måste starta med en bokstav eller siffra, kan innehålla bindestreck ('-') och vara max 35 tecken långa.
 
 
 
 

360 rader
18 KiB

  1. """Scan this scene report only. Never print matched credentials or request data."""
  2. from pathlib import Path
  3. from collections import Counter
  4. from html.parser import HTMLParser
  5. from urllib.parse import unquote, urlsplit
  6. import argparse
  7. import base64
  8. import datetime
  9. import hashlib
  10. import html
  11. import io
  12. import json
  13. import re
  14. import subprocess
  15. import zipfile
  16. from PIL import Image
  17. WORKSPACE = next(parent for parent in Path(__file__).resolve().parents if (parent / 'unreal_tran/ute2e').is_dir())
  18. ROOT = WORKSPACE / 'unreal_tran'
  19. REPORT = ROOT / 'ute2e/reports/scene-editor-closure-20260906'
  20. OUTPUT = REPORT / 'artifact-checks.json'
  21. FINAL_FILES = ('results.json', 'validation.json', 'cleanup.json', 'playwright/index.html')
  22. TEXT_SUFFIXES = {'.ts', '.js', '.mjs', '.cjs', '.py', '.md', '.json', '.html', '.css', '.txt', '.log', '.trace', '.network', '.svg', '.xml', '.yml', '.yaml', '.csv', '.vue', '.sql'}
  23. IMAGES = {'.png', '.jpg', '.jpeg', '.webp'}
  24. JWT = re.compile(r'(?<![A-Za-z0-9_-])(eyJ[A-Za-z0-9_-]{5,})\.([A-Za-z0-9_-]{8,})\.([A-Za-z0-9_-]{8,})(?![A-Za-z0-9_-])')
  25. ZIP_DATA = re.compile(r'data:application/zip;base64,([A-Za-z0-9+/=\r\n]+)')
  26. PRIVATE_PATH = re.compile(r'(?:^|/)(?:账号\.md|credentials?(?:\.[^/]+)?|storage[-_]?state\.json|fixtures\.json|\.env(?:\.[^/]+)?|[^/]*\.sql\.gz)$', re.I)
  27. SECRET_KEY = re.compile(r'^(?:password|passwd|pwd|accessToken|refreshToken|apiKey|secret)$', re.I)
  28. ASSIGNMENT = re.compile(r'(?:password|passwd|pwd|secret|apiKey|accessToken|refreshToken|authorization|cookie|密码)[\s"\x27]*[:=][\s"\x27]*[^,;\r\n]{0,40}$', re.I)
  29. def decode(data):
  30. return data.decode('utf-16' if data[:2] in (b'\xff\xfe', b'\xfe\xff') else 'utf-8-sig', errors='replace').replace('\r\n', '\n')
  31. def credentials():
  32. lines = [line.strip() for line in (ROOT / '账号.MD').read_text(encoding='utf-8-sig').splitlines() if line.strip()]
  33. values = {re.split(r'[::]', line, maxsplit=1)[-1].strip() for line in lines[1::2]}
  34. fixture = WORKSPACE / '.codex-tmp/scene-closure-20260906/fixtures.json'
  35. def collect(value):
  36. if isinstance(value, dict):
  37. for key, child in value.items():
  38. if SECRET_KEY.fullmatch(key) and isinstance(child, str) and child:
  39. values.add(child)
  40. elif isinstance(child, (dict, list)):
  41. collect(child)
  42. elif isinstance(value, list):
  43. for child in value:
  44. collect(child)
  45. if not fixture.is_file():
  46. raise ValueError('Missing private reference')
  47. collect(json.loads(fixture.read_text(encoding='utf-8-sig')))
  48. if not values or '' in values:
  49. raise ValueError('Missing credential values')
  50. return sorted(values, key=len, reverse=True)
  51. def decoded_jwts(text):
  52. for match in JWT.finditer(text):
  53. try:
  54. head = json.loads(base64.urlsafe_b64decode(match[1] + '=' * (-len(match[1]) % 4)))
  55. body = json.loads(base64.urlsafe_b64decode(match[2] + '=' * (-len(match[2]) % 4)))
  56. if isinstance(head, dict) and isinstance(body, dict) and ('alg' in head or str(head.get('typ', '')).upper() == 'JWT'):
  57. yield match
  58. except (ValueError, UnicodeError):
  59. continue
  60. def text_matches(text, values, baselines=()):
  61. findings, exemptions = [], []
  62. for value in values:
  63. for needle in {value, json.dumps(value, ensure_ascii=True)[1:-1], html.escape(value, quote=True)}:
  64. used = Counter()
  65. for match in re.finditer(re.escape(needle), text):
  66. offset = match.start()
  67. prefix = text[max(text.rfind('\n', 0, offset) + 1, offset - 100):offset]
  68. context = text[max(0, offset - 80):min(len(text), match.end() + 80)]
  69. accepted = None
  70. # Only the caller-supplied, committed Playwright static shell is
  71. # eligible. An assignment or decoded ZIP can never use this waiver.
  72. if not ASSIGNMENT.search(prefix) and len(context) >= 64:
  73. for name, previous in baselines:
  74. key = (name, context)
  75. if context in previous and used[key] < previous.count(context) * context.count(needle):
  76. accepted = name
  77. used[key] += 1
  78. break
  79. if accepted:
  80. exemptions.append({'rule': 'unchanged-playwright-vendor-context', 'baseline': accepted})
  81. else:
  82. findings.append({'rule': 'known-credential', 'line': text.count('\n', 0, offset) + 1})
  83. findings.extend({'rule': 'decodable-jwt', 'line': text.count('\n', 0, match.start()) + 1} for match in decoded_jwts(text))
  84. return findings, exemptions
  85. def status_pass(value):
  86. return str(value or '').upper() in ('PASS', 'PASSED', 'SUCCESS')
  87. def final_input_state(documents, requested):
  88. reasons = []
  89. if not requested:
  90. reasons.append('final-scan-not-requested')
  91. for name in FINAL_FILES:
  92. if name not in documents:
  93. reasons.append('missing-final-input:' + name)
  94. validation = documents.get('validation.json', {})
  95. if not (validation.get('finalRun') is True or validation.get('e2eComplete') is True or validation.get('e2e', {}).get('complete') is True):
  96. reasons.append('final-run-not-confirmed')
  97. if not status_pass(validation.get('overallStatus', validation.get('status'))):
  98. reasons.append('validation-not-pass')
  99. if not status_pass(documents.get('cleanup.json', {}).get('status')):
  100. reasons.append('cleanup-not-pass')
  101. tests = []
  102. def visit(suites):
  103. for suite in suites:
  104. for spec in suite.get('specs', []):
  105. tests.extend(spec.get('tests', []))
  106. visit(suite.get('suites', []))
  107. results = documents.get('results.json', {})
  108. visit(results.get('suites', []))
  109. if not tests or any(test.get('status') in ('unexpected', 'flaky', 'skipped') or not test.get('results') or test['results'][-1].get('status') != 'passed' for test in tests):
  110. reasons.append('current-e2e-not-all-pass')
  111. if results.get('errors') or any(results.get('stats', {}).get(key, 0) for key in ('unexpected', 'flaky', 'skipped')):
  112. reasons.append('current-e2e-has-errors')
  113. return reasons
  114. class LocalLinks(HTMLParser):
  115. def __init__(self):
  116. super().__init__(convert_charrefs=True)
  117. self.links = []
  118. def handle_starttag(self, tag, attrs):
  119. self.links.extend(value for key, value in attrs if key in ('href', 'src', 'poster') and value)
  120. def resolve_local_link(source, value, root):
  121. parsed = urlsplit(value)
  122. if parsed.scheme in ('http', 'https', 'data', 'mailto', 'tel', 'javascript', 'blob') or parsed.netloc or not parsed.path:
  123. return None
  124. if parsed.scheme:
  125. raise ValueError('Unsupported local URL scheme')
  126. target = (source.parent / unquote(parsed.path)).resolve()
  127. if not target.is_relative_to(root):
  128. raise ValueError('Local URL outside workspace')
  129. return target
  130. def image_metadata(payload):
  131. with Image.open(io.BytesIO(payload)) as img:
  132. img.verify()
  133. with Image.open(io.BytesIO(payload)) as img:
  134. img.load()
  135. if min(img.size) <= 0:
  136. raise ValueError('Empty image')
  137. values = list(img.info.values()) + list(img.getexif().values())
  138. metadata = '\n'.join(decode(value) if isinstance(value, bytes) else str(value) for value in values if isinstance(value, (str, bytes)))
  139. return img.format, img.size, metadata
  140. def self_checks():
  141. value = 'test-only-scene-scanner-value'
  142. vendor = 'const alphabet="ABCDEFGHIJKLMNOPQRSTUVWXYZ-prefix-' + value + '-suffix-abcdefghijklmnopqrstuvwxyz-0123456789";'
  143. assert not text_matches(vendor, [value], [('HEAD:vendor', vendor)])[0]
  144. assert text_matches('password="' + value + '"', [value], [('HEAD:vendor', 'password="' + value + '"')])[0]
  145. assert text_matches(vendor, [value])[0]
  146. enc = lambda obj: base64.urlsafe_b64encode(json.dumps(obj).encode()).decode().rstrip('=')
  147. token = enc({'alg': 'HS256'}) + '.' + enc({'sub': 'scanner-test'}) + '.testsigsample'
  148. assert text_matches(token, [])[0]
  149. assert not text_matches('Bearer ${token}; eyJnot-a-token', [])[0]
  150. archive = io.BytesIO()
  151. with zipfile.ZipFile(archive, 'w') as item:
  152. item.writestr('report.json', json.dumps({'accessToken': token}))
  153. embedded = 'data:application/zip;base64,' + base64.b64encode(archive.getvalue()).decode()
  154. with zipfile.ZipFile(io.BytesIO(base64.b64decode(ZIP_DATA.search(embedded)[1]))) as item:
  155. assert text_matches(decode(item.read('report.json')), [])[0]
  156. png = io.BytesIO()
  157. Image.new('RGB', (2, 3)).save(png, format='PNG')
  158. assert image_metadata(png.getvalue())[:2] == ('PNG', (2, 3))
  159. try:
  160. image_metadata(png.getvalue()[:20])
  161. except Exception:
  162. pass
  163. else:
  164. raise AssertionError('Truncated image was accepted')
  165. docs = {'results.json': {'suites': [{'specs': [{'tests': [{'status': 'expected', 'results': [{'status': 'passed'}]}]}]}]}, 'validation.json': {'status': 'PASS', 'e2eComplete': True}, 'cleanup.json': {'status': 'PASS'}, 'playwright/index.html': True}
  166. assert not final_input_state(docs, True)
  167. assert final_input_state(docs, False)
  168. assert final_input_state({}, True)
  169. docs['results.json']['suites'][0]['specs'][0]['tests'][0]['status'] = 'flaky'
  170. assert final_input_state(docs, True)
  171. source = WORKSPACE / 'example/index.html'
  172. assert resolve_local_link(source, '../%E6%96%87%E6%A1%A3/sample.pdf#x', WORKSPACE) == WORKSPACE / '文档/sample.pdf'
  173. assert resolve_local_link(source, 'https://example.test/image.png', WORKSPACE) is None
  174. try:
  175. resolve_local_link(source, '../../../outside.txt', WORKSPACE)
  176. except ValueError:
  177. pass
  178. else:
  179. raise AssertionError('Escaping local link was accepted')
  180. parser = LocalLinks()
  181. parser.feed('<img src="screenshots/test.png"><a href="data/evidence.json">x</a>')
  182. assert len(parser.links) == 2
  183. return 16
  184. def scan(final=False):
  185. checks = self_checks()
  186. values = credentials()
  187. findings, exemptions, pending = [], [], []
  188. counts = Counter()
  189. documents, hashes = {}, {}
  190. baselines = []
  191. for source in ('reports/role-permissions-20260906/playwright/index.html', 'reports/model-editor-closure-20260906/playwright/index.html'):
  192. result = subprocess.run(['git', 'show', 'HEAD:' + source], cwd=ROOT / 'ute2e', stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, check=False)
  193. if result.returncode == 0:
  194. baselines.append(('ute2e/HEAD:' + source, ZIP_DATA.sub('data:application/zip;base64,[decoded separately]', decode(result.stdout))))
  195. def safe(label):
  196. for value in values:
  197. label = label.replace(value, '[redacted]')
  198. return JWT.sub('[redacted-jwt]', label)
  199. def record(label, rule, **extra):
  200. findings.append({'file': safe(label), 'rule': rule, **extra})
  201. def scan_text(label, content, vendor=False, depth=0):
  202. counts['textArtifactsScanned'] += 1
  203. encoded = list(ZIP_DATA.finditer(content))
  204. plain = ZIP_DATA.sub('data:application/zip;base64,[decoded separately]', content)
  205. hits, waived = text_matches(plain, values, baselines if vendor else ())
  206. findings.extend({'file': safe(label), **hit} for hit in hits)
  207. exemptions.extend({'file': safe(label), **item} for item in waived)
  208. if 'playwrightReportBase64' in content and not encoded:
  209. record(label, 'playwright-embedded-metadata-missing')
  210. for index, match in enumerate(encoded):
  211. try:
  212. scan_zip(label + '!embedded-' + str(index + 1) + '.zip', base64.b64decode(match[1]), depth + 1)
  213. except Exception:
  214. record(label, 'embedded-archive-invalid')
  215. def scan_image(label, payload):
  216. try:
  217. _, _, metadata = image_metadata(payload)
  218. counts['imagesValidated'] += 1
  219. if metadata:
  220. scan_text(label + '!image-metadata', metadata)
  221. except Exception:
  222. record(label, 'image-validation-failed')
  223. def scan_zip(label, payload, depth=0):
  224. if depth > 3:
  225. record(label, 'archive-depth-exceeded')
  226. return
  227. try:
  228. with zipfile.ZipFile(io.BytesIO(payload)) as archive:
  229. entries = [entry for entry in archive.infolist() if not entry.is_dir()]
  230. if sum(entry.file_size for entry in entries) > 256 * 1024 * 1024:
  231. record(label, 'archive-size-limit-exceeded')
  232. return
  233. counts['archivesDecoded'] += 1
  234. for entry in entries:
  235. child = label + '!' + entry.filename
  236. if PRIVATE_PATH.search(entry.filename.replace('\\', '/')):
  237. record(child, 'private-artifact-in-archive')
  238. data = archive.read(entry)
  239. counts['archiveEntriesScanned'] += 1
  240. suffix = Path(entry.filename).suffix.lower()
  241. if suffix == '.zip' or data[:4] == b'PK\x03\x04':
  242. scan_zip(child, data, depth + 1)
  243. elif suffix in IMAGES:
  244. scan_image(child, data)
  245. elif suffix in TEXT_SUFFIXES or b'\x00' not in data[:4096]:
  246. scan_text(child, decode(data), depth=depth)
  247. except Exception:
  248. record(label, 'archive-read-failed')
  249. for file in sorted(REPORT.rglob('*')):
  250. if not file.is_file():
  251. continue
  252. label = file.relative_to(REPORT).as_posix()
  253. if file.is_symlink() or not file.resolve().is_relative_to(REPORT.resolve()):
  254. record(label, 'report-link-outside-scan-root')
  255. continue
  256. if PRIVATE_PATH.search(label):
  257. record(label, 'private-artifact-in-report')
  258. data = file.read_bytes()
  259. counts['filesScanned'] += 1
  260. if label in FINAL_FILES:
  261. hashes[label] = hashlib.sha256(data).hexdigest()
  262. try:
  263. documents[label] = json.loads(decode(data)) if label.endswith('.json') else True
  264. except Exception:
  265. record(label, 'final-input-invalid-json')
  266. suffix = file.suffix.lower()
  267. if suffix in IMAGES:
  268. scan_image(label, data)
  269. elif suffix == '.zip' or data[:4] == b'PK\x03\x04':
  270. scan_zip(label, data)
  271. elif suffix in TEXT_SUFFIXES or b'\x00' not in data[:4096]:
  272. content = decode(data)
  273. scan_text(label, content, vendor=label == 'playwright/index.html')
  274. if suffix == '.html':
  275. parser = LocalLinks()
  276. try:
  277. parser.feed(content)
  278. for value in parser.links:
  279. target = resolve_local_link(file, value, WORKSPACE)
  280. if target is not None:
  281. counts['localLinksChecked'] += 1
  282. if not target.exists():
  283. record(label, 'missing-local-link', target=safe(str(target.relative_to(WORKSPACE)).replace('\\', '/')))
  284. except Exception:
  285. record(label, 'invalid-html-local-link')
  286. else:
  287. counts['nontextBinaryFiles'] += 1
  288. pending.extend(final_input_state(documents, final))
  289. if not counts['filesScanned'] or not counts['imagesValidated']:
  290. pending.append('report-or-images-missing')
  291. status = 'FAIL' if findings else 'PENDING' if pending else 'PASS'
  292. output = {
  293. 'time': datetime.datetime.now(datetime.timezone.utc).isoformat(),
  294. 'status': status,
  295. 'phase': 'final' if final else 'preliminary',
  296. 'finalArtifactScan': final and not pending,
  297. 'scope': '仅 scene-editor-closure-20260906 报告文件;不改应用,不调用 API,不清理数据。',
  298. 'finalInputs': {name: name in documents for name in FINAL_FILES},
  299. 'finalInputSha256': hashes,
  300. 'pendingReasons': pending,
  301. 'selfChecks': {'status': 'PASS', 'tests': checks},
  302. 'counts': dict(counts),
  303. 'findings': findings,
  304. 'exemptions': {'count': len(exemptions), 'items': exemptions},
  305. 'limitations': ['图片解码和文本元数据检查不代替人工视觉核验;未对图片像素进行 OCR。', '只校验 HTML 本地链接文件存在,不访问外部链接;Playwright 路由片段不作文件路径。', '只有当前最终输入完整且全部通过才可 PASS;已解码 ZIP 不适用静态第三方库基线豁免。']
  306. }
  307. OUTPUT.parent.mkdir(parents=True, exist_ok=True)
  308. OUTPUT.write_text(json.dumps(output, ensure_ascii=False, indent=2), encoding='utf-8')
  309. print(json.dumps({'status': status, 'finalArtifactScan': output['finalArtifactScan'], 'findings': len(findings), 'pending': pending, 'counts': dict(counts)}, ensure_ascii=False))
  310. return 0 if status == 'PASS' else 1 if status == 'FAIL' else 2
  311. if __name__ == '__main__':
  312. parser = argparse.ArgumentParser()
  313. parser.add_argument('--final', action='store_true')
  314. parser.add_argument('--self-test', action='store_true')
  315. args = parser.parse_args()
  316. if args.self_test:
  317. print(json.dumps({'status': 'PASS', 'tests': self_checks()}))
  318. else:
  319. try:
  320. raise SystemExit(scan(args.final))
  321. except Exception as error:
  322. # Do not include exception messages: they may contain source values.
  323. failure = {'time': datetime.datetime.now(datetime.timezone.utc).isoformat(), 'status': 'FAIL', 'finalArtifactScan': False, 'finalInputSha256': {}, 'findings': [{'file': 'scanner', 'rule': 'scan-incomplete', 'errorType': type(error).__name__}]}
  324. OUTPUT.parent.mkdir(parents=True, exist_ok=True)
  325. OUTPUT.write_text(json.dumps(failure, indent=2), encoding='utf-8')
  326. print(json.dumps({'status': 'FAIL', 'errorType': type(error).__name__}))
  327. raise SystemExit(1)