diff --git a/scripts/inbound-plan-resource-fault-executor.mjs b/scripts/inbound-plan-resource-fault-executor.mjs index e98556768..b02e30992 100644 --- a/scripts/inbound-plan-resource-fault-executor.mjs +++ b/scripts/inbound-plan-resource-fault-executor.mjs @@ -61,7 +61,16 @@ export function createInboundResourceRuntime(env, scope, fetcher=globalThis.fetc const text=await response.text();need(!text.includes('MultipleCompilationErrorsException'),'RENDERER_COMPILATION_FAILED_NO_ACTION'); const rows=text.split(/\r?\n/).filter(row=>row.startsWith('INBOUND_RESOURCE_JSON='));need(rows.length===1,'AMBIGUOUS_RESPONSE_NO_AUTO_RETRY'); let result;try {result=JSON.parse(rows[0].slice('INBOUND_RESOURCE_JSON='.length));}catch{throw Error('INVALID_RESPONSE_NO_AUTO_RETRY');} - need(result?.ok===true,/^[A-Z_0-9]{1,90}$/.test(result?.error||'')?result.error:'RUNTIME_ACTION_FAILED');return result.data; + if(result?.ok!==true) { + const error=Error(/^[A-Z_0-9]{1,90}$/.test(result?.error||'')?result.error:'RUNTIME_ACTION_FAILED'); + const d=result?.diagnostic; + if(d&&/^[A-Z_0-9]{1,90}$/.test(d.stage||'')&&/^[A-Za-z_]{1,80}$/.test(d.errorType||'')) + error.runtimeDiagnostic={stage:d.stage,errorType:d.errorType,...(Number.isInteger(d.httpStatus)&&d.httpStatus>=100&&d.httpStatus<=599?{httpStatus:d.httpStatus}:{}), + ...(/^[A-Za-z_]{1,80}$/.test(d.transportErrorType||'')?{transportErrorType:d.transportErrorType}:{}), + ...(Number.isInteger(d.errno)&&d.errno>=-10000&&d.errno<=10000?{errno:d.errno}:{})}; + throw error; + } + return result.data; }; } async function main(argv) { @@ -74,4 +83,4 @@ async function main(argv) { if(args.mode)extra.mode=args.mode;if(args.ttl)extra.ttl=Number(args.ttl); console.log(JSON.stringify(await createInboundResourceRuntime(process.env,scope)(action,extra),null,2)); } -if(process.argv[1]&&import.meta.url===pathToFileURL(resolve(process.argv[1])).href)main(process.argv.slice(2)).catch(error=>{console.error(/^[A-Z_0-9]{1,90}$/.test(error.message)?error.message:'INBOUND_RESOURCE_ARGUMENT_ERROR');process.exitCode=1;}); +if(process.argv[1]&&import.meta.url===pathToFileURL(resolve(process.argv[1])).href)main(process.argv.slice(2)).catch(error=>{console.error(JSON.stringify({error:/^[A-Z_0-9]{1,90}$/.test(error.message)?error.message:'INBOUND_RESOURCE_ARGUMENT_ERROR',diagnostic:error.runtimeDiagnostic}));process.exitCode=1;}); diff --git a/scripts/inbound-plan-resource-fault-executor.test.mjs b/scripts/inbound-plan-resource-fault-executor.test.mjs index f0cb85ce8..bb76d2490 100644 --- a/scripts/inbound-plan-resource-fault-executor.test.mjs +++ b/scripts/inbound-plan-resource-fault-executor.test.mjs @@ -46,6 +46,18 @@ test('transport unknown, duplicate result and malformed result each fail after e } assert.throws(()=>createInboundResourceRuntime({...env,CHONGCHONG_ENV:'prod'},scope)); }); +test('remote error preserves safe stage/type/status and strips untrusted diagnostic fields',async()=>{ + const runtime=createInboundResourceRuntime(env,scope,async()=>({ok:true,text:async()=> 'INBOUND_RESOURCE_JSON='+JSON.stringify({ok:false,error:'NACOS_CONFIG_READ_HTTP_404',diagnostic:{stage:'NACOS_CONFIG_READ',errorType:'HTTPError',httpStatus:404,token:'must-never-escape',body:'secret',url:'http://private.invalid'}})})); + await assert.rejects(runtime('preflight',{routing}),error=>{ + assert.equal(error.message,'NACOS_CONFIG_READ_HTTP_404');assert.deepEqual(error.runtimeDiagnostic,{stage:'NACOS_CONFIG_READ',errorType:'HTTPError',httpStatus:404});return true; + }); +}); +test('DNS diagnostics preserve only bounded errno and transport class, no private resolver message',async()=>{ + const runtime=createInboundResourceRuntime(env,scope,async()=>({ok:true,text:async()=> 'INBOUND_RESOURCE_JSON='+JSON.stringify({ok:false,error:'NACOS_SOURCE_READ_TRANSPORT_FAILED',diagnostic:{stage:'NACOS_SOURCE_READ',errorType:'URLError',transportErrorType:'gaierror',errno:-2,message:'private resolver secret'}})})); + await assert.rejects(runtime('preflight',{routing}),error=>{ + assert.deepEqual(error.runtimeDiagnostic,{stage:'NACOS_SOURCE_READ',errorType:'URLError',transportErrorType:'gaierror',errno:-2});return true; + }); +}); test('default plan CLI has zero HTTP and exposes no business conversion action',()=>{ const dir=mkdtempSync(join(tmpdir(),'inbound-fault-plan-'));const file=join(dir,'plan.json');writeFileSync(file,JSON.stringify(plan)); const run=spawnSync(process.execPath,[fileURLToPath(new URL('./inbound-plan-resource-fault-executor.mjs',import.meta.url)),'plan','--plan',file],{encoding:'utf8',env:{...process.env,CHONGCHONG_ENV:'prod',JENKINS_URL:'http://127.0.0.1:1'}}); diff --git a/scripts/inbound-plan-resource-fault-runtime.py b/scripts/inbound-plan-resource-fault-runtime.py index 9f1473d2e..d1d9f4d2f 100644 --- a/scripts/inbound-plan-resource-fault-runtime.py +++ b/scripts/inbound-plan-resource-fault-runtime.py @@ -18,11 +18,37 @@ import sys import time import urllib.parse import urllib.request +import urllib.error from pathlib import Path TARGET = 'firstleg-test-lastleg' UPSTREAM = 'firstleg-test-warehouse-overseas' PROPERTY = 'baiyue.warehouse.base-url' +STAGE = 'INITIALIZE' + + +def safe_diagnostic(error): + """Never return exception messages, URLs, bodies, environment or identities.""" + stage, status = STAGE, None + if isinstance(error, urllib.error.HTTPError): + endpoint = urllib.parse.urlsplit(error.geturl()).path + stage = {'/nacos/v3/auth/user/login': 'NACOS_LOGIN', + '/nacos/v3/admin/cs/config': 'NACOS_CONFIG_READ'}.get(endpoint, stage) + status = error.code if type(error.code) is int and 100 <= error.code <= 599 else None + kind = type(error).__name__ + diagnostic = {'stage': stage, 'errorType': kind if re.fullmatch(r'[A-Za-z_]{1,80}', kind) else 'Exception'} + if isinstance(error, urllib.error.URLError) and not isinstance(error, urllib.error.HTTPError): + reason = error.reason + reason_type = type(reason).__name__ + if re.fullmatch(r'[A-Za-z_]{1,80}', reason_type): diagnostic['transportErrorType'] = reason_type + number = getattr(reason, 'errno', None) + if type(number) is int and -10000 <= number <= 10000: diagnostic['errno'] = number + if type(status) is int and 100 <= status <= 599: diagnostic['httpStatus'] = status + message = str(error) + code = message if re.fullmatch(r'[A-Z_0-9]{1,90}', message) else 'INBOUND_RESOURCE_ACTION_FAILED' + if status is not None: code = stage + '_HTTP_' + str(status) + elif isinstance(error, urllib.error.URLError): code = stage + '_TRANSPORT_FAILED' + return {'ok': False, 'error': code, 'diagnostic': diagnostic} def validate_scope(scope): @@ -183,15 +209,25 @@ def runtime_class(base_source): def execute(config): + global STAGE + STAGE = 'LOAD_REVIEWED_PRIMITIVES' Runtime, namespace = runtime_class(base64.b64decode(config['baseSource']).decode()) runtime = Runtime(config['scope'], namespace['common_tools'](base64.b64decode(config['commonSource']).decode())) action = config['action'] if action == 'preflight': + STAGE = 'LASTLEG_DOCKER_READ' current = runtime.container() + STAGE = 'WAREHOUSE_UPSTREAM_IDENTITY' target = runtime.verified_upstream(current, config['routing'], runtime.tools['env_of'](current).get('INTERNAL_SERVICE_TOKEN', '')) - return {'originalImageId': current['Image'], 'originalFingerprint': runtime.tools['fingerprint'](current), - 'sourceSha256': runtime.config_fingerprint(current, config['routing']), 'runtimeChanged': False, - 'upstreamImageId': target['Image'], 'upstreamFingerprint': runtime.tools['fingerprint'](target)} + STAGE = 'LASTLEG_FULL_FINGERPRINT' + original_fingerprint = runtime.tools['fingerprint'](current) + STAGE = 'NACOS_SOURCE_READ' + source_hash = runtime.config_fingerprint(current, config['routing']) + STAGE = 'WAREHOUSE_FULL_FINGERPRINT' + upstream_fingerprint = runtime.tools['fingerprint'](target) + return {'originalImageId': current['Image'], 'originalFingerprint': original_fingerprint, + 'sourceSha256': source_hash, 'runtimeChanged': False, + 'upstreamImageId': target['Image'], 'upstreamFingerprint': upstream_fingerprint} if action == 'prepare': return runtime.prepare(config) state = runtime.load() if action == 'status': @@ -234,5 +270,4 @@ def main(config): if __name__ == '__main__': try: print('INBOUND_RESOURCE_JSON=' + json.dumps({'ok': True, 'data': main(json.loads(base64.b64decode(sys.argv[1])))})) except Exception as error: - message = str(error) - print('INBOUND_RESOURCE_JSON=' + json.dumps({'ok': False, 'error': message if re.fullmatch(r'[A-Z_0-9]{1,90}', message) else 'INBOUND_RESOURCE_ACTION_FAILED'})) + print('INBOUND_RESOURCE_JSON=' + json.dumps(safe_diagnostic(error))) diff --git a/scripts/inbound-plan-resource-fault-runtime.test.mjs b/scripts/inbound-plan-resource-fault-runtime.test.mjs index 4e215d9e2..71e7e5e78 100644 --- a/scripts/inbound-plan-resource-fault-runtime.test.mjs +++ b/scripts/inbound-plan-resource-fault-runtime.test.mjs @@ -3,9 +3,19 @@ import assert from 'node:assert/strict'; import {spawnSync} from 'node:child_process'; import {fileURLToPath} from 'node:url'; const script=String.raw` -import importlib.util,sys,json,hashlib,copy +import importlib.util,sys,json,hashlib,copy,urllib.error from pathlib import Path spec=importlib.util.spec_from_file_location('resource_runtime',sys.argv[1]);m=importlib.util.module_from_spec(spec);spec.loader.exec_module(m) +m.STAGE='NACOS_SOURCE_READ' +for endpoint,stage in [('/nacos/v3/auth/user/login','NACOS_LOGIN'),('/nacos/v3/admin/cs/config','NACOS_CONFIG_READ')]: + d=m.safe_diagnostic(urllib.error.HTTPError('http://user:password@private.invalid'+endpoint+'?token=secret',404,'secret body',{},None)) + assert d=={'ok':False,'error':stage+'_HTTP_404','diagnostic':{'stage':stage,'errorType':'HTTPError','httpStatus':404}} + assert 'secret' not in json.dumps(d) and 'private' not in json.dumps(d) +assert m.safe_diagnostic(TypeError('private-secret-response'))=={'ok':False,'error':'INBOUND_RESOURCE_ACTION_FAILED','diagnostic':{'stage':'NACOS_SOURCE_READ','errorType':'TypeError'}} +import socket +dns=m.safe_diagnostic(urllib.error.URLError(socket.gaierror(-2,'private DNS hostname secret'))) +assert dns=={'ok':False,'error':'NACOS_SOURCE_READ_TRANSPORT_FAILED','diagnostic':{'stage':'NACOS_SOURCE_READ','errorType':'URLError','transportErrorType':'gaierror','errno':-2}} +assert 'private' not in json.dumps(dns) and 'secret' not in json.dumps(dns) base=Path(sys.argv[1]).with_name('order-submit-dependency-fault-runtime.py').read_text() C,ns=m.runtime_class(base) scope={'environment':'TEST','marker':'OFFLINE','containerBookingId':'2106714159440728066','warehouseId':'193000000000200003','batchPrefixHash':hashlib.sha256(json.dumps('2106714159440728066').encode()).hexdigest(),'planSha256':'a'*64}