diff --git a/.github/workflows/repository-governance-reusable.yml b/.github/workflows/repository-governance-reusable.yml index 31afe49..bd9e3ce 100644 --- a/.github/workflows/repository-governance-reusable.yml +++ b/.github/workflows/repository-governance-reusable.yml @@ -2,6 +2,12 @@ name: NeoGenesis Repository Governance on: workflow_call: + inputs: + self_test_only: + description: Run validator invariants without evaluating the caller repository. + required: false + type: boolean + default: false permissions: contents: read @@ -29,6 +35,7 @@ jobs: env: REPOSITORY: ${{ github.repository }} GH_TOKEN: ${{ github.token }} + SELF_TEST_ONLY: ${{ inputs.self_test_only }} run: | set -euo pipefail python3 - <<'PY' @@ -41,22 +48,137 @@ jobs: import subprocess import sys import urllib.request + from collections.abc import Mapping root = pathlib.Path('.') repository = os.environ.get('REPOSITORY', '') token = os.environ.get('GH_TOKEN', '') + self_test_only = os.environ.get('SELF_TEST_ONLY', '').lower() == 'true' errors: list[str] = [] + secret_patterns: tuple[tuple[str, re.Pattern[str]], ...] = ( + ('GitHub classic token', re.compile( + r'(? list[str]: + return [label for label, pattern in secret_patterns if pattern.search(line)] + + def setting_finding( + settings: Mapping[str, object], key: str, expected: bool + ) -> str | None: + if key not in settings or type(settings.get(key)) is not bool: + return f'repository setting {key} is UNVERIFIED (field unavailable)' + actual = settings[key] + if actual is not expected: + return f'repository setting {key} must be {expected}, got {actual}' + return None + + def run_validator_self_tests() -> None: + negatives = ( + 'task-execution-listener.ts', + 'risk-classification', + 'desktop-task-service', + 'mask-sensitive-value', + 'desk-state', + 'ask-followup-question', + 'the sk-prefix is discussed without a credential', + ) + for line in negatives: + labels = matching_secret_labels(line) + if labels: + raise AssertionError(f'false-positive match for {line!r}: {labels}') + + positives = { + 'GitHub classic token': 'ghp_' + ('A1' * 18), + 'GitHub fine-grained token': 'github_pat_' + ('A1_' * 14), + 'OpenAI project or service key': 'sk-' + 'proj-' + ('A1_' * 10), + 'OpenAI legacy key': 'sk-' + ('A1' * 16), + 'Anthropic key': 'sk-' + 'ant-' + ('A1_' * 10), + 'xAI key': 'xai-' + ('A1_' * 10), + 'AWS access key': 'AKIA' + ('A1' * 8), + 'Google API key': 'AIza' + ('A1_' * 11) + 'A1', + 'Slack token': 'xoxb-' + ('A1-' * 8), + 'Stripe live secret': 'sk_' + 'live_' + ('A1' * 8), + 'Hugging Face token': 'hf_' + ('A1' * 15), + 'Private key header': '-----BEGIN ' + 'PRIVATE' + ' KEY-----', + } + for expected, line in positives.items(): + observed = matching_secret_labels(line) + if expected not in observed: + raise AssertionError( + f'secret detector missed synthetic {expected}: {observed}' + ) + + expected_unverified = ( + 'repository setting allow_squash_merge is UNVERIFIED ' + '(field unavailable)' + ) + if setting_finding({}, 'allow_squash_merge', True) != expected_unverified: + raise AssertionError('missing setting did not remain UNVERIFIED') + if setting_finding( + {'allow_squash_merge': None}, 'allow_squash_merge', True + ) != expected_unverified: + raise AssertionError('null setting did not remain UNVERIFIED') + if setting_finding( + {'allow_squash_merge': False}, 'allow_squash_merge', True + ) != 'repository setting allow_squash_merge must be True, got False': + raise AssertionError('confirmed mismatch was not rejected') + if setting_finding( + {'allow_squash_merge': True}, 'allow_squash_merge', True + ) is not None: + raise AssertionError('matching setting was rejected') + + run_validator_self_tests() + if self_test_only: + print('repository-governance validator self-test PASS') + raise SystemExit(0) + allowlist_path = root / '.github' / 'repository-governance-allowlist.json' allowlist: dict[str, object] = {} if allowlist_path.is_file(): try: - allowlist = json.loads(allowlist_path.read_text(encoding='utf-8')) - if not isinstance(allowlist, dict): + value = json.loads(allowlist_path.read_text(encoding='utf-8')) + if not isinstance(value, dict): raise ValueError('root must be an object') + allowlist = value except Exception as exc: errors.append(f'invalid governance allowlist: {type(exc).__name__}') - allowlist = {} allowed_paths = { str(value) for value in allowlist.get('allowedPaths', []) @@ -74,25 +196,74 @@ jobs: return errors.append(message) - if not repository or not token: - record('repository identity or GitHub token is unavailable') - settings = {} + settings: dict[str, object] = {} + if not repository or not token or '/' not in repository: + record( + 'repository settings are UNVERIFIED: ' + 'repository identity or GitHub token unavailable' + ) else: + owner, name = repository.split('/', 1) + query = ''' + query RepositoryGovernance($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + defaultBranchRef { name } + squashMergeAllowed + mergeCommitAllowed + rebaseMergeAllowed + deleteBranchOnMerge + allowUpdateBranch + autoMergeAllowed + } + } + ''' request = urllib.request.Request( - f'https://api.github.com/repos/{repository}', + 'https://api.github.com/graphql', + data=json.dumps({ + 'query': query, + 'variables': {'owner': owner, 'name': name}, + }).encode('utf-8'), headers={ 'Accept': 'application/vnd.github+json', 'Authorization': f'Bearer {token}', + 'Content-Type': 'application/json', 'X-GitHub-Api-Version': '2022-11-28', 'User-Agent': 'neogenesis-repository-governance', }, + method='POST', ) try: with urllib.request.urlopen(request, timeout=20) as response: - settings = json.load(response) + payload = json.load(response) + graph_errors = payload.get('errors') if isinstance(payload, dict) else None + repo_data = ( + payload.get('data', {}).get('repository') + if isinstance(payload, dict) + and isinstance(payload.get('data'), dict) + else None + ) + if graph_errors or not isinstance(repo_data, dict): + record('repository settings are UNVERIFIED: GraphQL read failed') + else: + default_ref = repo_data.get('defaultBranchRef') + settings = { + 'allow_squash_merge': repo_data.get('squashMergeAllowed'), + 'allow_merge_commit': repo_data.get('mergeCommitAllowed'), + 'allow_rebase_merge': repo_data.get('rebaseMergeAllowed'), + 'delete_branch_on_merge': repo_data.get('deleteBranchOnMerge'), + 'allow_update_branch': repo_data.get('allowUpdateBranch'), + 'allow_auto_merge': repo_data.get('autoMergeAllowed'), + 'default_branch': ( + default_ref.get('name') + if isinstance(default_ref, dict) + else None + ), + } except Exception as exc: - settings = {} - record(f'cannot read repository settings: {type(exc).__name__}') + record( + f'repository settings are UNVERIFIED: GraphQL ' + f'{type(exc).__name__}' + ) contract = root / 'REPOSITORY_GOVERNANCE.md' if not contract.is_file(): @@ -100,12 +271,21 @@ jobs: else: text = contract.read_text(encoding='utf-8', errors='replace') if 'ng-repo-governance/1.0.0' not in text: - record('repository contract does not identify policy ng-repo-governance/1.0.0') + record( + 'repository contract does not identify policy ' + 'ng-repo-governance/1.0.0' + ) normalized = text.replace('`', '').lower() - if 'unknown' not in normalized or 'must never be reported as pass' not in normalized: + if ( + 'unknown' not in normalized + or 'must never be reported as pass' not in normalized + ): record('repository contract does not define UNKNOWN as non-PASS') if 'presence of this file alone' not in normalized: - record('repository contract does not prohibit documentation-only compliance') + record( + 'repository contract does not prohibit ' + 'documentation-only compliance' + ) required_settings = { 'allow_squash_merge': True, @@ -116,26 +296,38 @@ jobs: 'allow_auto_merge': True, } for key, expected in required_settings.items(): - actual = settings.get(key) - if actual is not expected: - record(f'repository setting {key} must be {expected}, got {actual}') + finding = setting_finding(settings, key, expected) + if finding: + record(finding) + + default_value = settings.get('default_branch') + if not isinstance(default_value, str) or not default_value: + record('repository default branch is UNVERIFIED (field unavailable)') + default_branch = '' + else: + default_branch = default_value - default_branch = str(settings.get('default_branch') or '') approved_exceptions = { 'NeoGenesisAI/neomux-desktop': 'einstein/main', } - task_prefixes = ('codex/', 'rebuild/', 'feature/', 'fix/', 'chore/', 'governance/') + task_prefixes = ( + 'codex/', 'rebuild/', 'feature/', 'fix/', 'chore/', 'governance/' + ) expected_exception = approved_exceptions.get(repository) - if expected_exception: + if expected_exception and default_branch: if default_branch != expected_exception: record( - f'documented default-branch exception drifted: expected {expected_exception}, got {default_branch}' + 'documented default-branch exception drifted: ' + f'expected {expected_exception}, got {default_branch}' ) elif default_branch.startswith(task_prefixes): record(f'task branch is configured as repository default: {default_branch}') tracked = subprocess.check_output(['git', 'ls-files', '-z']).split(b'\0') - tracked_paths = [p.decode('utf-8', errors='surrogateescape') for p in tracked if p] + tracked_paths = [ + value.decode('utf-8', errors='surrogateescape') + for value in tracked if value + ] allowed_env_names = { '.env.example', '.env.sample', '.env.template', '.env.local.example' @@ -151,28 +343,25 @@ jobs: 'node_modules', '__pycache__', '.venv', 'venv', '.next', 'coverage', 'Library', 'Temp', 'obj', '.gradle' } + generated_paths: set[str] = set() for raw in tracked_paths: path = pathlib.PurePosixPath(raw) name = path.name parts = set(path.parts) - if name == '.env' or (name.startswith('.env.') and name not in allowed_env_names): + if name == '.env' or ( + name.startswith('.env.') and name not in allowed_env_names + ): record(f'prohibited environment file is tracked: {raw}', raw) if name in forbidden_names or name.endswith(forbidden_suffixes): record(f'prohibited credential or signing file is tracked: {raw}', raw) - if parts & generated_parts: - record(f'generated or dependency directory is tracked: {raw}', raw) - - secret_patterns = [ - ('GitHub classic token', re.compile(r'gh[pousr]_[A-Za-z0-9]{36,}')), - ('GitHub fine-grained token', re.compile(r'github_pat_[A-Za-z0-9_]{40,}')), - ('OpenAI secret key', re.compile(r'sk-(?:proj-)?[A-Za-z0-9_-]{20,}')), - ('AWS access key', re.compile(r'AKIA[0-9A-Z]{16}')), - ('Google API key', re.compile(r'AIza[0-9A-Za-z_-]{35}')), - ('Slack token', re.compile(r'xox[baprs]-[0-9A-Za-z-]{20,}')), - ('Stripe live secret', re.compile(r'sk_live_[0-9A-Za-z]{16,}')), - ('Private key header', re.compile(r'-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----')), - ] + generated = sorted(parts & generated_parts) + if generated: + generated_paths.add(f'{generated[0]} in {raw}') + + for generated_path in sorted(generated_paths): + record(f'generated or dependency directory is tracked: {generated_path}') + text_extensions = { '.txt', '.md', '.json', '.jsonc', '.yaml', '.yml', '.toml', '.ini', '.env', '.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.py', '.rb', @@ -180,34 +369,53 @@ jobs: '.bash', '.zsh', '.ps1', '.xml', '.html', '.css', '.scss', '.sql', '.gradle', '.properties', '.conf', '.config' } - ignored_markers = ('REDACTED', 'EXAMPLE', 'DUMMY', 'PLACEHOLDER') + special_text_names = {'Dockerfile', 'Makefile', 'Gemfile', 'Podfile'} for raw in tracked_paths: path = root / raw - if raw in allowed_paths: + pure_path = pathlib.PurePosixPath(raw) + if raw in allowed_paths or set(pure_path.parts) & generated_parts: continue if not path.is_file() or path.stat().st_size > 2_000_000: continue - if path.suffix.lower() not in text_extensions and path.name not in { - 'Dockerfile', 'Makefile', 'Gemfile', 'Podfile' - }: + if ( + path.suffix.lower() not in text_extensions + and path.name not in special_text_names + ): continue try: content = path.read_text(encoding='utf-8') except UnicodeDecodeError: continue for line_number, line in enumerate(content.splitlines(), start=1): - if any(marker in line.upper() for marker in ignored_markers): - continue - for label, pattern in secret_patterns: - if pattern.search(line): - record(f'{label} pattern detected at {raw}:{line_number}', raw) + for label in matching_secret_labels(line): + record(f'{label} pattern detected at {raw}:{line_number}', raw) unique_errors = sorted(set(errors)) + summary_path = os.environ.get('GITHUB_STEP_SUMMARY') + summary_lines = [ + '## Repository governance result', + '', + f'- Repository: `{repository or "UNAVAILABLE"}`', + f'- Findings: `{len(unique_errors)}`', + '- Validator self-tests: `PASS`', + ] + if unique_errors: + summary_lines.extend(['', '### Fail-closed findings', '']) + summary_lines.extend(f'- `{item}`' for item in unique_errors) + if summary_path: + pathlib.Path(summary_path).write_text( + '\n'.join(summary_lines) + '\n', encoding='utf-8' + ) + if unique_errors: for item in unique_errors: print(f'::error::{item}') - print(f'repository-governance failed with {len(unique_errors)} unique finding(s)', file=sys.stderr) + print( + f'repository-governance failed with ' + f'{len(unique_errors)} unique finding(s)', + file=sys.stderr, + ) raise SystemExit(1) print('repository-governance PASS') diff --git a/.github/workflows/repository-governance-validator-selftest.yml b/.github/workflows/repository-governance-validator-selftest.yml new file mode 100644 index 0000000..269f695 --- /dev/null +++ b/.github/workflows/repository-governance-validator-selftest.yml @@ -0,0 +1,21 @@ +name: Repository Governance Validator Self-test + +on: + pull_request: + paths: + - ".github/workflows/repository-governance-reusable.yml" + - ".github/workflows/repository-governance-validator-selftest.yml" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: repository-governance-validator-selftest-${{ github.ref }} + cancel-in-progress: true + +jobs: + validator-self-test: + uses: ./.github/workflows/repository-governance-reusable.yml + with: + self_test_only: true