|
| 1 | +name: Final security hardening builder |
| 2 | + |
| 3 | +on: |
| 4 | + push: |
| 5 | + branches: [main] |
| 6 | + paths: |
| 7 | + - ".github/workflows/final-security-hardening-once.yml" |
| 8 | + workflow_dispatch: |
| 9 | + |
| 10 | +permissions: |
| 11 | + contents: write |
| 12 | + |
| 13 | +jobs: |
| 14 | + build: |
| 15 | + runs-on: ubuntu-latest |
| 16 | + timeout-minutes: 10 |
| 17 | + steps: |
| 18 | + - name: Checkout security hardening branch |
| 19 | + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 |
| 20 | + with: |
| 21 | + ref: chore/final-security-hardening |
| 22 | + fetch-depth: 0 |
| 23 | + |
| 24 | + - name: Apply reviewed security hardening |
| 25 | + shell: bash |
| 26 | + run: | |
| 27 | + set -euo pipefail |
| 28 | + python3 - <<'PY' |
| 29 | + from pathlib import Path |
| 30 | +
|
| 31 | + root = Path('.') |
| 32 | +
|
| 33 | + worker_path = root / 'scripts/provider_worker.cjs' |
| 34 | + worker = worker_path.read_text(encoding='utf-8') |
| 35 | + old_restrictions = '''function installModuleRestrictions() { |
| 36 | + const blocked = new Set([ |
| 37 | + 'child_process', 'node:child_process', 'cluster', 'node:cluster', |
| 38 | + 'worker_threads', 'node:worker_threads', 'inspector', 'node:inspector', |
| 39 | + 'repl', 'node:repl', 'vm', 'node:vm', 'module', 'node:module', |
| 40 | + ]); |
| 41 | + const originalLoad = Module._load; |
| 42 | + Module._load = function restrictedLoad(request, parent, isMain) { |
| 43 | + if (blocked.has(String(request))) throw new Error(`provider module blocked: ${request}`); |
| 44 | + return originalLoad.call(this, request, parent, isMain); |
| 45 | + }; |
| 46 | + } |
| 47 | + ''' |
| 48 | + new_restrictions = '''const BLOCKED_PROVIDER_MODULES = new Set([ |
| 49 | + 'child_process', 'cluster', 'worker_threads', 'inspector', 'repl', 'vm', 'module', |
| 50 | + 'fs', 'fs/promises', 'http', 'https', 'http2', 'net', 'tls', 'dgram', |
| 51 | + 'dns', 'dns/promises', 'process', 'wasi', 'sqlite', 'v8', |
| 52 | + ]); |
| 53 | +
|
| 54 | + function canonicalProviderModule(request) { |
| 55 | + const value = String(request || '').trim(); |
| 56 | + return value.startsWith('node:') ? value.slice(5) : value; |
| 57 | + } |
| 58 | +
|
| 59 | + function blockedProviderModule(request) { |
| 60 | + return BLOCKED_PROVIDER_MODULES.has(canonicalProviderModule(request)); |
| 61 | + } |
| 62 | +
|
| 63 | + function installModuleRestrictions() { |
| 64 | + const originalLoad = Module._load; |
| 65 | + Module._load = function restrictedLoad(request, parent, isMain) { |
| 66 | + if (blockedProviderModule(request)) throw new Error(`provider module blocked: ${request}`); |
| 67 | + return originalLoad.call(this, request, parent, isMain); |
| 68 | + }; |
| 69 | +
|
| 70 | + if (typeof process.getBuiltinModule === 'function') { |
| 71 | + const originalGetBuiltinModule = process.getBuiltinModule.bind(process); |
| 72 | + const restrictedGetBuiltinModule = (request) => { |
| 73 | + if (blockedProviderModule(request)) throw new Error(`provider module blocked: ${request}`); |
| 74 | + return originalGetBuiltinModule(request); |
| 75 | + }; |
| 76 | + try { |
| 77 | + Object.defineProperty(process, 'getBuiltinModule', { |
| 78 | + value: restrictedGetBuiltinModule, |
| 79 | + configurable: true, |
| 80 | + writable: false, |
| 81 | + }); |
| 82 | + } catch { |
| 83 | + try { process.getBuiltinModule = restrictedGetBuiltinModule; } catch {} |
| 84 | + } |
| 85 | + } |
| 86 | + } |
| 87 | +
|
| 88 | + function assertProviderSourcePolicy(sourceText) { |
| 89 | + const patterns = [ |
| 90 | + /\\b(?:require|import)\\s*\\(\\s*['\"`]([^'\"`]+)['\"`]\\s*\\)/g, |
| 91 | + /\\b(?:from|import)\\s+['\"`]([^'\"`]+)['\"`]/g, |
| 92 | + /\\bprocess\\s*\\.\\s*getBuiltinModule\\s*\\(\\s*['\"`]([^'\"`]+)['\"`]\\s*\\)/g, |
| 93 | + ]; |
| 94 | + for (const pattern of patterns) { |
| 95 | + for (const match of String(sourceText || '').matchAll(pattern)) { |
| 96 | + const request = match[1]; |
| 97 | + if (blockedProviderModule(request)) { |
| 98 | + throw new Error(`provider module blocked by source policy: ${request}`); |
| 99 | + } |
| 100 | + } |
| 101 | + } |
| 102 | + } |
| 103 | + ''' |
| 104 | + if old_restrictions not in worker: |
| 105 | + raise SystemExit('provider worker restriction anchor changed') |
| 106 | + worker = worker.replace(old_restrictions, new_restrictions, 1) |
| 107 | + old_loader = '''async function loadProvider(filePath) { |
| 108 | + installModuleRestrictions(); |
| 109 | + try { |
| 110 | + return require(filePath); |
| 111 | + } catch (requireError) { |
| 112 | + ''' |
| 113 | + new_loader = '''async function loadProvider(filePath) { |
| 114 | + const sourceText = fs.readFileSync(filePath, 'utf8'); |
| 115 | + assertProviderSourcePolicy(sourceText); |
| 116 | + installModuleRestrictions(); |
| 117 | + try { |
| 118 | + return require(filePath); |
| 119 | + } catch (requireError) { |
| 120 | + ''' |
| 121 | + if old_loader not in worker: |
| 122 | + raise SystemExit('provider worker loader anchor changed') |
| 123 | + worker = worker.replace(old_loader, new_loader, 1) |
| 124 | + worker_path.write_text(worker, encoding='utf-8') |
| 125 | +
|
| 126 | + health_path = root / 'scripts/health_check.mjs' |
| 127 | + health = health_path.read_text(encoding='utf-8') |
| 128 | + old_spawn = ''' const child = spawn(process.execPath, [ |
| 129 | + `--max-old-space-size=${workerMemoryMb}`, |
| 130 | + WORKER_PATH, |
| 131 | + providerPath, |
| 132 | + ''' |
| 133 | + new_spawn = ''' const child = spawn(process.execPath, [ |
| 134 | + `--max-old-space-size=${workerMemoryMb}`, |
| 135 | + '--permission', |
| 136 | + `--allow-fs-read=${path.join(ROOT, 'scripts')}`, |
| 137 | + `--allow-fs-read=${path.join(ROOT, 'node_modules')}`, |
| 138 | + `--allow-fs-read=${path.join(ROOT, 'package.json')}`, |
| 139 | + `--allow-fs-read=${STAGE}`, |
| 140 | + WORKER_PATH, |
| 141 | + providerPath, |
| 142 | + ''' |
| 143 | + if old_spawn not in health: |
| 144 | + raise SystemExit('health worker spawn anchor changed') |
| 145 | + health = health.replace(old_spawn, new_spawn, 1) |
| 146 | + health_path.write_text(health, encoding='utf-8') |
| 147 | +
|
| 148 | + security_test = r'''#!/usr/bin/env node |
| 149 | + 'use strict'; |
| 150 | + const assert = require('node:assert'); |
| 151 | + const fs = require('node:fs'); |
| 152 | + const os = require('node:os'); |
| 153 | + const path = require('node:path'); |
| 154 | + const { spawnSync } = require('node:child_process'); |
| 155 | + const root = path.resolve(__dirname, '..'); |
| 156 | + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'nuvio-worker-security-')); |
| 157 | + const fixture = JSON.stringify({tmdbId:'1',mediaType:'movie',title:'Test'}); |
| 158 | + const context = JSON.stringify({locale:'fr-FR',networkLimits:{maxFetches:1}}); |
| 159 | +
|
| 160 | + function runProvider(source) { |
| 161 | + const provider = path.join(dir, `provider-${Math.random().toString(16).slice(2)}.cjs`); |
| 162 | + fs.writeFileSync(provider, source); |
| 163 | + return spawnSync(process.execPath,[path.join(root,'scripts/provider_worker.cjs'),provider,fixture,context],{cwd:root,encoding:'utf8'}); |
| 164 | + } |
| 165 | +
|
| 166 | + for (const request of ['node:child_process','node:fs','node:https','node:net','node:dns']) { |
| 167 | + const result = runProvider(`module.exports={getStreams(){require('${request}');return []}}`); |
| 168 | + assert.match(result.stdout, new RegExp(`provider module blocked: ${request.replace(/[.*+?^${}()|[\\]\\]/g,'\\$&')}`)); |
| 169 | + } |
| 170 | +
|
| 171 | + if (typeof process.getBuiltinModule === 'function') { |
| 172 | + const result = runProvider(`module.exports={getStreams(){process.getBuiltinModule('fs');return []}}`); |
| 173 | + assert.match(result.stdout, /provider module blocked(?: by source policy)?: fs/); |
| 174 | + } |
| 175 | +
|
| 176 | + { |
| 177 | + const result = runProvider(`module.exports={async getStreams(){await import('node:http');return []}}`); |
| 178 | + assert.match(result.stdout, /provider module blocked by source policy: node:http/); |
| 179 | + } |
| 180 | +
|
| 181 | + { |
| 182 | + const result = runProvider(`const cheerio=require('cheerio-without-node-native');module.exports={getStreams(){const $=cheerio.load('<div>ok</div>');if($('div').text()!=='ok')throw new Error('parser failed');return []}}`); |
| 183 | + assert.strictEqual(result.status, 0, result.stderr || result.stdout); |
| 184 | + assert.match(result.stdout, /NUVIO_HEALTH_RESULT=/); |
| 185 | + } |
| 186 | +
|
| 187 | + console.log('provider worker security tests passed'); |
| 188 | + ''' |
| 189 | + # Remove indentation introduced by the Python raw string formatting. |
| 190 | + security_test = '\n'.join(line[10:] if line.startswith(' ') else line for line in security_test.splitlines()) + '\n' |
| 191 | + (root / 'tests/provider_worker_security.test.cjs').write_text(security_test, encoding='utf-8') |
| 192 | +
|
| 193 | + workflow_test = r'''#!/usr/bin/env python3 |
| 194 | + from __future__ import annotations |
| 195 | +
|
| 196 | + import re |
| 197 | + from pathlib import Path |
| 198 | +
|
| 199 | + ROOT = Path(__file__).resolve().parents[1] |
| 200 | + WORKFLOWS = ROOT / '.github' / 'workflows' |
| 201 | + SHA = re.compile(r'^[0-9a-f]{40}$', re.IGNORECASE) |
| 202 | + USES = re.compile(r'^\s*(?:-\s*)?uses:\s*([^\s#]+)', re.MULTILINE) |
| 203 | + errors: list[str] = [] |
| 204 | +
|
| 205 | + for path in sorted([*WORKFLOWS.glob('*.yml'), *WORKFLOWS.glob('*.yaml')]): |
| 206 | + text = path.read_text(encoding='utf-8') |
| 207 | + rel = path.relative_to(ROOT) |
| 208 | + if re.search(r'^\s*pull_request_target\s*:', text, re.MULTILINE): |
| 209 | + errors.append(f'{rel}: pull_request_target is forbidden') |
| 210 | + if re.search(r'^\s*permissions\s*:\s*write-all\s*$', text, re.MULTILINE): |
| 211 | + errors.append(f'{rel}: permissions write-all is forbidden') |
| 212 | + if 'permissions:' not in text: |
| 213 | + errors.append(f'{rel}: explicit permissions are required') |
| 214 | + for match in USES.finditer(text): |
| 215 | + value = match.group(1).strip('"\'') |
| 216 | + if value.startswith('./'): |
| 217 | + continue |
| 218 | + if value.startswith('docker://'): |
| 219 | + if '@sha256:' not in value: |
| 220 | + errors.append(f'{rel}: Docker action/image must be digest-pinned: {value}') |
| 221 | + continue |
| 222 | + if '@' not in value: |
| 223 | + errors.append(f'{rel}: action/ref missing immutable pin: {value}') |
| 224 | + continue |
| 225 | + _action, ref = value.rsplit('@', 1) |
| 226 | + if not SHA.fullmatch(ref): |
| 227 | + errors.append(f'{rel}: external action must use full commit SHA: {value}') |
| 228 | +
|
| 229 | + if errors: |
| 230 | + raise SystemExit('workflow security policy failed:\n- ' + '\n- '.join(errors)) |
| 231 | + print('workflow security policy tests passed') |
| 232 | + ''' |
| 233 | + workflow_test = '\n'.join(line[10:] if line.startswith(' ') else line for line in workflow_test.splitlines()) + '\n' |
| 234 | + (root / 'tests/workflow_security_policy_test.py').write_text(workflow_test, encoding='utf-8') |
| 235 | +
|
| 236 | + codeowners = '''# Default ownership keeps review routing explicit for every repository change. |
| 237 | + * @niakw |
| 238 | +
|
| 239 | + # Security- and publication-sensitive surfaces. |
| 240 | + /.github/ @niakw |
| 241 | + /engine_v2/ @niakw |
| 242 | + /scripts/ @niakw |
| 243 | + /tests/ @niakw |
| 244 | + /provider_catalog.json @niakw |
| 245 | + /provider-overrides.json @niakw |
| 246 | + /manifest.json @niakw |
| 247 | + /vf/manifest.json @niakw |
| 248 | + /package.json @niakw |
| 249 | + /package-lock.json @niakw |
| 250 | + /SECURITY.md @niakw |
| 251 | + ''' |
| 252 | + codeowners = '\n'.join(line[10:] if line.startswith(' ') else line for line in codeowners.splitlines()) + '\n' |
| 253 | + (root / '.github' / 'CODEOWNERS').write_text(codeowners, encoding='utf-8') |
| 254 | +
|
| 255 | + dep_path = root / '.github/workflows/dependency-gate.yml' |
| 256 | + dep = dep_path.read_text(encoding='utf-8') |
| 257 | + dep = dep.replace( |
| 258 | + ' - name: Verify upgraded runtime imports\n run: node -e "require(\'axios\'); require(\'cheerio\'); console.log(\'dependency imports ok\')"\n', |
| 259 | + ' - name: Audit non-optional production dependencies\n run: npm audit --audit-level=high --omit=optional\n\n - name: Verify upgraded runtime imports\n run: node -e "require(\'axios\'); require(\'cheerio\'); console.log(\'dependency imports ok\')"\n', |
| 260 | + 1, |
| 261 | + ) |
| 262 | + dep = dep.replace(' import subprocess\n from pathlib import Path\n', ' import shlex\n import subprocess\n from pathlib import Path\n', 1) |
| 263 | + dep = dep.replace(' subprocess.run(command, shell=True, check=True)\n', ' subprocess.run(shlex.split(command), check=True)\n', 1) |
| 264 | + dep_path.write_text(dep, encoding='utf-8') |
| 265 | +
|
| 266 | + actions_path = root / '.github/workflows/github-actions-gate.yml' |
| 267 | + actions = actions_path.read_text(encoding='utf-8') |
| 268 | + actions = actions.replace(' - ".github/workflows/**"\n', ' - ".github/workflows/**"\n - "tests/workflow_security_policy_test.py"\n', 1) |
| 269 | + actions = actions.replace(' python3 tests/ci_preservation_policy_test.py\n', ' python3 tests/workflow_security_policy_test.py\n python3 tests/ci_preservation_policy_test.py\n', 1) |
| 270 | + actions_path.write_text(actions, encoding='utf-8') |
| 271 | +
|
| 272 | + export_path = root / '.github/workflows/provider-status-export.yml' |
| 273 | + export = export_path.read_text(encoding='utf-8') |
| 274 | + export = export.replace(" types: [completed]\n", " types: [completed]\n branches: [main]\n", 1) |
| 275 | + export_path.write_text(export, encoding='utf-8') |
| 276 | +
|
| 277 | + security_path = root / 'SECURITY.md' |
| 278 | + security = security_path.read_text(encoding='utf-8').rstrip() + '''\n\n## Repository and runtime hardening\n\nGitHub Actions use explicit least-privilege permissions and immutable full-length action SHAs. A repository policy test rejects `pull_request_target`, `write-all`, mutable external Action refs, and unpinned Docker action images. `CODEOWNERS` routes all changes — especially workflows, publication state, scripts and security controls — to the repository owner for review.\n\nThe provider worker is a defense-in-depth compatibility sandbox: it receives a reduced environment, blocks process spawning plus direct filesystem/network-capable Node modules, filters `process.getBuiltinModule`, rejects static dangerous imports, and is launched by health checks under Node's Permission Model with read-only access limited to the worker scripts, dependencies and staged provider inputs. Provider network access must go through the guarded `fetch` surface so SSRF, redirect, host-count and response-size limits remain effective. This still does not claim perfect hostile-code isolation; OS/container isolation remains the stronger long-term boundary.\n\nThe dependency gate installs the committed lockfile with lifecycle scripts disabled, checks high-severity advisories for the non-optional production tree, and runs the deterministic repository suite. The legacy `cheerio-without-node-native` package remains intentionally pinned because published upstream bundles still import it; replacing it requires provider compatibility evidence rather than a blind dependency substitution.\n\nFor a potentially sensitive vulnerability, avoid publishing exploit details in a normal public issue. Prefer GitHub private vulnerability reporting when available for the repository, or contact the repository owner privately.\n''' |
| 279 | + security_path.write_text(security, encoding='utf-8') |
| 280 | + PY |
| 281 | +
|
| 282 | + - name: Validate generated proposal locally |
| 283 | + shell: bash |
| 284 | + run: | |
| 285 | + set -euo pipefail |
| 286 | + node tests/provider_worker_security.test.cjs |
| 287 | + python3 tests/workflow_security_policy_test.py |
| 288 | + python3 -m py_compile scripts/validate_platform_runtime_policy.py tests/workflow_security_policy_test.py |
| 289 | + git diff --check |
| 290 | +
|
| 291 | + - name: Build proposal commit |
| 292 | + shell: bash |
| 293 | + run: | |
| 294 | + set -euo pipefail |
| 295 | + git config user.name "niakvio-security" |
| 296 | + git config user.email "actions@users.noreply.github.com" |
| 297 | + git add scripts/provider_worker.cjs scripts/health_check.mjs tests/provider_worker_security.test.cjs \ |
| 298 | + tests/workflow_security_policy_test.py .github/CODEOWNERS SECURITY.md \ |
| 299 | + .github/workflows/dependency-gate.yml .github/workflows/github-actions-gate.yml \ |
| 300 | + .github/workflows/provider-status-export.yml |
| 301 | + git commit -m "security: harden provider sandbox and repository gates" |
| 302 | + echo "FIELD_SECURITY_HARDENING_COMMIT=$(git rev-parse HEAD)" |
| 303 | + git push origin HEAD:chore/final-security-hardening |
0 commit comments