Skip to content

Commit 8b5d12d

Browse files
committed
ci: simplify final security builder and expose PR trigger
1 parent df9ed57 commit 8b5d12d

1 file changed

Lines changed: 6 additions & 261 deletions

File tree

.github/workflows/final-security-hardening-once.yml

Lines changed: 6 additions & 261 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
name: Final security hardening builder
22

33
on:
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

1010
permissions:
@@ -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

Comments
 (0)