@bongos/core 1.19.704 → 1.19.706
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/.bongos-core.json +28 -13
- package/docs/module-api-changelog.md +4 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/exec-path-guard.js +564 -0
- package/scripts/gds/fitness.js +1 -1
- package/scripts/gds/smoke-write.sh +88 -0
- package/src/module-api.js +1 -1
- package/tests/exec_path_guard.mjs +248 -0
- package/tests/smoke_write_contract.mjs +110 -0
package/.bongos-core.json
CHANGED
|
@@ -2,22 +2,22 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.706",
|
|
6
|
+
"core_contract": "1.19.706",
|
|
7
|
+
"source_commit": "64c9df530070e324d8e24759cabe9f99b5f72e84",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-13T05:25:47.946Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 477,
|
|
13
13
|
"agent_docs_stubbed": 24,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2135,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2636,
|
|
20
|
+
"tree_sha256": "153b7b8ec9f73145d3a44c31dd75daa089b2c94be32c2b91ad61e59270e89c69",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2802,7 +2802,7 @@
|
|
|
2802
2802
|
{
|
|
2803
2803
|
"path": "docs/module-api-changelog.md",
|
|
2804
2804
|
"mode": "0000644",
|
|
2805
|
-
"sha256": "
|
|
2805
|
+
"sha256": "68adee401731b2252b8f8e5c32d1164da7abb13261d0bc9aa1e8b83ead4a2a99"
|
|
2806
2806
|
},
|
|
2807
2807
|
{
|
|
2808
2808
|
"path": "docs/modules-contract.md",
|
|
@@ -7782,12 +7782,12 @@
|
|
|
7782
7782
|
{
|
|
7783
7783
|
"path": "package-lock.json",
|
|
7784
7784
|
"mode": "0000644",
|
|
7785
|
-
"sha256": "
|
|
7785
|
+
"sha256": "c22b832366994675cd2cb205017f9d775c35f89cafd4ecbf2ff4a13f5e8e218c"
|
|
7786
7786
|
},
|
|
7787
7787
|
{
|
|
7788
7788
|
"path": "package.json",
|
|
7789
7789
|
"mode": "0000644",
|
|
7790
|
-
"sha256": "
|
|
7790
|
+
"sha256": "e85ec6c7db1b0f2ba643ff4178049b28c57c43ba417feff7c54a4184a6da111f"
|
|
7791
7791
|
},
|
|
7792
7792
|
{
|
|
7793
7793
|
"path": "public-docs/index.html",
|
|
@@ -8199,6 +8199,11 @@
|
|
|
8199
8199
|
"mode": "0000644",
|
|
8200
8200
|
"sha256": "2fcc3f97558ed6a653638df96d585dc7a1ef7dd884657a63d3a9545dbdb03e92"
|
|
8201
8201
|
},
|
|
8202
|
+
{
|
|
8203
|
+
"path": "scripts/gds/exec-path-guard.js",
|
|
8204
|
+
"mode": "0000644",
|
|
8205
|
+
"sha256": "e568575a922de8c4538f11fa0e67ca0a97ab75317ff59b6aea27e6020a87313c"
|
|
8206
|
+
},
|
|
8202
8207
|
{
|
|
8203
8208
|
"path": "scripts/gds/feedback-latest.js",
|
|
8204
8209
|
"mode": "0000644",
|
|
@@ -8252,7 +8257,7 @@
|
|
|
8252
8257
|
{
|
|
8253
8258
|
"path": "scripts/gds/fitness.js",
|
|
8254
8259
|
"mode": "0000644",
|
|
8255
|
-
"sha256": "
|
|
8260
|
+
"sha256": "418376cea128d2b1683e65017d74c453eb170e163bd131c1252ec79c6baf0920"
|
|
8256
8261
|
},
|
|
8257
8262
|
{
|
|
8258
8263
|
"path": "scripts/gds/gate-review.js",
|
|
@@ -9052,7 +9057,7 @@
|
|
|
9052
9057
|
{
|
|
9053
9058
|
"path": "scripts/gds/smoke-write.sh",
|
|
9054
9059
|
"mode": "0000644",
|
|
9055
|
-
"sha256": "
|
|
9060
|
+
"sha256": "beb3dd4f828a8fe6e3a800ce6481b373af00b10dad5b11f7cc9dbaaf6aa2dc1a"
|
|
9056
9061
|
},
|
|
9057
9062
|
{
|
|
9058
9063
|
"path": "scripts/gds/spark.js",
|
|
@@ -9557,7 +9562,7 @@
|
|
|
9557
9562
|
{
|
|
9558
9563
|
"path": "src/module-api.js",
|
|
9559
9564
|
"mode": "0000644",
|
|
9560
|
-
"sha256": "
|
|
9565
|
+
"sha256": "ea041db8186de51e61bdb52281d9d8a57627b10514f94a39e4120d9888c05aa5"
|
|
9561
9566
|
},
|
|
9562
9567
|
{
|
|
9563
9568
|
"path": "src/module-loader/catalog.js",
|
|
@@ -10574,6 +10579,11 @@
|
|
|
10574
10579
|
"mode": "0000644",
|
|
10575
10580
|
"sha256": "d6a78357533fdeea3bc33fe97cfcd27b1d04747c600d0a7800ddfca5199dbb1f"
|
|
10576
10581
|
},
|
|
10582
|
+
{
|
|
10583
|
+
"path": "tests/exec_path_guard.mjs",
|
|
10584
|
+
"mode": "0000644",
|
|
10585
|
+
"sha256": "478075d19af9592d87ef9419c06496e72c132f4c045e19a1ebf821ab6ff7074b"
|
|
10586
|
+
},
|
|
10577
10587
|
{
|
|
10578
10588
|
"path": "tests/executed_closure.mjs",
|
|
10579
10589
|
"mode": "0000644",
|
|
@@ -12779,6 +12789,11 @@
|
|
|
12779
12789
|
"mode": "0000644",
|
|
12780
12790
|
"sha256": "f9b1a7021c96c02a5eb667b2d10003b4c7e256fcaafbbc228b2585c710f1d162"
|
|
12781
12791
|
},
|
|
12792
|
+
{
|
|
12793
|
+
"path": "tests/smoke_write_contract.mjs",
|
|
12794
|
+
"mode": "0000644",
|
|
12795
|
+
"sha256": "d4938674f77288a6efd6cb0d03dc0d4faf76716be4c6e71fc16712022ca08dec"
|
|
12796
|
+
},
|
|
12782
12797
|
{
|
|
12783
12798
|
"path": "tests/social_mode.mjs",
|
|
12784
12799
|
"mode": "0000644",
|
|
@@ -1867,5 +1867,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1867
1867
|
landed since 1.19.702 with no explicit bump. run 34736566183. (task 1002620)
|
|
1868
1868
|
1.19.704 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1869
1869
|
landed since 1.19.703 with no explicit bump. run 34738864605. (task 1002620)
|
|
1870
|
+
1.19.705 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1871
|
+
landed since 1.19.704 with no explicit bump. run 34739785273. (task 1002620)
|
|
1872
|
+
1.19.706 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1873
|
+
landed since 1.19.705 with no explicit bump. run 34740247460. (task 1002620)
|
|
1870
1874
|
---------------------------------------------------------------------------
|
|
1871
1875
|
```
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.706",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.706",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.706",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/exec-path-guard.js — server-side code must not EXECUTE a path it does
|
|
3
|
+
// not statically control (task 1003371, audit ref B50, 2026-08-29 security audit).
|
|
4
|
+
//
|
|
5
|
+
// THE BLIND SPOT THIS EXISTS TO CLOSE. Every other gate asks who may CALL something:
|
|
6
|
+
// route-rank-check.js checks who may reach a route, fitness Check 4 checks the gates
|
|
7
|
+
// exist, permission-path-check.js checks who may EDIT a file. Nothing asked what the
|
|
8
|
+
// server RUNS, or out of whose tree. All three CRITICALs in the 2026-08-29 audit lived
|
|
9
|
+
// in that one gap, and none of them were caught by anything.
|
|
10
|
+
//
|
|
11
|
+
// THE DEFECT SHAPE. modules/lifecycle/conflict-resolve.js clones a builder's branch to
|
|
12
|
+
// a temp dir and runs `execFile(process.execPath, [path.join(work, rel)])` — a script
|
|
13
|
+
// out of the CLONE, with the live server's DATABASE_URL and BUILDER_SECRET_KEY
|
|
14
|
+
// inherited. The path is assembled from `work`, a runtime value, so no amount of
|
|
15
|
+
// reading the call site tells you what binary actually runs.
|
|
16
|
+
//
|
|
17
|
+
// THE RULE. An executed path must be STATICALLY ROOTED AT THE CORE CHECKOUT: built only
|
|
18
|
+
// from string literals, __dirname/__filename, process.execPath, and module-level consts
|
|
19
|
+
// that are themselves so built. A path that reaches the call through a parameter, a
|
|
20
|
+
// let, or any other runtime value is flagged. That is a deliberately blunt rule — it
|
|
21
|
+
// cannot tell a safe runtime path from an unsafe one, so it insists the question never
|
|
22
|
+
// arises. Where a runtime path is genuinely correct, EXEMPTIONS below records why, and
|
|
23
|
+
// the exemption is the review.
|
|
24
|
+
//
|
|
25
|
+
// WHY STATIC AND NOT A RUNTIME GUARD. The dangerous call already ran with full
|
|
26
|
+
// privilege by the time a runtime check could see it, and the sites are few and rarely
|
|
27
|
+
// touched — this is exactly the shape a build-time scan handles well.
|
|
28
|
+
//
|
|
29
|
+
// THE RESIDUAL ASSUMPTION, stated so it can be re-checked rather than rediscovered.
|
|
30
|
+
// This is a LINE/BRACKET scan, not a parse (the repo ships no JS parser — see
|
|
31
|
+
// route-rank-check.js, which reads route files the same way for the same reason). It
|
|
32
|
+
// resolves child_process bindings per file, so a regex's `.exec()` is never mistaken
|
|
33
|
+
// for the child_process one, and it balances brackets to read a multi-line call. It
|
|
34
|
+
// does NOT follow a value across files or through a function return. A path laundered
|
|
35
|
+
// through a helper in another module would read as a bare identifier here and be
|
|
36
|
+
// FLAGGED, not missed — the scan fails toward noise, never toward silence.
|
|
37
|
+
|
|
38
|
+
'use strict';
|
|
39
|
+
|
|
40
|
+
const fs = require('node:fs');
|
|
41
|
+
const path = require('node:path');
|
|
42
|
+
const { execFileSync } = require('node:child_process');
|
|
43
|
+
|
|
44
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
45
|
+
const NAME = 'no server-side exec/spawn runs a path that is not statically rooted at the core checkout (task 1003371)';
|
|
46
|
+
|
|
47
|
+
// The child_process members that start a process. `fork` included: it runs a module
|
|
48
|
+
// path the same way execFile does.
|
|
49
|
+
const SPAWNERS = new Set([
|
|
50
|
+
'exec', 'execSync', 'execFile', 'execFileSync', 'spawn', 'spawnSync', 'fork',
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
// Identifiers an executed path may be built from without the scan knowing more. These
|
|
54
|
+
// are fixed at module load and cannot be steered by a request or a branch.
|
|
55
|
+
const STATIC_ROOTS = new Set([
|
|
56
|
+
'__dirname', '__filename', 'process', 'path', 'require',
|
|
57
|
+
'String', 'Number', 'JSON', 'Boolean', 'Array', 'Object',
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
// Reviewed exemptions. Each is a call the scan flags where a runtime path is the
|
|
61
|
+
// correct behaviour, with the reason it is safe. An entry is a REVIEW, not a mute:
|
|
62
|
+
// adding one is the moment to argue the case, which is why `why` is asserted non-empty
|
|
63
|
+
// by tests/exec_path_guard.mjs. Keyed `<repo-relative file>:<line>` is deliberately
|
|
64
|
+
// brittle — a line shift re-opens the review rather than carrying a stale approval.
|
|
65
|
+
const EXEMPTIONS = [
|
|
66
|
+
{
|
|
67
|
+
at: 'modules/lifecycle/conflict-resolve.js:382',
|
|
68
|
+
why: 'THE site this check was written for (audit ref B1), and the one place a runtime path is '
|
|
69
|
+
+ 'REQUIRED rather than tolerated. Each generator derives its repo root from its own '
|
|
70
|
+
+ '__dirname, so running the SERVER\'s copy would regenerate the deploy tree instead of the '
|
|
71
|
+
+ 'throwaway clone — rooting this statically would break the feature and corrupt the live '
|
|
72
|
+
+ 'checkout. It is safe because of a control OUTSIDE this call: task 1003367 made '
|
|
73
|
+
+ 'resolveBranchConflicts refuse to auto-resolve any branch touching '
|
|
74
|
+
+ 'computeExecutedClosure() (steps 5b/4b), so this line is unreachable for a branch that '
|
|
75
|
+
+ 'edited executed code, and task 1003366 put all eight execution roots at the Metic floor. '
|
|
76
|
+
+ 'If that refusal is ever removed this exemption dies with it.',
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
at: 'modules/grading/generated-provenance.js:156',
|
|
80
|
+
why: '`dir` defaults to path.join(__dirname, ..., \'scripts\', \'gds\') — the SERVER\'s own tree — '
|
|
81
|
+
+ 'and `script` comes from the module-level GENERATORS table, which holds string literals '
|
|
82
|
+
+ 'only. The `scriptDir` seam exists so the unit test never spawns a process and is never '
|
|
83
|
+
+ 'set in production. The untrusted input here is the CWD, not the script: this runs a '
|
|
84
|
+
+ 'trusted generator against a suspect tree, which is the inverse of the B50 shape.',
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
at: 'modules/grading/generated-provenance.js:223',
|
|
88
|
+
why: 'The async twin of the call at :156 — same `dir` default, same literal GENERATORS table, '
|
|
89
|
+
+ 'same test-only `scriptDir` seam. Listed separately rather than folded in because an '
|
|
90
|
+
+ 'exemption is keyed to a line: if one of the two calls changes shape, only that one '
|
|
91
|
+
+ 're-opens for review.',
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
at: 'src/module-loader/loader.js:190',
|
|
95
|
+
why: 'The plugin loader, and the second site the audit named (B50 "motivating sites"). It '
|
|
96
|
+
+ 'require()s a discovered module\'s route file, so the specifier is a runtime path BY '
|
|
97
|
+
+ 'DESIGN — a plugin system whose module paths are compile-time constants is not a plugin '
|
|
98
|
+
+ 'system. What bounds it is not this call but WHICH ROOTS are discovered: DEFAULT_ROOTS is '
|
|
99
|
+
+ 'the core package\'s modules/ plus the instance\'s own, both operator-controlled, never a '
|
|
100
|
+
+ 'builder branch. The residual risk — an instance accepting an untrusted module — is a '
|
|
101
|
+
+ 'different boundary with its own control: ADR 0107 designates an accept-time module '
|
|
102
|
+
+ 'security review, tracked as task 1003400. Exempt here so that work is not silently '
|
|
103
|
+
+ 'absorbed into this check.',
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
at: 'src/module-loader/loader.js:293',
|
|
107
|
+
why: 'The poller twin of the route require at :190 — same DEFAULT_ROOTS bound, same plugin-by-'
|
|
108
|
+
+ 'design rationale, same ADR 0107 / task 1003400 follow-on. Listed on its own line so a '
|
|
109
|
+
+ 'change to either call re-opens only that one.',
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
at: 'modules/ui-design/scripts/validate-design.js:222',
|
|
113
|
+
why: 'A developer validation script, not a server path: it require()s each adapter\'s index.js '
|
|
114
|
+
+ 'to check the adapter contract, and loading the thing under test is the whole point. '
|
|
115
|
+
+ 'indexPath is path.join(ADAPTERS_DIR, adapterName, \'index.js\') where ADAPTERS_DIR is '
|
|
116
|
+
+ '__dirname-rooted and adapterName comes from reading that same directory — so it cannot '
|
|
117
|
+
+ 'escape the core checkout. Flagged only because the scan does not model a directory '
|
|
118
|
+
+ 'listing as a constrained source.',
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
at: 'src/bongos/llm-cache.js:116',
|
|
122
|
+
why: 'Not an executed path at all. `cmd` is a `git rev-parse HEAD:<p>` command string built in '
|
|
123
|
+
+ 'scopeShaFor from a code-defined paths list; the binary is git, resolved from PATH, and '
|
|
124
|
+
+ 'nothing is run out of a builder tree. Flagged because the check reads arg 0 of every '
|
|
125
|
+
+ 'spawner and cannot tell a command string from a path — the honest cost of a rule blunt '
|
|
126
|
+
+ 'enough to have no gaps. NOTE for a future reader: interpolating `p` into a shell string '
|
|
127
|
+
+ 'is a real smell of a DIFFERENT class (shell metacharacters, not tree provenance). It is '
|
|
128
|
+
+ 'not exploitable today because every caller passes a literal, and it is out of scope for '
|
|
129
|
+
+ 'B50 — but it is the thing to fix if `paths` ever becomes request-derived.',
|
|
130
|
+
},
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// binding resolution — which local names in THIS file are child_process spawners
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
const REQUIRE_CP_RE = /(?:const|let|var)\s+(\{[^}]*\}|[A-Za-z_$][\w$]*)\s*=\s*require\(\s*['"](?:node:)?child_process['"]\s*\)/g;
|
|
138
|
+
|
|
139
|
+
// ESM: `import { execFile, spawn as sp } from 'node:child_process'`, and the namespace
|
|
140
|
+
// form. The repo is CJS today, but a check that only understands CJS quietly stops
|
|
141
|
+
// working the day a file converts — and stops without a signal, which is the failure
|
|
142
|
+
// mode this file exists to avoid.
|
|
143
|
+
const IMPORT_CP_RE = /import\s+(\*\s*as\s*[A-Za-z_$][\w$]*|\{[^}]*\}|[A-Za-z_$][\w$]*)\s*from\s*['"](?:node:)?child_process['"]/g;
|
|
144
|
+
|
|
145
|
+
// The UNBOUND form: `require('node:child_process').execFileSync(...)` called straight off
|
|
146
|
+
// the require. It binds no name, so binding resolution alone never sees it — and the
|
|
147
|
+
// first draft of this check short-circuited the WHOLE FILE on "no bindings", so a file
|
|
148
|
+
// spawning this way was skipped entirely rather than scanned. That is the exact
|
|
149
|
+
// inversion of this scan's stated "fails toward noise, never toward silence" contract,
|
|
150
|
+
// and the form is not hypothetical: scripts/gds/autonomy-gate.js and
|
|
151
|
+
// scripts/gds/ship-land.js both already use it.
|
|
152
|
+
const INLINE_CP_RE = new RegExp(
|
|
153
|
+
`require\\(\\s*['"](?:node:)?child_process['"]\\s*\\)\\s*\\.\\s*(?:${[...SPAWNERS].join('|')})\\s*\\(`,
|
|
154
|
+
'g',
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
// A require() whose specifier is not a literal: the server LOADS AND RUNS that module in
|
|
158
|
+
// its own process, which is the same question this check asks of exec/spawn through a
|
|
159
|
+
// different mechanism. src/module-loader/loader.js is the motivating site the audit named
|
|
160
|
+
// alongside conflict-resolve.js.
|
|
161
|
+
const REQUIRE_CALL_RE = /(?<![.\w$])require\s*\(/g;
|
|
162
|
+
|
|
163
|
+
// Returns { direct:Set<name>, namespaces:Set<name> } — `direct` are names bound to a
|
|
164
|
+
// spawner (destructuring or ESM named import, renames included), `namespaces` are
|
|
165
|
+
// whole-module bindings used as `cp.execFile(...)`.
|
|
166
|
+
function resolveBindings(src) {
|
|
167
|
+
const direct = new Set();
|
|
168
|
+
const namespaces = new Set();
|
|
169
|
+
const takeList = (lhs) => {
|
|
170
|
+
if (!lhs.startsWith('{')) {
|
|
171
|
+
namespaces.add(lhs.replace(/^\*\s*as\s*/, '').trim());
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
for (const part of lhs.slice(1, -1).split(',')) {
|
|
175
|
+
const [orig, alias] = part.split(/:|\bas\b/).map((s) => s.trim());
|
|
176
|
+
if (orig && SPAWNERS.has(orig)) direct.add(alias || orig);
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
for (const re of [REQUIRE_CP_RE, IMPORT_CP_RE]) {
|
|
180
|
+
re.lastIndex = 0;
|
|
181
|
+
let m;
|
|
182
|
+
while ((m = re.exec(src))) takeList(m[1].trim());
|
|
183
|
+
}
|
|
184
|
+
return { direct, namespaces };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// argument extraction — read a call's arguments, balancing brackets and strings
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
// From the index of a call's '(', return the raw argument text, or null if unbalanced.
|
|
192
|
+
function readCallArgs(src, openIdx) {
|
|
193
|
+
let depth = 0;
|
|
194
|
+
let quote = null;
|
|
195
|
+
for (let i = openIdx; i < src.length; i += 1) {
|
|
196
|
+
const c = src[i];
|
|
197
|
+
if (quote) {
|
|
198
|
+
if (c === '\\') { i += 1; continue; }
|
|
199
|
+
if (c === quote) quote = null;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (c === "'" || c === '"' || c === '`') { quote = c; continue; }
|
|
203
|
+
if (c === '(' || c === '[' || c === '{') depth += 1;
|
|
204
|
+
else if (c === ')' || c === ']' || c === '}') {
|
|
205
|
+
depth -= 1;
|
|
206
|
+
if (depth === 0) return src.slice(openIdx + 1, i);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Split argument text on TOP-LEVEL commas only.
|
|
213
|
+
function splitArgs(argText) {
|
|
214
|
+
const out = [];
|
|
215
|
+
let depth = 0;
|
|
216
|
+
let quote = null;
|
|
217
|
+
let start = 0;
|
|
218
|
+
for (let i = 0; i < argText.length; i += 1) {
|
|
219
|
+
const c = argText[i];
|
|
220
|
+
if (quote) {
|
|
221
|
+
if (c === '\\') { i += 1; continue; }
|
|
222
|
+
if (c === quote) quote = null;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (c === "'" || c === '"' || c === '`') { quote = c; continue; }
|
|
226
|
+
if (c === '(' || c === '[' || c === '{') depth += 1;
|
|
227
|
+
else if (c === ')' || c === ']' || c === '}') depth -= 1;
|
|
228
|
+
else if (c === ',' && depth === 0) { out.push(argText.slice(start, i)); start = i + 1; }
|
|
229
|
+
}
|
|
230
|
+
out.push(argText.slice(start));
|
|
231
|
+
return out.map((s) => s.trim()).filter((s) => s.length);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ---------------------------------------------------------------------------
|
|
235
|
+
// staticness — is an expression built only from roots fixed at module load?
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
|
|
238
|
+
// Blank string CONTENT so it cannot contribute identifiers — with one exception that
|
|
239
|
+
// decides whether this check works at all.
|
|
240
|
+
//
|
|
241
|
+
// A template literal's `${...}` holes are CODE, not content. Blanking a whole template
|
|
242
|
+
// the way a quoted string is blanked leaves `${work}/gen.js` with no free identifiers,
|
|
243
|
+
// and isStatic()'s every() over an empty set is vacuously TRUE — so the path reads as
|
|
244
|
+
// static and the call is never flagged. That is not a missed edge: it is a one-character
|
|
245
|
+
// bypass of the entire check (write a backtick instead of calling path.join) for exactly
|
|
246
|
+
// the runtime-derived path B1/B50 are about. Found by the grader, reproduced, pinned by
|
|
247
|
+
// tests/exec_path_guard.mjs.
|
|
248
|
+
//
|
|
249
|
+
// So a template is reduced to a call-shaped list of its interpolated expressions —
|
|
250
|
+
// `${a}/x/${b}` becomes `(a,b)` — which keeps every identifier the hole referenced and
|
|
251
|
+
// discards the literal text around it. Templates are handled BEFORE the quoted forms so
|
|
252
|
+
// a quote inside a hole cannot be mistaken for a string opener.
|
|
253
|
+
const TEMPLATE_RE = /`(?:[^`\\]|\\.)*`/g;
|
|
254
|
+
const HOLE_RE = /\$\{([^{}]*(?:\{[^{}]*\}[^{}]*)*)\}/g;
|
|
255
|
+
|
|
256
|
+
function stripStrings(expr) {
|
|
257
|
+
return expr
|
|
258
|
+
.replace(TEMPLATE_RE, (lit) => {
|
|
259
|
+
const holes = [];
|
|
260
|
+
HOLE_RE.lastIndex = 0;
|
|
261
|
+
let m;
|
|
262
|
+
while ((m = HOLE_RE.exec(lit))) holes.push(m[1]);
|
|
263
|
+
return holes.length ? `(${holes.join(',')})` : '``';
|
|
264
|
+
})
|
|
265
|
+
.replace(/'(?:[^'\\]|\\.)*'/g, "''")
|
|
266
|
+
.replace(/"(?:[^"\\]|\\.)*"/g, '""');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Free identifiers: tokens not preceded by '.' (property names) and not object keys.
|
|
270
|
+
function freeIdentifiers(expr) {
|
|
271
|
+
const bare = stripStrings(expr);
|
|
272
|
+
const out = new Set();
|
|
273
|
+
const re = /(^|[^.\w$])([A-Za-z_$][\w$]*)/g;
|
|
274
|
+
let m;
|
|
275
|
+
while ((m = re.exec(bare))) out.add(m[2]);
|
|
276
|
+
return out;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// Module-level `const NAME = <expr>;` declarations — the only indirection the scan
|
|
280
|
+
// follows. Column 0 is the test for module level; a const nested in a function is
|
|
281
|
+
// re-assigned per call and is not a fixed root.
|
|
282
|
+
function moduleConsts(src) {
|
|
283
|
+
const out = new Map();
|
|
284
|
+
const re = /^const\s+([A-Za-z_$][\w$]*)\s*=\s*([^\n]*?);?\s*$/gm;
|
|
285
|
+
let m;
|
|
286
|
+
while ((m = re.exec(src))) out.set(m[1], m[2]);
|
|
287
|
+
return out;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// The safe set: STATIC_ROOTS plus module consts that resolve to safe expressions.
|
|
291
|
+
// Iterated to a fixpoint so `const A = __dirname; const B = path.join(A, 'x')` works.
|
|
292
|
+
function safeIdentifiers(src) {
|
|
293
|
+
const consts = moduleConsts(src);
|
|
294
|
+
const safe = new Set(STATIC_ROOTS);
|
|
295
|
+
for (let pass = 0; pass < 5; pass += 1) {
|
|
296
|
+
let grew = false;
|
|
297
|
+
for (const [name, expr] of consts) {
|
|
298
|
+
if (safe.has(name)) continue;
|
|
299
|
+
if ([...freeIdentifiers(expr)].every((id) => safe.has(id))) { safe.add(name); grew = true; }
|
|
300
|
+
}
|
|
301
|
+
if (!grew) break;
|
|
302
|
+
}
|
|
303
|
+
return safe;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function isStatic(expr, safe) {
|
|
307
|
+
return [...freeIdentifiers(expr)].every((id) => safe.has(id));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
// masking — blank comments, string bodies and regex literals before matching
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
// Call sites are matched against a MASKED copy of the source, never the raw text.
|
|
315
|
+
// Without this the scan reads prose: modules/grading/grader.js carries the words
|
|
316
|
+
// "before worker spawn (GRADER_BYPASS_ENABLED / runOpts.bypass)" inside a note string,
|
|
317
|
+
// and a bare /spawn\s*\(/ matches it and reports the sentence as an executed path.
|
|
318
|
+
// Masking preserves LENGTH and NEWLINES, so offsets and line numbers computed on the
|
|
319
|
+
// mask still address the original text and argument text is read back from it.
|
|
320
|
+
//
|
|
321
|
+
// Regex literals are detected by what precedes them — after an operator or an opening
|
|
322
|
+
// bracket a `/` starts a pattern, after a value it is division. That heuristic is the
|
|
323
|
+
// standard one and it can be wrong; being wrong here masks or unmasks a few characters
|
|
324
|
+
// and can only add a finding, never hide one, which is the direction this scan fails in
|
|
325
|
+
// everywhere else too.
|
|
326
|
+
function maskNonCode(src) {
|
|
327
|
+
const out = src.split('');
|
|
328
|
+
const blank = (i) => { if (out[i] !== '\n') out[i] = ' '; };
|
|
329
|
+
let i = 0;
|
|
330
|
+
let prev = '';
|
|
331
|
+
while (i < src.length) {
|
|
332
|
+
const c = src[i];
|
|
333
|
+
const next = src[i + 1];
|
|
334
|
+
if (c === '/' && next === '/') {
|
|
335
|
+
while (i < src.length && src[i] !== '\n') { blank(i); i += 1; }
|
|
336
|
+
continue;
|
|
337
|
+
}
|
|
338
|
+
if (c === '/' && next === '*') {
|
|
339
|
+
blank(i); blank(i + 1); i += 2;
|
|
340
|
+
while (i < src.length && !(src[i] === '*' && src[i + 1] === '/')) { blank(i); i += 1; }
|
|
341
|
+
if (i < src.length) { blank(i); blank(i + 1); i += 2; }
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
if (c === "'" || c === '"' || c === '`') {
|
|
345
|
+
i += 1; // keep the opening quote so string-shaped args stay recognisable
|
|
346
|
+
while (i < src.length && src[i] !== c) {
|
|
347
|
+
if (src[i] === '\\') { blank(i); i += 1; }
|
|
348
|
+
if (i < src.length) { blank(i); i += 1; }
|
|
349
|
+
}
|
|
350
|
+
if (i < src.length) i += 1;
|
|
351
|
+
prev = c;
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
if (c === '/' && /[(,=:[!&|?{};+\-*%<>~^]/.test(prev)) {
|
|
355
|
+
i += 1;
|
|
356
|
+
while (i < src.length && src[i] !== '/' && src[i] !== '\n') {
|
|
357
|
+
if (src[i] === '\\') { blank(i); i += 1; }
|
|
358
|
+
if (i < src.length) { blank(i); i += 1; }
|
|
359
|
+
}
|
|
360
|
+
if (i < src.length && src[i] === '/') i += 1;
|
|
361
|
+
prev = '/';
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
if (!/\s/.test(c)) prev = c;
|
|
365
|
+
i += 1;
|
|
366
|
+
}
|
|
367
|
+
return out.join('');
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ---------------------------------------------------------------------------
|
|
371
|
+
// the scan
|
|
372
|
+
// ---------------------------------------------------------------------------
|
|
373
|
+
|
|
374
|
+
// Which argument carries the executed path? Always arg 0. When arg 0 is the node
|
|
375
|
+
// binary, the SCRIPT is the first element of the args array, so that is checked too —
|
|
376
|
+
// `execFile(process.execPath, [path.join(work, rel)])` is the exact defect and its
|
|
377
|
+
// arg 0 is perfectly static.
|
|
378
|
+
function executedExpressions(args) {
|
|
379
|
+
const out = [];
|
|
380
|
+
if (!args.length) return out;
|
|
381
|
+
out.push({ expr: args[0], role: 'executable' });
|
|
382
|
+
const isNode = /process\.execPath/.test(args[0]) || /^['"](?:node|node\.exe)['"]$/.test(args[0].trim());
|
|
383
|
+
if (isNode && args[1] && args[1].trim().startsWith('[')) {
|
|
384
|
+
const first = splitArgs(args[1].trim().slice(1, -1))[0];
|
|
385
|
+
if (first) out.push({ expr: first, role: 'script' });
|
|
386
|
+
}
|
|
387
|
+
return out;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Record one finding if the expression is not statically rooted.
|
|
391
|
+
function consider(findings, { rel, masked, src, idx, callText, role, expr, safe }) {
|
|
392
|
+
if (expr == null || isStatic(expr, safe)) return;
|
|
393
|
+
findings.push({
|
|
394
|
+
file: rel,
|
|
395
|
+
line: src.slice(0, idx).split('\n').length,
|
|
396
|
+
role,
|
|
397
|
+
expr: expr.replace(/\s+/g, ' ').slice(0, 120),
|
|
398
|
+
call: callText,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function scanFile(rel, src) {
|
|
403
|
+
// Everything regex-matched below runs against `masked`; argument TEXT is read back
|
|
404
|
+
// from `src` at the same offsets, which masking keeps aligned.
|
|
405
|
+
const masked = maskNonCode(src);
|
|
406
|
+
// Bindings are resolved from the ORIGINAL text: masking blanks string bodies, and the
|
|
407
|
+
// require specifier 'node:child_process' IS a string body — reading bindings off the
|
|
408
|
+
// mask finds none, and the scan silently passes every file (caught in review).
|
|
409
|
+
const { direct, namespaces } = resolveBindings(src);
|
|
410
|
+
const safe = safeIdentifiers(masked);
|
|
411
|
+
const findings = [];
|
|
412
|
+
|
|
413
|
+
// Every way a spawner can be reached: a bound name, a namespace member, and the
|
|
414
|
+
// unbound require('child_process').x() form. NOT short-circuited on "no bindings" —
|
|
415
|
+
// the inline form has none by construction.
|
|
416
|
+
const forms = [
|
|
417
|
+
...[...direct].map((n) => `(?<![.\\w$])${n}\\s*\\(`),
|
|
418
|
+
...[...namespaces].map((ns) => `(?<![.\\w$])${ns}\\s*\\.\\s*(?:${[...SPAWNERS].join('|')})\\s*\\(`),
|
|
419
|
+
];
|
|
420
|
+
|
|
421
|
+
// Bound spawner calls are matched against the MASK, so prose can never be one.
|
|
422
|
+
const hits = [];
|
|
423
|
+
if (forms.length) {
|
|
424
|
+
const bound = new RegExp(forms.join('|'), 'g');
|
|
425
|
+
let b;
|
|
426
|
+
while ((b = bound.exec(masked))) hits.push(b);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// The UNBOUND form is matched against the ORIGINAL text, because the thing that
|
|
430
|
+
// identifies it IS a specifier string and masking blanks string bodies — the same trap
|
|
431
|
+
// that once made binding resolution see nothing at all. Prose is still excluded
|
|
432
|
+
// without re-reading the raw text wholesale: had the construct sat inside a comment or
|
|
433
|
+
// a string, its trailing '(' would have been blanked too, so the mask is consulted for
|
|
434
|
+
// that one character.
|
|
435
|
+
INLINE_CP_RE.lastIndex = 0;
|
|
436
|
+
let im;
|
|
437
|
+
while ((im = INLINE_CP_RE.exec(src))) {
|
|
438
|
+
if (masked[im.index + im[0].length - 1] === '(') hits.push(im);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
for (const m of hits) {
|
|
442
|
+
const openIdx = m.index + m[0].length - 1;
|
|
443
|
+
const argText = readCallArgs(src, openIdx);
|
|
444
|
+
if (argText == null) continue;
|
|
445
|
+
const args = splitArgs(argText);
|
|
446
|
+
const callText = m[0].slice(0, -1).trim().replace(/\s+/g, '');
|
|
447
|
+
for (const { expr, role } of executedExpressions(args)) {
|
|
448
|
+
consider(findings, { rel, masked, src, idx: m.index, callText, role, expr, safe });
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Dynamic require(): the server loads and RUNS that module in-process. Same question,
|
|
453
|
+
// different mechanism — src/module-loader/loader.js is the site the audit named.
|
|
454
|
+
REQUIRE_CALL_RE.lastIndex = 0;
|
|
455
|
+
let r;
|
|
456
|
+
while ((r = REQUIRE_CALL_RE.exec(masked))) {
|
|
457
|
+
const openIdx = r.index + r[0].length - 1;
|
|
458
|
+
const argText = readCallArgs(src, openIdx);
|
|
459
|
+
if (argText == null) continue;
|
|
460
|
+
const spec = splitArgs(argText)[0];
|
|
461
|
+
consider(findings, {
|
|
462
|
+
rel, masked, src, idx: r.index, callText: 'require', role: 'module', expr: spec, safe,
|
|
463
|
+
});
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return findings;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Every tracked first-party server file. Browser assets under public/ never spawn.
|
|
470
|
+
function trackedServerFiles() {
|
|
471
|
+
try {
|
|
472
|
+
// DIRECTORY pathspecs, filtered by extension here, rather than '**/*.js' globs.
|
|
473
|
+
// NOT a bug fix — the glob form enumerated the same 351 files, because a git pathspec
|
|
474
|
+
// '*' crosses '/' (fnmatch without FNM_PATHNAME), so 'src/*.js' already matched every
|
|
475
|
+
// nested file and 'src/**/*.js' — which matches only 63 — was pure redundancy. That
|
|
476
|
+
// equivalence is a non-obvious property of git that a reader has to know to trust the
|
|
477
|
+
// line, and the cost of being wrong about it is coverage that narrows with no signal.
|
|
478
|
+
// A directory pathspec needs no such knowledge. Pinned by the enumeration test below.
|
|
479
|
+
const out = execFileSync('git', ['ls-files', '--', 'src', 'modules'],
|
|
480
|
+
{ cwd: REPO_ROOT, encoding: 'utf8' });
|
|
481
|
+
return [...new Set(out.split('\n').map((x) => x.trim()).filter(Boolean))]
|
|
482
|
+
.filter((f) => f.endsWith('.js'))
|
|
483
|
+
.filter((f) => !f.includes('/public/') && !f.includes('/app/'));
|
|
484
|
+
} catch {
|
|
485
|
+
return [];
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function exemptionFor(f) {
|
|
490
|
+
return EXEMPTIONS.find((e) => e.at === `${f.file}:${f.line}`) || null;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function checkExecPathsStatic({ files, read } = {}) {
|
|
494
|
+
const list = files || trackedServerFiles();
|
|
495
|
+
if (!list.length) {
|
|
496
|
+
return {
|
|
497
|
+
name: NAME, ok: false, hardFail: true,
|
|
498
|
+
violations: ['scan defect — enumerated 0 tracked server files; a broken enumeration, not a clean result.'],
|
|
499
|
+
warnings: [], note: 'static scan of src/ + modules/ for executed paths.',
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
const reader = read || ((rel) => {
|
|
503
|
+
try { return fs.readFileSync(path.join(REPO_ROOT, rel), 'utf8'); } catch { return null; }
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
const violations = [];
|
|
507
|
+
// Exemptions are NOT warnings. Four reviewed entries emitted as warnings make this check
|
|
508
|
+
// read WARN on every single run, and a check that is permanently yellow is one people stop
|
|
509
|
+
// reading. They ride in `exempt` instead: counted in the note, listed in full by the
|
|
510
|
+
// standalone run (node scripts/gds/exec-path-guard.js).
|
|
511
|
+
const exempt = [];
|
|
512
|
+
let scanned = 0;
|
|
513
|
+
let callSites = 0;
|
|
514
|
+
for (const rel of list) {
|
|
515
|
+
const src = reader(rel);
|
|
516
|
+
if (src == null) continue;
|
|
517
|
+
scanned += 1;
|
|
518
|
+
for (const f of scanFile(rel, src)) {
|
|
519
|
+
callSites += 1;
|
|
520
|
+
const ex = exemptionFor(f);
|
|
521
|
+
if (ex) { exempt.push(`${f.file}:${f.line} exempt — ${ex.why}`); continue; }
|
|
522
|
+
violations.push(
|
|
523
|
+
`${f.file}:${f.line} ${f.call}() runs a ${f.role} path that is not statically rooted at the core `
|
|
524
|
+
+ `checkout: \`${f.expr}\`. The server executes it with its OWN environment — DATABASE_URL, `
|
|
525
|
+
+ 'BUILDER_SECRET_KEY — so a path assembled at runtime is a path an untrusted tree can steer. '
|
|
526
|
+
+ 'Root it at __dirname or a module-level const, or add a reviewed EXEMPTIONS entry in '
|
|
527
|
+
+ 'scripts/gds/exec-path-guard.js saying why a runtime path is correct here.',
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
return {
|
|
533
|
+
name: NAME,
|
|
534
|
+
ok: violations.length === 0,
|
|
535
|
+
hardFail: violations.length > 0,
|
|
536
|
+
violations,
|
|
537
|
+
warnings: [],
|
|
538
|
+
exempt,
|
|
539
|
+
note: `${scanned} server file(s) scanned for child_process calls; ${callSites} non-static executed `
|
|
540
|
+
+ `path(s) found, ${EXEMPTIONS.length} reviewed exemption(s). An executed path must be built only `
|
|
541
|
+
+ 'from literals, __dirname/__filename, process.execPath and module-level consts — the server runs '
|
|
542
|
+
+ 'it with its own credentials, so "what runs" must be answerable by reading the call site.',
|
|
543
|
+
};
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function main() {
|
|
547
|
+
const r = checkExecPathsStatic();
|
|
548
|
+
for (const v of r.violations) console.error(`✗ ${v}`);
|
|
549
|
+
for (const w of r.exempt) console.log(` - ${w}`);
|
|
550
|
+
console.log(`${r.hardFail ? 'FAIL' : 'PASS'} ${r.note}`);
|
|
551
|
+
process.exit(r.hardFail ? 1 : 0);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (require.main === module) main();
|
|
555
|
+
|
|
556
|
+
// Exported surface = what fitness.js, the standalone main and the tests actually use.
|
|
557
|
+
// stripStrings/maskNonCode/isStatic are exported because the template-literal bypass
|
|
558
|
+
// above is the kind of defect that needs a test at ITS OWN altitude, not only through
|
|
559
|
+
// scanFile — a unit-level probe is how that regression stays visible.
|
|
560
|
+
module.exports = {
|
|
561
|
+
checkExecPathsStatic, scanFile, resolveBindings, trackedServerFiles,
|
|
562
|
+
stripStrings, maskNonCode, isStatic, safeIdentifiers,
|
|
563
|
+
EXEMPTIONS, NAME,
|
|
564
|
+
};
|
package/scripts/gds/fitness.js
CHANGED
|
@@ -1406,7 +1406,7 @@ const CHECKS = [
|
|
|
1406
1406
|
// migrations: same shared, monotonic, human-allocated number line, and until this
|
|
1407
1407
|
// landed only one of the two was enforced. An ADR number is a citation target, so a
|
|
1408
1408
|
// duplicate is wrong forever (ADR 0195).
|
|
1409
|
-
require('./adr-namespace.js').checkAdrNumbering,
|
|
1409
|
+
require('./adr-namespace.js').checkAdrNumbering, require('./exec-path-guard.js').checkExecPathsStatic, // Check 32 — task 1003371 / B50: what the server EXECUTES, out of whose tree — the blind spot all three 2026-08-29 CRITICALs shared. Rationale + reviewed exemptions live in that file. Sharing Check 31's line (the 19c/19d + 23/24 precedent above): fitness.js is AT the 1,500 ratchet and a new check cannot afford a line until task 1003825 buys it back.
|
|
1410
1410
|
];
|
|
1411
1411
|
|
|
1412
1412
|
function runAll() {
|
|
@@ -11,6 +11,9 @@
|
|
|
11
11
|
# session_logs row exists for this claim
|
|
12
12
|
# public/recent-shipped does NOT contain the smoke title
|
|
13
13
|
# public/progress weighting unchanged (smoke tasks filtered)
|
|
14
|
+
# 6. On PASS, abandon the throwaway task so the ledger does not refill with
|
|
15
|
+
# test noise (task 1002018). A FAILED run deliberately KEEPS its task and
|
|
16
|
+
# claim so the failure can be read from the ledger afterwards.
|
|
14
17
|
#
|
|
15
18
|
# The synthetic task has credits_reward=0 so no credit_log row is written
|
|
16
19
|
# and the leaderboard is unaffected. The credit-award path is implicitly
|
|
@@ -68,6 +71,51 @@ VERSION_ID=$(node "$(dirname "$0")/version-select.js") || {
|
|
|
68
71
|
}
|
|
69
72
|
echo "Version: ${VERSION_ID}"
|
|
70
73
|
|
|
74
|
+
# Caused by task 1003598 (BV1.R11, ADR 0250 D4), found and fixed under task
|
|
75
|
+
# 1002018: goal_id became REQUIRED on POST /tasks and this script still sent none, so step 1 died on a 400 goal_id_required
|
|
76
|
+
# and the whole smoke could not run. Resolved at RUNTIME, not hardcoded, for the
|
|
77
|
+
# same reason version-select.js exists (task 1003166): an id literal goes stale
|
|
78
|
+
# the moment the version rolls, and the failure then looks like a broken API
|
|
79
|
+
# rather than a stale constant. Every version auto-creates exactly ONE
|
|
80
|
+
# is_maintenance goal (BV1.R18), and a synthetic row is maintenance-goal work.
|
|
81
|
+
#
|
|
82
|
+
# node, not python3, on purpose: this script already shells out to node for
|
|
83
|
+
# version-select.js, and node is the interpreter a Windows builder box actually
|
|
84
|
+
# has — the python3 the rest of this file depends on is frequently absent there
|
|
85
|
+
# (it is the Microsoft Store stub), so a python3 step cannot be verified locally.
|
|
86
|
+
GOAL_ID=$(call GET "/api/gds/goals?version=${VERSION_ID}" | node -e '
|
|
87
|
+
let s = "";
|
|
88
|
+
process.stdin.on("data", (d) => { s += d; });
|
|
89
|
+
process.stdin.on("end", () => {
|
|
90
|
+
const goals = JSON.parse(s).goals || [];
|
|
91
|
+
const m = goals.filter((g) => g.is_maintenance && g.status === "open");
|
|
92
|
+
if (m.length !== 1) {
|
|
93
|
+
process.stderr.write("expected exactly 1 open maintenance goal, found " + m.length + "\n");
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
// The caller splices this straight into a python3 -c SOURCE string, unquoted,
|
|
97
|
+
// so a non-numeric id would be executed as Python rather than read as data.
|
|
98
|
+
// Validating the COUNT is not validating the ID: check its shape too.
|
|
99
|
+
const id = String(m[0].id);
|
|
100
|
+
if (!/^[0-9]+$/.test(id)) {
|
|
101
|
+
process.stderr.write("goal id is not numeric: " + JSON.stringify(id) + "\n");
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
process.stdout.write(id);
|
|
105
|
+
});
|
|
106
|
+
') || {
|
|
107
|
+
echo "FAIL: could not resolve the maintenance goal for ${VERSION_ID}" >&2
|
|
108
|
+
exit 1
|
|
109
|
+
}
|
|
110
|
+
# Belt-and-braces at the point of use: the resolver above already refuses a
|
|
111
|
+
# non-numeric id, but this value is interpolated into python3 source below as
|
|
112
|
+
# CODE, not as a quoted literal, so it is re-checked where the risk actually is.
|
|
113
|
+
[[ "${GOAL_ID}" =~ ^[0-9]+$ ]] || {
|
|
114
|
+
echo "FAIL: resolved goal id is not numeric: ${GOAL_ID}" >&2
|
|
115
|
+
exit 1
|
|
116
|
+
}
|
|
117
|
+
echo "Goal: ${GOAL_ID}"
|
|
118
|
+
|
|
71
119
|
echo "--- 0. snapshot progress before ---"
|
|
72
120
|
PROGRESS_BEFORE=$(snapshot_progress)
|
|
73
121
|
echo "${PROGRESS_BEFORE}"
|
|
@@ -78,6 +126,7 @@ CREATE_BODY=$(python3 -c "
|
|
|
78
126
|
import json
|
|
79
127
|
print(json.dumps({
|
|
80
128
|
'version_id': '${VERSION_ID}',
|
|
129
|
+
'goal_id': ${GOAL_ID},
|
|
81
130
|
'title': '${SMOKE_TITLE}',
|
|
82
131
|
'description': 'Synthetic smoke task. Auto-shipped by smoke-write.sh.',
|
|
83
132
|
'status': 'ready',
|
|
@@ -177,6 +226,45 @@ else
|
|
|
177
226
|
echo "SKIP session_logs DB check (set GDS_SMOKE_DB_SSH + GDS_SMOKE_DB_NAME to enable)"
|
|
178
227
|
fi
|
|
179
228
|
|
|
229
|
+
echo
|
|
230
|
+
echo "--- 5. clean up the throwaway task ---"
|
|
231
|
+
# task 1002018 — this smoke CREATES a real task to exercise the create API, and
|
|
232
|
+
# used to leave it in the ledger forever: 94 rows accumulated under PMS-V1 before
|
|
233
|
+
# the task-2016 sweep cleared them by hand. Abandon is the right terminal state —
|
|
234
|
+
# a smoke row is not shipped work, and the route refuses to abandon a shipped one
|
|
235
|
+
# anyway (cannot_abandon_shipped). The row sits at 'completed' after step 3, and
|
|
236
|
+
# only 'shipped' and an unreleased claim are refused, so this is always legal here.
|
|
237
|
+
#
|
|
238
|
+
# ONLY on success, deliberately: a FAILED run leaves its task and claim in place
|
|
239
|
+
# so the failure can be inspected in the ledger, which is why the FAIL branch of
|
|
240
|
+
# the summary below still prints both ids.
|
|
241
|
+
#
|
|
242
|
+
# Strictly best-effort. A cleanup that cannot run must never turn a passing smoke
|
|
243
|
+
# into a failing one, so this never touches ${fail} and never trips set -e:
|
|
244
|
+
# task.abandon is floor-metic (ADR 0152), so a Xenos/Thetes token gets a 403 here
|
|
245
|
+
# while every assertion above genuinely passed.
|
|
246
|
+
cleanup_throwaway_task() {
|
|
247
|
+
local out code
|
|
248
|
+
local reason='{"reason":"smoke cleanup (task 1002018) - write-path smoke passed; the throwaway row has served its purpose"}'
|
|
249
|
+
out=$(curl -sS -X POST \
|
|
250
|
+
-H "Authorization: Bearer ${TOKEN}" -H "Content-Type: application/json" \
|
|
251
|
+
-d "${reason}" -w "\n%{http_code}" \
|
|
252
|
+
"${API}/api/gds/tasks/${TASK_ID}/abandon") || true
|
|
253
|
+
code="${out##*$'\n'}"
|
|
254
|
+
case "${code}" in
|
|
255
|
+
2*) echo "PASS cleaned up throwaway task #${TASK_ID} (abandoned)" ;;
|
|
256
|
+
403) echo "SKIP cleanup — task.abandon needs Metic+ (ADR 0152); task #${TASK_ID} left in the ledger" ;;
|
|
257
|
+
000) echo "SKIP cleanup — POST /tasks/${TASK_ID}/abandon got no response; task #${TASK_ID} left in the ledger" ;;
|
|
258
|
+
*) echo "SKIP cleanup — POST /tasks/${TASK_ID}/abandon returned HTTP ${code:-none}; task #${TASK_ID} left in the ledger" ;;
|
|
259
|
+
esac
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if [[ "${fail}" -eq 0 ]]; then
|
|
263
|
+
cleanup_throwaway_task
|
|
264
|
+
else
|
|
265
|
+
echo "SKIP cleanup — run FAILED; task #${TASK_ID} and claim #${CLAIM_ID} kept for post-mortem"
|
|
266
|
+
fi
|
|
267
|
+
|
|
180
268
|
echo
|
|
181
269
|
echo "--- summary ---"
|
|
182
270
|
if [[ "${fail}" -eq 0 ]]; then
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.706'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// tests/exec_path_guard.mjs — task 1003371, audit ref B50 (2026-08-29 security audit).
|
|
2
|
+
//
|
|
3
|
+
// WHAT THE CHECK IS FOR. Nothing in the repo asked what the SERVER EXECUTES, or out of
|
|
4
|
+
// whose tree. route-rank-check asks who may call a route; permission-path-check asks who
|
|
5
|
+
// may edit a file; neither looks at execution. All three CRITICALs in the audit lived in
|
|
6
|
+
// that gap.
|
|
7
|
+
//
|
|
8
|
+
// WHAT THESE TESTS PIN. Two things, and the second is the one that matters:
|
|
9
|
+
// 1. the check CATCHES the defect shape — a script path assembled from a runtime value;
|
|
10
|
+
// 2. it does not catch everything that merely LOOKS like one. A scan that fires on
|
|
11
|
+
// `/re/.exec(s)` or on the word "spawn (" inside a sentence gets exempted into
|
|
12
|
+
// uselessness within a month, so the negative cases are pinned as hard as the
|
|
13
|
+
// positive ones. Both of those false positives were real during development.
|
|
14
|
+
import assert from 'node:assert/strict';
|
|
15
|
+
import { test } from 'node:test';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { execFileSync } from 'node:child_process';
|
|
19
|
+
import { fileURLToPath } from 'node:url';
|
|
20
|
+
|
|
21
|
+
// fileURLToPath, not URL.pathname. Both a test (tests/test_path_guard.mjs) and a
|
|
22
|
+
// fitness check (scripts/gds/test-path-guard.js, Check 30) reject the pathname form.
|
|
23
|
+
const require = createRequire(import.meta.url);
|
|
24
|
+
const ROOT = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
|
|
25
|
+
const g = require(path.join(ROOT, 'scripts', 'gds', 'exec-path-guard.js'));
|
|
26
|
+
|
|
27
|
+
const scan = (src, rel = 'modules/probe/x.js') => g.scanFile(rel, src);
|
|
28
|
+
const CP = "const { execFile, execFileSync, spawn, execSync } = require('node:child_process');\n";
|
|
29
|
+
|
|
30
|
+
// ---- the defect shape it must catch ---------------------------------------
|
|
31
|
+
|
|
32
|
+
test('a script path built from a runtime value is flagged', () => {
|
|
33
|
+
// conflict-resolve.js:382 in miniature: arg 0 is perfectly static (process.execPath)
|
|
34
|
+
// and the DANGER is in the args array, which is why the check reads inside it.
|
|
35
|
+
const f = scan(CP + "execFile(process.execPath, [path.join(work, rel)], { cwd: work });");
|
|
36
|
+
assert.equal(f.length, 1, 'the clone-rooted script path must be reported');
|
|
37
|
+
assert.equal(f[0].role, 'script', 'the finding must name the args-array script, not arg 0');
|
|
38
|
+
assert.match(f[0].expr, /work/);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('a non-static EXECUTABLE is flagged too, not just the script', () => {
|
|
42
|
+
const f = scan(CP + 'spawn(bin, args);');
|
|
43
|
+
assert.equal(f.length, 1);
|
|
44
|
+
assert.equal(f[0].role, 'executable');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('a path laundered through a helper still reads as non-static (fails toward noise)', () => {
|
|
48
|
+
// The scan does not follow values across functions. The honest consequence is that an
|
|
49
|
+
// indirect path is FLAGGED rather than missed — pinned so the direction of failure is
|
|
50
|
+
// a decision on record, not an accident.
|
|
51
|
+
const f = scan(CP + 'execFile(process.execPath, [resolveScript()], {});');
|
|
52
|
+
assert.equal(f.length, 1);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// ---- what it must NOT catch ------------------------------------------------
|
|
56
|
+
|
|
57
|
+
test("a regex's .exec() is never mistaken for child_process.exec", () => {
|
|
58
|
+
// The dominant false positive: `.exec(` outnumbers real spawner calls ~5:1 in this repo.
|
|
59
|
+
const src = "const m = /^Bearer\\s+(\\w+)$/.exec(header);\nconst k = RE.exec(line);";
|
|
60
|
+
assert.deepEqual(scan(src), [], 'a file that never requires child_process has no call sites');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('the word "spawn (" inside a string is not a call site', () => {
|
|
64
|
+
// Real regression: modules/grading/grader.js carries "before worker spawn
|
|
65
|
+
// (GRADER_BYPASS_ENABLED / runOpts.bypass)" in a note string, and the first draft
|
|
66
|
+
// reported that sentence as an executed path.
|
|
67
|
+
const src = CP + "const note = 'Grader bypassed before worker spawn (GRADER_BYPASS_ENABLED / x) — none ran.';";
|
|
68
|
+
assert.deepEqual(scan(src), []);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('a call site inside a comment is not a call site', () => {
|
|
72
|
+
assert.deepEqual(scan(CP + '// execFile(process.execPath, [path.join(work, rel)]);'), []);
|
|
73
|
+
assert.deepEqual(scan(CP + '/* spawn(bin, args); */'), []);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('a literal binary and a __dirname-rooted script are static', () => {
|
|
77
|
+
assert.deepEqual(scan(CP + "execSync('git rev-parse HEAD');"), []);
|
|
78
|
+
assert.deepEqual(scan(CP + "execFile(process.execPath, [path.join(__dirname, 'serve.js')]);"), []);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('a module-level const rooted at __dirname is static, transitively', () => {
|
|
82
|
+
const src = CP
|
|
83
|
+
+ "const KIT_DIR = path.join(__dirname, 'kit');\n"
|
|
84
|
+
+ "const ENTRY = path.join(KIT_DIR, 'serve.js');\n"
|
|
85
|
+
+ 'spawn(process.execPath, [ENTRY]);';
|
|
86
|
+
assert.deepEqual(scan(src), [], 'const → const → __dirname must resolve to static');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('a const declared INSIDE a function is not a fixed root', () => {
|
|
90
|
+
// Module level is column 0. A const rebound per call is a runtime value however it reads.
|
|
91
|
+
const src = CP + 'function run(work) {\n const entry = path.join(work, "gen.js");\n spawn(process.execPath, [entry]);\n}';
|
|
92
|
+
assert.equal(scan(src).length, 1);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ---- template literals: the one-character bypass (grade round 2) ----------
|
|
96
|
+
|
|
97
|
+
test('a path built from a TEMPLATE LITERAL is flagged, not silently static', () => {
|
|
98
|
+
// The bypass: stripStrings() used to blank a whole template, leaving zero free
|
|
99
|
+
// identifiers, and isStatic()'s every() over an empty set is vacuously true. Writing a
|
|
100
|
+
// backtick instead of calling path.join defeated the entire check for exactly the
|
|
101
|
+
// runtime-derived path B1/B50 are about.
|
|
102
|
+
const f = scan(CP + 'execFile(process.execPath, [`${work}/gen.js`]);');
|
|
103
|
+
assert.equal(f.length, 1, 'a template-literal script path must be reported');
|
|
104
|
+
assert.match(f[0].expr, /work/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('a template-literal require() specifier is flagged too', () => {
|
|
108
|
+
assert.equal(scan('const m = require(`${dir}/routes.js`);').length, 1);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('stripStrings keeps interpolated expressions and drops literal text', () => {
|
|
112
|
+
// Pinned at unit altitude: a regression here is invisible through scanFile alone,
|
|
113
|
+
// because the symptom is a finding that quietly stops appearing.
|
|
114
|
+
assert.match(g.stripStrings('`${a}/x/${b}`'), /a/);
|
|
115
|
+
assert.match(g.stripStrings('`${a}/x/${b}`'), /b/);
|
|
116
|
+
assert.doesNotMatch(g.stripStrings('`${a}/secretword/${b}`'), /secretword/);
|
|
117
|
+
assert.equal(g.isStatic('`${work}/gen.js`', new Set(['path', '__dirname'])), false);
|
|
118
|
+
assert.equal(g.isStatic('`no-holes-here`', new Set(['path'])), true, 'a hole-less template is a constant');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('a template rooted at __dirname is still static', () => {
|
|
122
|
+
assert.deepEqual(scan(CP + 'execFile(process.execPath, [`${__dirname}/serve.js`]);'), []);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// ---- the unbound + ESM forms (grade round 1: the check's own contract) -----
|
|
126
|
+
|
|
127
|
+
test('an UNBOUND require(...).execFileSync(runtimePath) is caught', () => {
|
|
128
|
+
// The gap that failed the first grade. This form binds no name, and the first draft
|
|
129
|
+
// short-circuited the whole file on "no bindings" — so a file spawning this way was
|
|
130
|
+
// skipped entirely. Not hypothetical: scripts/gds/autonomy-gate.js and
|
|
131
|
+
// scripts/gds/ship-land.js already use exactly this shape.
|
|
132
|
+
const f = scan("require('node:child_process').execFileSync(process.execPath, [path.join(work, rel)]);");
|
|
133
|
+
assert.equal(f.length, 1, 'the unbound spawner form must not escape the scan');
|
|
134
|
+
assert.equal(f[0].role, 'script');
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('a file with NO child_process binding is still scanned, not skipped wholesale', () => {
|
|
138
|
+
// The severity of the round-1 finding was not the missed call — it was that ONE
|
|
139
|
+
// unbound call made the scan abandon the ENTIRE file.
|
|
140
|
+
const src = "require('child_process').exec(userCmd);\nconst x = 1;";
|
|
141
|
+
assert.equal(scan(src).length, 1);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('an ESM named import of a spawner is tracked, renames included', () => {
|
|
145
|
+
const src = "import { execFile as run } from 'node:child_process';\nrun(process.execPath, [path.join(work, 'x.js')]);";
|
|
146
|
+
assert.equal(scan(src).length, 1, 'the repo is CJS today; a check that only reads CJS dies silently on conversion');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// ---- dynamic require(): same question, different mechanism -----------------
|
|
150
|
+
|
|
151
|
+
test('a require() of a runtime path is reported as an executed module', () => {
|
|
152
|
+
// src/module-loader/loader.js:190 is the second site the audit named. The server
|
|
153
|
+
// LOADS AND RUNS that module in-process, which is execution by another name.
|
|
154
|
+
const f = scan("const m = require(path.join(dir, 'routes', rk + '.js'));");
|
|
155
|
+
assert.equal(f.length, 1);
|
|
156
|
+
assert.equal(f[0].role, 'module');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('an ordinary literal require is not a finding', () => {
|
|
160
|
+
// The whole repo is built on these; firing on them would make the check unusable.
|
|
161
|
+
assert.deepEqual(scan("const path = require('node:path');\nconst db = require('../db');"), []);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ---- enumeration coverage --------------------------------------------------
|
|
165
|
+
|
|
166
|
+
test('enumeration is complete for src/ and modules/, and not glob-dependent', () => {
|
|
167
|
+
// Round-1 finding: the scan enumerated via 'src/**/*.js'-style pathspecs, whose
|
|
168
|
+
// completeness rests on git matching '*' ACROSS '/' — true, but a property a reader
|
|
169
|
+
// must already know to trust the line. This pins the result rather than the spelling:
|
|
170
|
+
// whatever the implementation, it must see every tracked .js under src/ and modules/
|
|
171
|
+
// that is not a browser or desktop-app asset.
|
|
172
|
+
const listed = new Set(g.trackedServerFiles());
|
|
173
|
+
const expected = execFileSync('git', ['ls-files', '--', 'src', 'modules'], { cwd: ROOT, encoding: 'utf8' })
|
|
174
|
+
.split('\n').map((s) => s.trim()).filter(Boolean)
|
|
175
|
+
.filter((f) => f.endsWith('.js'))
|
|
176
|
+
.filter((f) => !f.includes('/public/') && !f.includes('/app/'));
|
|
177
|
+
const missing = expected.filter((f) => !listed.has(f));
|
|
178
|
+
assert.deepEqual(missing, [], 'every tracked server .js must be scanned — silent narrowing is the one failure this check cannot survive');
|
|
179
|
+
assert.ok(expected.length > 300, `enumeration collapsed to ${expected.length} files`);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// ---- binding resolution ----------------------------------------------------
|
|
183
|
+
|
|
184
|
+
test('only names actually bound to child_process count as spawners', () => {
|
|
185
|
+
// `spawn` here is a local helper with nothing to do with processes.
|
|
186
|
+
const src = "const spawn = (n) => new Array(n);\nspawn(itemCount);";
|
|
187
|
+
assert.deepEqual(scan(src), []);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('a renamed destructured binding is still tracked', () => {
|
|
191
|
+
const src = "const { execFile: ef } = require('node:child_process');\nef(process.execPath, [path.join(work, 'x.js')]);";
|
|
192
|
+
assert.equal(scan(src).length, 1, 'an alias must not escape the scan');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('a namespace binding (cp.execFile) is tracked', () => {
|
|
196
|
+
const src = "const cp = require('child_process');\ncp.execFile(process.execPath, [path.join(work, 'x.js')]);";
|
|
197
|
+
assert.equal(scan(src).length, 1);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('bindings survive masking — the require specifier is a string body', () => {
|
|
201
|
+
// Regression: resolving bindings from the MASKED source blanked
|
|
202
|
+
// 'node:child_process' itself, so no file had bindings and the whole scan passed
|
|
203
|
+
// vacuously. A check that silently passes everything is worse than no check.
|
|
204
|
+
const { direct } = g.resolveBindings(CP);
|
|
205
|
+
assert.ok(direct.has('execFile') && direct.has('spawn'), 'bindings must resolve from real source');
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// ---- the exemption list is a review, not a mute ----------------------------
|
|
209
|
+
|
|
210
|
+
test('every exemption names a file:line and carries a substantive reason', () => {
|
|
211
|
+
for (const e of g.EXEMPTIONS) {
|
|
212
|
+
assert.match(e.at, /^[\w./-]+:\d+$/, `${e.at} must be a repo-relative file:line`);
|
|
213
|
+
assert.ok(e.why && e.why.length > 80,
|
|
214
|
+
`${e.at} must record WHY a runtime path is correct there — a bare entry is a mute, not a review.`);
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('the motivating CRITICAL is on the list with its compensating control named', () => {
|
|
219
|
+
// If conflict-resolve.js:382 ever drops off this list silently, the audit finding has
|
|
220
|
+
// been forgotten rather than fixed.
|
|
221
|
+
const e = g.EXEMPTIONS.find((x) => x.at.startsWith('modules/lifecycle/conflict-resolve.js'));
|
|
222
|
+
assert.ok(e, 'the B1 site must be accounted for explicitly');
|
|
223
|
+
assert.match(e.why, /computeExecutedClosure|1003367/,
|
|
224
|
+
'the exemption must name the control that makes it safe, not merely assert it is');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ---- the check runs clean on the real tree --------------------------------
|
|
228
|
+
|
|
229
|
+
test('the repo passes, and the scan is not vacuous', () => {
|
|
230
|
+
const r = g.checkExecPathsStatic();
|
|
231
|
+
assert.equal(r.hardFail, false, `unexempted executed paths:\n${r.violations.join('\n')}`);
|
|
232
|
+
assert.ok(/\b(\d{2,})\s+server file\(s\) scanned/.test(r.note),
|
|
233
|
+
'a scan reporting a handful of files has broken its enumeration');
|
|
234
|
+
assert.equal(r.exempt.length, g.EXEMPTIONS.length,
|
|
235
|
+
'every exemption must still match a real finding — a stale one means the code moved and the '
|
|
236
|
+
+ 'review was silently carried forward');
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('exemptions never surface as warnings — a permanently-yellow check stops being read', () => {
|
|
240
|
+
const r = g.checkExecPathsStatic();
|
|
241
|
+
assert.deepEqual(r.warnings, [], 'reviewed exemptions belong in `exempt`, not `warnings`');
|
|
242
|
+
assert.ok(r.exempt.length > 0, 'and they must still be reported somewhere');
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test('an empty file list is a scan defect, not a pass', () => {
|
|
246
|
+
const r = g.checkExecPathsStatic({ files: [] });
|
|
247
|
+
assert.equal(r.hardFail, true, 'zero files enumerated must fail closed');
|
|
248
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// tests/smoke_write_contract.mjs — the write-path smoke's own contract
|
|
2
|
+
// (task 1002018).
|
|
3
|
+
//
|
|
4
|
+
// TWO DEFECTS, ONE FILE. Both were found by trying to RUN scripts/gds/smoke-write.sh
|
|
5
|
+
// rather than by reading it, and neither was visible to any existing test:
|
|
6
|
+
//
|
|
7
|
+
// 1. IT COULD NOT RUN AT ALL. goal_id became required on POST /tasks (task 1003598,
|
|
8
|
+
// ADR 0250 D4) and this script still sent none, so step 1 died on a 400
|
|
9
|
+
// goal_id_required. The production write-path smoke had been dead since that
|
|
10
|
+
// landed, and nothing said so — the same silent-rot shape as the PMS-V1 version
|
|
11
|
+
// literal that tests/version_literals.mjs exists to prevent, one field over.
|
|
12
|
+
//
|
|
13
|
+
// 2. IT LEAKED ITS THROWAWAY TASK. The smoke creates a REAL task to exercise the
|
|
14
|
+
// create API and never disposed of it: 94 rows accumulated under PMS-V1 before
|
|
15
|
+
// the task-2016 sweep cleared them by hand.
|
|
16
|
+
//
|
|
17
|
+
// WHY ASSERT ON SOURCE TEXT. This script talks to a live API, needs a session
|
|
18
|
+
// token and (for the rest of the file) python3, so it cannot be executed in the
|
|
19
|
+
// DB-free unit gate. Source assertions are what CAN run here. They are written
|
|
20
|
+
// narrowly — each one names a specific defect that actually happened, and the
|
|
21
|
+
// resolution assertions deliberately REJECT a hardcoded id rather than merely
|
|
22
|
+
// requiring the field to be present, because "send goal_id" is satisfied just as
|
|
23
|
+
// well by a literal that goes stale the moment the version rolls. That is the
|
|
24
|
+
// over-broad-source-assertion trap this repo keeps relearning (learning 1000196):
|
|
25
|
+
// the cure is specificity, not a bigger regex.
|
|
26
|
+
import { strict as assert } from 'node:assert';
|
|
27
|
+
import { test } from 'node:test';
|
|
28
|
+
import fs from 'node:fs';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import { fileURLToPath } from 'node:url';
|
|
31
|
+
|
|
32
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
33
|
+
const SH = fs.readFileSync(path.join(ROOT, 'scripts', 'gds', 'smoke-write.sh'), 'utf8');
|
|
34
|
+
|
|
35
|
+
// ---- defect 1: the create must carry a goal_id, resolved not hardcoded -----
|
|
36
|
+
|
|
37
|
+
test('the create body sends goal_id at all (the 400 that killed the smoke)', () => {
|
|
38
|
+
assert.match(SH, /'goal_id':/, 'POST /tasks has required goal_id since task 1003598');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('goal_id is the RESOLVED id, never a literal', () => {
|
|
42
|
+
assert.match(SH, /'goal_id': \$\{GOAL_ID\}/, 'the create interpolates the resolved id');
|
|
43
|
+
assert.ok(
|
|
44
|
+
!/'goal_id': *[0-9]/.test(SH),
|
|
45
|
+
'a numeric goal id in the body would go stale the moment the version rolls'
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('GOAL_ID is resolved at runtime, from the is_maintenance flag', () => {
|
|
50
|
+
assert.match(SH, /GOAL_ID=\$\(call GET "\/api\/gds\/goals\?version=\$\{VERSION_ID\}"/,
|
|
51
|
+
'it asks the API for this version-s goals');
|
|
52
|
+
assert.match(SH, /is_maintenance/, 'and picks the maintenance goal by its flag');
|
|
53
|
+
// Exactly one open maintenance goal exists per version (BV1.R18). Ambiguity here
|
|
54
|
+
// means the instance is misconfigured, and guessing would file the smoke row into
|
|
55
|
+
// a real working area — so the resolution must refuse rather than pick.
|
|
56
|
+
assert.match(SH, /m\.length !== 1/, 'it refuses on anything but exactly one match');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// ---- defect 2: the throwaway task is disposed of ---------------------------
|
|
60
|
+
|
|
61
|
+
test('a passing run abandons its throwaway task', () => {
|
|
62
|
+
assert.match(SH, /cleanup_throwaway_task\(\) \{/, 'the cleanup exists');
|
|
63
|
+
assert.match(SH, /tasks\/\$\{TASK_ID\}\/abandon/, 'and it abandons the row it created');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('cleanup runs ONLY on success — a failed run keeps its evidence', () => {
|
|
67
|
+
const guard = SH.slice(SH.indexOf('--- 5. clean up'));
|
|
68
|
+
assert.match(guard, /if \[\[ "\$\{fail\}" -eq 0 \]\]; then\s*\n\s*cleanup_throwaway_task/,
|
|
69
|
+
'the call is gated on fail==0');
|
|
70
|
+
assert.match(guard, /kept for post-mortem/,
|
|
71
|
+
'and a failing run says why the row is still there');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('cleanup is best-effort: it can never fail a passing smoke', () => {
|
|
75
|
+
const fn = SH.slice(SH.indexOf('cleanup_throwaway_task() {'), SH.indexOf('--- summary ---'));
|
|
76
|
+
// The three ways this could turn a PASS into a FAIL, each closed:
|
|
77
|
+
assert.match(fn, /\|\| true/, 'the curl cannot trip set -e');
|
|
78
|
+
assert.ok(!/fail=1/.test(fn), 'and it never touches the fail counter');
|
|
79
|
+
// task.abandon is floor-metic (ADR 0152), so a Xenos/Thetes token gets a 403
|
|
80
|
+
// here while every assertion above genuinely passed. That must read as SKIP.
|
|
81
|
+
assert.match(fn, /403\)/, 'a rank refusal is reported as SKIP, not as a failure');
|
|
82
|
+
assert.match(fn, /000\)/, 'so is no response at all');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ---- the id is spliced as CODE, so its SHAPE is a security property ---------
|
|
86
|
+
//
|
|
87
|
+
// Raised by the grader's hacker worker on the first round of this task, and it
|
|
88
|
+
// was right: `'goal_id': ${GOAL_ID},` lands inside a `python3 -c "..."` SOURCE
|
|
89
|
+
// string, unquoted. The resolver validated the goal COUNT and never the id's own
|
|
90
|
+
// shape, so a non-numeric id from /goals — schema drift, a misbehaving API, any
|
|
91
|
+
// future path where an id-like field is influenced — would have been executed as
|
|
92
|
+
// Python rather than read as data. Validating the count is not validating the id.
|
|
93
|
+
|
|
94
|
+
test('the resolver refuses a non-numeric goal id', () => {
|
|
95
|
+
const ext = SH.slice(SH.indexOf('GOAL_ID=$(call GET'), SH.indexOf("') || {"));
|
|
96
|
+
assert.match(ext, /\/\^\[0-9\]\+\$\//, 'the extracted id is shape-checked');
|
|
97
|
+
assert.match(ext, /is not numeric/, 'and says so when it refuses');
|
|
98
|
+
// The check must come BEFORE the value is emitted, or it guards nothing.
|
|
99
|
+
assert.ok(
|
|
100
|
+
ext.indexOf('is not numeric') < ext.lastIndexOf('process.stdout.write'),
|
|
101
|
+
'the refusal precedes the write'
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('and the interpolation site re-checks it', () => {
|
|
106
|
+
// Belt-and-braces where the risk actually is: between resolution and the
|
|
107
|
+
// python3 heredoc the value passes through shell, so it is re-validated there.
|
|
108
|
+
assert.match(SH, /\[\[ "\$\{GOAL_ID\}" =~ \^\[0-9\]\+\$ \]\]/,
|
|
109
|
+
'bash re-checks the id before the create body interpolates it');
|
|
110
|
+
});
|