gowalk-cicd 1.0.129 → 1.0.130
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +6 -0
- package/README.md +7 -1
- package/action/.daemux-version +1 -1
- package/android-action/.daemux-version +1 -1
- package/backend-action/.daemux-version +1 -1
- package/backend-action/action.yml +15 -1
- package/backend-action/identity_diagnostics.py +26 -3
- package/backend-action/scripts/artifact_env.cjs +10 -0
- package/backend-action/scripts/cache_scope.cjs +83 -0
- package/backend-action/test_identity_artifact.py +77 -0
- package/package.json +1 -1
package/CLAUDE.md
CHANGED
|
@@ -434,3 +434,9 @@ workflow after a protected package release/adoption; never run hand-SSH or expos
|
|
|
434
434
|
The diagnostic reader accepts the product-independent standard WARNING logger envelope, discards its
|
|
435
435
|
logger name and rejects trailing fields. Structured remote failure codes survive exit1; SSH transport,
|
|
436
436
|
read bounds and scope refusals have a closed vocabulary. Unknown exceptions remain unclassified.
|
|
437
|
+
|
|
438
|
+
The same validated envelope is retained in the dedicated `backend-identity-diagnostics-<job>-<attempt>`
|
|
439
|
+
artifact on success and classified reader failure. Download `result.json` from that exact run;
|
|
440
|
+
GitHub secret masking can alter structural braces in logs. The artifact contains only schema,
|
|
441
|
+
ok, fixed failure and fixed stage/reason records. Raw host output, configuration and identities
|
|
442
|
+
never enter it. Its pinned upload uses the same process-scoped artifact transport as mobile actions.
|
package/README.md
CHANGED
|
@@ -1229,6 +1229,12 @@ it does not prove authentication succeeded. Match the bounded observation window
|
|
|
1229
1229
|
|
|
1230
1230
|
Reader failures retain only: `invalid_scope`, `invalid_window`, `invalid_destination`,
|
|
1231
1231
|
`container_ambiguous_or_absent`, `container_scope_mismatch`, `command_timeout`, `output_limit`,
|
|
1232
|
-
`command_failed`, `transport_failure`, `remote_invalid`, or `unclassified`. Structured remote refusals
|
|
1232
|
+
`command_failed`, `transport_failure`, `remote_invalid`, `artifact_write_failed`, or `unclassified`. Structured remote refusals
|
|
1233
1233
|
survive a remote exit1. SSH transport failures remain distinct. Unknown exceptions never contribute text.
|
|
1234
1234
|
The optional standard `WARNING:<logger>:` prefix is discarded; logger names and trailing fields are never returned.
|
|
1235
|
+
|
|
1236
|
+
Download the dedicated `backend-identity-diagnostics-<job>-<attempt>` artifact from that exact run.
|
|
1237
|
+
Its `result.json` contains the validated envelope on success and classified failure, without raw logs,
|
|
1238
|
+
headers, environment values, provider exceptions or identities. Normal GitHub artifact integrity checks
|
|
1239
|
+
apply. The console copy is supplementary: secret masking can replace JSON punctuation in logs.
|
|
1240
|
+
Each invocation uses a fresh local directory so a failed reader cannot publish a prior result.
|
package/action/.daemux-version
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.130
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.130
|
|
@@ -1 +1 @@
|
|
|
1
|
-
1.0.
|
|
1
|
+
1.0.130
|
|
@@ -182,6 +182,7 @@ runs:
|
|
|
182
182
|
'${{ inputs.health-path }}' '${{ inputs.cert-email }}'"
|
|
183
183
|
|
|
184
184
|
- name: Read fixed backend identity diagnostics
|
|
185
|
+
id: identity_diagnostics
|
|
185
186
|
if: inputs.operation == 'identity-diagnostics'
|
|
186
187
|
shell: bash
|
|
187
188
|
env:
|
|
@@ -191,9 +192,22 @@ runs:
|
|
|
191
192
|
DIAGNOSTIC_HOST: ${{ inputs.host }}
|
|
192
193
|
DIAGNOSTIC_USER: ${{ inputs.ssh-user }}
|
|
193
194
|
run: |
|
|
195
|
+
RESULT_DIR="$(mktemp -d "$RUNNER_TEMP/backend-identity-diagnostics.XXXXXX")"
|
|
196
|
+
echo "result=$RESULT_DIR/result.json" >> "$GITHUB_OUTPUT"
|
|
194
197
|
python3 "${{ github.action_path }}/identity_diagnostics.py" \
|
|
195
198
|
--app "$DIAGNOSTIC_APP" --service "$DIAGNOSTIC_SERVICE" --since "$DIAGNOSTIC_SINCE" \
|
|
196
|
-
--host "$DIAGNOSTIC_HOST" --user "$DIAGNOSTIC_USER"
|
|
199
|
+
--host "$DIAGNOSTIC_HOST" --user "$DIAGNOSTIC_USER" \
|
|
200
|
+
--output "$RESULT_DIR/result.json"
|
|
201
|
+
|
|
202
|
+
- name: Retain fixed backend identity result
|
|
203
|
+
if: always() && inputs.operation == 'identity-diagnostics'
|
|
204
|
+
uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5
|
|
205
|
+
env:
|
|
206
|
+
NODE_OPTIONS: --require "${{ github.action_path }}/scripts/artifact_env.cjs"
|
|
207
|
+
with:
|
|
208
|
+
name: backend-identity-diagnostics-${{ github.job }}-${{ github.run_attempt }}
|
|
209
|
+
path: ${{ steps.identity_diagnostics.outputs.result }}
|
|
210
|
+
if-no-files-found: error
|
|
197
211
|
|
|
198
212
|
- name: Cleanup key
|
|
199
213
|
if: always()
|
|
@@ -30,6 +30,7 @@ FAILURES = frozenset({
|
|
|
30
30
|
'unclassified', 'invalid_scope', 'invalid_window', 'invalid_destination',
|
|
31
31
|
'container_ambiguous_or_absent', 'container_scope_mismatch', 'command_timeout',
|
|
32
32
|
'output_limit', 'command_failed', 'transport_failure', 'remote_invalid',
|
|
33
|
+
'artifact_write_failed',
|
|
33
34
|
})
|
|
34
35
|
|
|
35
36
|
|
|
@@ -178,6 +179,17 @@ def decode_remote(status, raw):
|
|
|
178
179
|
return result
|
|
179
180
|
|
|
180
181
|
|
|
182
|
+
def retain_result(path, result):
|
|
183
|
+
"""Retain only the already validated public envelope, never captured host output."""
|
|
184
|
+
try:
|
|
185
|
+
target = Path(path)
|
|
186
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
with target.open('x', encoding='utf-8') as output:
|
|
188
|
+
output.write(json.dumps(result, separators=(',', ':')) + '\n')
|
|
189
|
+
except OSError:
|
|
190
|
+
raise ReaderFailure('artifact_write_failed') from None
|
|
191
|
+
|
|
192
|
+
|
|
181
193
|
def main():
|
|
182
194
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
183
195
|
parser.add_argument('--remote', action='store_true')
|
|
@@ -186,16 +198,27 @@ def main():
|
|
|
186
198
|
parser.add_argument('--since', required=True)
|
|
187
199
|
parser.add_argument('--host')
|
|
188
200
|
parser.add_argument('--user', default='root')
|
|
201
|
+
parser.add_argument('--output', help='Exclusive local file for the closed diagnostic envelope')
|
|
189
202
|
args = parser.parse_args()
|
|
203
|
+
status = 0
|
|
190
204
|
try:
|
|
205
|
+
if args.remote and args.output:
|
|
206
|
+
raise ReaderFailure('invalid_destination')
|
|
191
207
|
result = remote(args.app, args.service, args.since) if args.remote else local(args)
|
|
208
|
+
result = decode_remote(0, json.dumps(result).encode())
|
|
192
209
|
except Exception as error:
|
|
193
210
|
# Only our closed vocabulary survives; provider and parser exceptions remain unclassified.
|
|
194
211
|
code = error.code if isinstance(error, ReaderFailure) else 'unclassified'
|
|
195
|
-
|
|
196
|
-
|
|
212
|
+
result = {'schema': SCHEMA, 'ok': False, 'failure': code, 'records': []}
|
|
213
|
+
status = 1
|
|
214
|
+
if args.output and not args.remote:
|
|
215
|
+
try:
|
|
216
|
+
retain_result(args.output, result)
|
|
217
|
+
except ReaderFailure as error:
|
|
218
|
+
result = {'schema': SCHEMA, 'ok': False, 'failure': error.code, 'records': []}
|
|
219
|
+
status = 1
|
|
197
220
|
print(json.dumps(result, separators=(',', ':')))
|
|
198
|
-
return
|
|
221
|
+
return status
|
|
199
222
|
|
|
200
223
|
|
|
201
224
|
if __name__ == '__main__':
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Only the exact pinned upload-artifact process receives this GitHub-specific transport.
|
|
2
|
+
try {
|
|
3
|
+
const scope = require('./cache_scope.cjs');
|
|
4
|
+
scope.authorize(process.argv[1], process.env, 'artifact');
|
|
5
|
+
scope.guardRequests();
|
|
6
|
+
for (const key of scope.proxyKeys) delete process.env[key];
|
|
7
|
+
} catch {
|
|
8
|
+
process.stderr.write('github_artifact_transport_scope_refused\n');
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Direct routing belongs only to pinned first-party Actions programs and approved HTTPS origins.
|
|
2
|
+
const {readFileSync, copyFileSync, mkdtempSync, appendFileSync} = require('node:fs');
|
|
3
|
+
const {createHash} = require('node:crypto');
|
|
4
|
+
const {basename, dirname, join} = require('node:path');
|
|
5
|
+
const hashes = new Map([
|
|
6
|
+
['restore', 'a49301428b4c5477522c00f6464566457ed327d64ae5f80f8c12e762fb2ea9a2'],
|
|
7
|
+
['save', 'b969065795aaf5c6d20ba158e910be5d353eefb02eb2785263eb32e13090c066'],
|
|
8
|
+
['restore-only', '6255afaa3956351b8cfefc1e82f026b4db418a10678a8eaddf3d6c55f81744de'],
|
|
9
|
+
['save-only', '7fb63f90f06ce6a10f39d40a113f99791bffdedad5394cdad5b4dfaa644559cb'],
|
|
10
|
+
]);
|
|
11
|
+
const artifactHash = '168c44946cba03564808c19b83a72601e6d1d6f57081dbd2d6e2ea1cedc522f5';
|
|
12
|
+
const proxyKeys = ['HTTP_PROXY', 'HTTPS_PROXY', 'http_proxy', 'https_proxy', 'ALL_PROXY', 'all_proxy'];
|
|
13
|
+
const refusal = () => new Error('github_cache_transport_scope_refused');
|
|
14
|
+
|
|
15
|
+
function allowedHost(host) {
|
|
16
|
+
return host === 'api.github.com'
|
|
17
|
+
|| /^[a-z0-9.-]+\.actions\.githubusercontent\.com$/.test(host)
|
|
18
|
+
|| /^[a-z0-9]{3,24}\.blob\.core\.windows\.net$/.test(host);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function requireOrigin(value, service = false) {
|
|
22
|
+
let url;
|
|
23
|
+
try { url = new URL(value); } catch { throw refusal(); }
|
|
24
|
+
if (url.protocol !== 'https:' || url.port || url.username || url.password || !allowedHost(url.hostname)
|
|
25
|
+
|| (service && !url.hostname.endsWith('.actions.githubusercontent.com'))) throw refusal();
|
|
26
|
+
return url;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function authorize(entrypoint, env, kind = 'cache') {
|
|
30
|
+
if (!entrypoint || basename(entrypoint) !== 'index.js' || basename(dirname(dirname(entrypoint))) !== 'dist') {
|
|
31
|
+
throw refusal();
|
|
32
|
+
}
|
|
33
|
+
if (!['cache', 'artifact'].includes(kind)) throw refusal();
|
|
34
|
+
const program = basename(dirname(entrypoint));
|
|
35
|
+
const expected = kind === 'artifact' ? (program === 'upload' && artifactHash) : hashes.get(program);
|
|
36
|
+
if (!expected || createHash('sha256').update(readFileSync(entrypoint)).digest('hex') !== expected) throw refusal();
|
|
37
|
+
const origins = (kind === 'artifact' ? [env.ACTIONS_RESULTS_URL]
|
|
38
|
+
: [env.ACTIONS_RESULTS_URL, env.ACTIONS_CACHE_URL]).filter(Boolean);
|
|
39
|
+
if (!origins.length) throw refusal();
|
|
40
|
+
for (const origin of origins) requireOrigin(origin, true);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function requireRequest(args, protocol) {
|
|
44
|
+
const [input, extra] = args;
|
|
45
|
+
const options = typeof input === 'string' || input instanceof URL
|
|
46
|
+
? {protocol, ...require('node:url').urlToHttpOptions(requireOrigin(input)),
|
|
47
|
+
...(typeof extra === 'object' ? extra : {})}
|
|
48
|
+
: {...input};
|
|
49
|
+
if (!options || options.socketPath || (options.protocol || protocol) !== 'https:') throw refusal();
|
|
50
|
+
for (const host of [options.hostname, options.host].filter(Boolean)) {
|
|
51
|
+
requireOrigin(`https://${host}${options.port ? ':' + options.port : ''}`);
|
|
52
|
+
}
|
|
53
|
+
if (!options.hostname && !options.host) throw refusal();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function guardRequests() {
|
|
57
|
+
for (const [name, protocol] of [['node:http', 'http:'], ['node:https', 'https:']]) {
|
|
58
|
+
const client = require(name);
|
|
59
|
+
for (const verb of ['request', 'get']) {
|
|
60
|
+
const original = client[verb];
|
|
61
|
+
client[verb] = function (...args) { requireRequest(args, protocol); return original.apply(this, args); };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (globalThis.fetch) {
|
|
65
|
+
const original = globalThis.fetch;
|
|
66
|
+
globalThis.fetch = function (input, init) {
|
|
67
|
+
requireOrigin(typeof input === 'string' || input instanceof URL ? input : input.url);
|
|
68
|
+
return original.call(this, input, {...init, redirect: 'error'});
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function snapshot() {
|
|
74
|
+
const target = mkdtempSync(join(process.env.RUNNER_TEMP, 'gowalk-cache-scope-'));
|
|
75
|
+
for (const file of ['cache_env.cjs', 'cache_scope.cjs']) copyFileSync(join(__dirname, file), join(target, file));
|
|
76
|
+
appendFileSync(process.env.GITHUB_OUTPUT, `preloader=${join(target, 'cache_env.cjs')}\n`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = {authorize, guardRequests, proxyKeys, requireOrigin};
|
|
80
|
+
if (require.main === module) {
|
|
81
|
+
try { if (process.argv[2] !== '--snapshot') throw refusal(); snapshot(); }
|
|
82
|
+
catch { process.stderr.write('github_cache_scope_snapshot_failed\n'); process.exitCode = 1; }
|
|
83
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""The dedicated artifact carries the complete closed result independently of log masking."""
|
|
2
|
+
from contextlib import redirect_stdout
|
|
3
|
+
import hashlib
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import unittest
|
|
10
|
+
from unittest.mock import patch
|
|
11
|
+
from test_identity_diagnostics import READER, SINCE
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class IdentityArtifact(unittest.TestCase):
|
|
15
|
+
def invoke(self, path, *, result=None, error=None, remote=False):
|
|
16
|
+
argv = ['reader', '--app', 'fixture', '--service', 'api', '--since', SINCE, '--output', str(path)]
|
|
17
|
+
if remote:
|
|
18
|
+
argv.append('--remote')
|
|
19
|
+
output = io.StringIO()
|
|
20
|
+
with patch.object(sys, 'argv', argv), patch.object(READER, 'local', return_value=result,
|
|
21
|
+
side_effect=error), redirect_stdout(output):
|
|
22
|
+
status = READER.main()
|
|
23
|
+
return status, output.getvalue()
|
|
24
|
+
|
|
25
|
+
def test_success_file_survives_structural_log_redaction_byte_for_byte(self):
|
|
26
|
+
result = {'schema': READER.SCHEMA, 'ok': True, 'failure': '',
|
|
27
|
+
'records': [{'stage': 'auth_signature', 'reason': 'token_invalid'}]}
|
|
28
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
29
|
+
path = Path(folder) / 'result.json'
|
|
30
|
+
status, log = self.invoke(path, result=result)
|
|
31
|
+
data = path.read_bytes()
|
|
32
|
+
self.assertEqual(status, 0)
|
|
33
|
+
self.assertEqual(json.loads(data), result)
|
|
34
|
+
self.assertNotEqual(log.replace('{', '***').replace('}', '***'), data.decode())
|
|
35
|
+
self.assertEqual(hashlib.sha256(data).digest(), hashlib.sha256(log.encode()).digest())
|
|
36
|
+
|
|
37
|
+
def test_classified_and_unknown_failures_are_retained_without_exception_data(self):
|
|
38
|
+
for failure, expected in [(READER.ReaderFailure('container_scope_mismatch'), 'container_scope_mismatch'),
|
|
39
|
+
(RuntimeError('PRIVATE_SENTINEL'), 'unclassified')]:
|
|
40
|
+
with self.subTest(expected=expected), tempfile.TemporaryDirectory() as folder:
|
|
41
|
+
path = Path(folder) / 'result.json'
|
|
42
|
+
status, log = self.invoke(path, error=failure)
|
|
43
|
+
self.assertEqual(status, 1)
|
|
44
|
+
self.assertEqual(json.loads(path.read_bytes()), {
|
|
45
|
+
'schema': READER.SCHEMA, 'ok': False, 'failure': expected, 'records': []})
|
|
46
|
+
self.assertNotIn('PRIVATE_SENTINEL', log + path.read_text())
|
|
47
|
+
|
|
48
|
+
def test_unvalidated_fields_never_reach_artifact(self):
|
|
49
|
+
invalid = {'schema': READER.SCHEMA, 'ok': True, 'failure': '', 'records': [], 'raw': 'PRIVATE_SENTINEL'}
|
|
50
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
51
|
+
path = Path(folder) / 'result.json'
|
|
52
|
+
status, log = self.invoke(path, result=invalid)
|
|
53
|
+
self.assertEqual(status, 1)
|
|
54
|
+
self.assertEqual(json.loads(path.read_text())['failure'], 'remote_invalid')
|
|
55
|
+
self.assertNotIn('PRIVATE_SENTINEL', log + path.read_text())
|
|
56
|
+
|
|
57
|
+
def test_existing_file_is_preserved_and_write_failure_is_closed(self):
|
|
58
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
59
|
+
path = Path(folder) / 'result.json'; path.write_text('existing')
|
|
60
|
+
status, log = self.invoke(path, error=READER.ReaderFailure('invalid_scope'))
|
|
61
|
+
self.assertEqual(status, 1)
|
|
62
|
+
self.assertEqual(json.loads(log)['failure'], 'artifact_write_failed')
|
|
63
|
+
self.assertEqual(path.read_text(), 'existing')
|
|
64
|
+
|
|
65
|
+
def test_remote_mode_cannot_create_output_files(self):
|
|
66
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
67
|
+
path = Path(folder) / 'result.json'
|
|
68
|
+
with patch.object(READER, 'remote') as remote:
|
|
69
|
+
status, log = self.invoke(path, remote=True)
|
|
70
|
+
self.assertEqual(status, 1)
|
|
71
|
+
self.assertEqual(json.loads(log)['failure'], 'invalid_destination')
|
|
72
|
+
self.assertFalse(path.exists())
|
|
73
|
+
remote.assert_not_called()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
if __name__ == '__main__':
|
|
77
|
+
unittest.main()
|