audit-release-workspace.py 3.0 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253
  1. """Inventory source changes without including private data, generated releases or retired demos."""
  2. import hashlib
  3. import json
  4. from pathlib import Path
  5. import re
  6. import subprocess
  7. ROOT = Path(__file__).resolve().parents[1]
  8. ALLOWED = ('scripts/', 'docs/', 'deploy/', 'integrations/', 'server/', 'skills/', 'test/')
  9. def main():
  10. pending = subprocess.check_output(['git', 'ls-files', '--others', '--exclude-standard', '-z'], cwd=ROOT).decode('utf-8').split('\0')
  11. for args in (['git', 'diff', '--name-only', '-z'], ['git', 'diff', '--cached', '--name-only', '-z']):
  12. pending += subprocess.check_output(args, cwd=ROOT).decode('utf-8').split('\0')
  13. candidates = sorted({p for p in pending if p and (p.startswith(ALLOWED) or p in {'.gitignore', '.gitattributes', 'package.json'})})
  14. findings = []
  15. forbidden = []
  16. for name in candidates:
  17. p = ROOT / name
  18. if not p.exists():
  19. continue
  20. if p.is_symlink() or p.suffix.lower() in {'.zip', '.sqlite', '.db', '.pem', '.key', '.pyc', '.png'} or name.endswith('credential-binding.json'):
  21. forbidden.append(name)
  22. continue
  23. text = p.read_text(encoding='utf-8-sig')
  24. for m in re.finditer(r'''(?i)\b(master_?key|service_?token|access_?token|api_?key|client_?secret)\b\s*[:=]\s*['"]([^'"\n]{16,})['"]''', text):
  25. if not ('test/' in name or 'server/test/' in name or Path(name).name.startswith('test_')):
  26. findings.append({'path': name, 'line': text.count('\n', 0, m.start()) + 1, 'field': m[1], 'literalLength': len(m[2])})
  27. if re.search(r'-----BEGIN (?:RSA |OPENSSH )?PRIVATE KEY-----\s+[A-Za-z0-9+/]{40}', text):
  28. forbidden.append(name)
  29. skills = {}
  30. for skill in (ROOT / 'skills').iterdir():
  31. manifest = skill / 'FILES.sha256'
  32. if not manifest.is_file():
  33. continue
  34. rows = manifest.read_text(encoding='utf-8').splitlines()
  35. for line in rows:
  36. expected, relative = line.split(' ', 1)
  37. actual = hashlib.sha256((skill / relative).read_bytes()).hexdigest()
  38. if expected != actual:
  39. raise ValueError('SKILL_MANIFEST_MISMATCH:' + skill.name + '/' + relative)
  40. skills[skill.name] = {'filesVerified': len(rows), 'manifestSha256': hashlib.sha256(manifest.read_bytes()).hexdigest()}
  41. out = {'ok': not forbidden and not findings, 'sourceCandidates': candidates, 'sourceCandidateCount': len(candidates),
  42. 'forbiddenCandidates': forbidden, 'literalCredentialCandidates': findings, 'skills': skills,
  43. 'excludedLocalOutputs': ['artifacts/', 'data/', 'work/', 'private database files', 'credentials', 'retired demo drafts'],
  44. 'productionWrites': 0}
  45. target = ROOT / 'work/complete-release-20261106'
  46. target.mkdir(parents=True, exist_ok=True)
  47. (target / 'workspace-audit.json').write_text(json.dumps(out, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
  48. print(json.dumps({k:v for k,v in out.items() if k != 'sourceCandidates'}, ensure_ascii=False, indent=2))
  49. if __name__ == '__main__':
  50. main()