11name : Final security hardening builder
22
33on :
4- push :
4+ pull_request :
55 branches : [main]
66 paths :
7- - " .github/workflows /final-security-hardening-once.yml "
7+ - " .github/triggers /final-security-hardening"
88 workflow_dispatch :
99
1010permissions :
@@ -21,271 +21,16 @@ jobs:
2121 ref : chore/final-security-hardening
2222 fetch-depth : 0
2323
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
24+ - name : Materialize reviewed hardening
25+ run : python3 scripts/build_final_security_hardening_once.py
28126
282- - name : Validate generated proposal locally
27+ - name : Validate generated proposal
28328 shell : bash
28429 run : |
28530 set -euo pipefail
28631 node tests/provider_worker_security.test.cjs
28732 python3 tests/workflow_security_policy_test.py
288- python3 -m py_compile scripts/validate_platform_runtime_policy .py tests/workflow_security_policy_test.py
33+ python3 -m py_compile scripts/build_final_security_hardening_once .py tests/workflow_security_policy_test.py
28934 git diff --check
29035
29136 - name : Build proposal commit
0 commit comments