@bongos/core 1.20.41 → 1.20.42

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 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.20.41",
6
- "core_contract": "1.20.41",
7
- "source_commit": "e537f265c1269c76691d50bf25c111080be455f1",
5
+ "core_version": "1.20.42",
6
+ "core_contract": "1.20.42",
7
+ "source_commit": "5453ba448339c4d1ba0bfbf24b8efee9121377a6",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-10-01T00:59:19.394Z",
9
+ "built_at": "2026-10-01T01:33:09.710Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 563,
13
13
  "agent_docs_stubbed": 27,
14
- "functional_verbatim": 2687,
14
+ "functional_verbatim": 2689,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 3278,
20
- "tree_sha256": "0c3d1503ca07445b27ce4fbdd31fa8b9e6706bc6a6643335cfc75b9f0eae9425",
19
+ "file_count": 3280,
20
+ "tree_sha256": "b8cd8e728744399dbc32d6496698b9979b49fd393057e0ea691cdf10d7f05135",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2257,7 +2257,7 @@
2257
2257
  {
2258
2258
  "path": "docs/architecture.md",
2259
2259
  "mode": "0000644",
2260
- "sha256": "e73b5883090da09febf6fcbb3b23ccc6582623eb864b385b759687c061ea4cd3"
2260
+ "sha256": "345ab85b3665ae561a435cf1eccfa5ddb0464176153a12d85c84ddbc9f5cfc82"
2261
2261
  },
2262
2262
  {
2263
2263
  "path": "docs/branding-contract.md",
@@ -2787,7 +2787,7 @@
2787
2787
  {
2788
2788
  "path": "docs/module-api-changelog.md",
2789
2789
  "mode": "0000644",
2790
- "sha256": "08ec9b35f24c0267ab6b9f3f39c7eb3cefbb5e62b96ada2505e24f8928438881"
2790
+ "sha256": "24c164c2f2b3c3adc7677b762593b08616e649824d6b08a99c588fef468433a6"
2791
2791
  },
2792
2792
  {
2793
2793
  "path": "docs/modules-contract.md",
@@ -9037,12 +9037,12 @@
9037
9037
  {
9038
9038
  "path": "package-lock.json",
9039
9039
  "mode": "0000644",
9040
- "sha256": "afc9f33e08cf7b0347f62d5ce0bc85b4368dbb0d90df1f1ef1c815a82b61521f"
9040
+ "sha256": "892ffba9754d3e54ec9ed9a7ae0b2cfbe81719c5cef44076b0a4a226a102bd42"
9041
9041
  },
9042
9042
  {
9043
9043
  "path": "package.json",
9044
9044
  "mode": "0000644",
9045
- "sha256": "ac656023e65736d012cfa9f7dbbad627dbb4d42758ba832ffd3167a8b30a5b03"
9045
+ "sha256": "8583d2e551ed1df355c051cf4f13e14c8d299391699e244d8a707f66008bd42b"
9046
9046
  },
9047
9047
  {
9048
9048
  "path": "public-docs/index.html",
@@ -9062,7 +9062,7 @@
9062
9062
  {
9063
9063
  "path": "release-notes.json",
9064
9064
  "mode": "0000644",
9065
- "sha256": "9eb3a2a173030074171bb3b7187b16296ad7198b79050772a690e319dd9c7d06"
9065
+ "sha256": "a726d78b6bc53a7cbf9c94205499975b751f9ffb271162881c1966983a1c00ea"
9066
9066
  },
9067
9067
  {
9068
9068
  "path": "scripts/bongos-mcp.js",
@@ -9907,7 +9907,12 @@
9907
9907
  {
9908
9908
  "path": "scripts/gds/module-artifact.js",
9909
9909
  "mode": "0000644",
9910
- "sha256": "09211dcd5bae4b935b5f80a2c021a1c10a376236b1c7ea7ebc1f53b84d1ead40"
9910
+ "sha256": "a16e9cee3135bcd14ae99f58dfbd17657610c2fc5b5c126ed052ccca49297428"
9911
+ },
9912
+ {
9913
+ "path": "scripts/gds/module-assess-security.js",
9914
+ "mode": "0000644",
9915
+ "sha256": "0dad366dcabf453ae276e074aafa6bd71223853a31cfe005dac6f3d713e12e4a"
9911
9916
  },
9912
9917
  {
9913
9918
  "path": "scripts/gds/module-assess-tests.js",
@@ -11217,7 +11222,7 @@
11217
11222
  {
11218
11223
  "path": "src/module-api.js",
11219
11224
  "mode": "0000644",
11220
- "sha256": "b1e5fefed87830633778aef9a1076002ee2e0067b8868789a2411be6b453c366"
11225
+ "sha256": "8a5db5a4ee82ff0f0ef9f8ec1986995f6ffee351161d85320f2f91656900bb06"
11221
11226
  },
11222
11227
  {
11223
11228
  "path": "src/module-loader/catalog.js",
@@ -14194,6 +14199,11 @@
14194
14199
  "mode": "0000644",
14195
14200
  "sha256": "9183d78e4d195428afbee8c71746e12db2455f8529ac1223d690a7b8f7ded538"
14196
14201
  },
14202
+ {
14203
+ "path": "tests/module_assess_security.mjs",
14204
+ "mode": "0000644",
14205
+ "sha256": "6868f5c8ba6fbef96eab63b0726f8d3d2261aa146a0a4566c1c1929e6134a245"
14206
+ },
14197
14207
  {
14198
14208
  "path": "tests/module_assess_tests.mjs",
14199
14209
  "mode": "0000644",
@@ -272,7 +272,7 @@ module_assessment_scores(id, version_id, kind IN (computed|override), overall 0-
272
272
  -- and is a new row on the computed history, never an edit (D6)
273
273
  ```
274
274
 
275
- Signals that fill it: **Tests** — `scripts/gds/module-assess-tests.js <version id>` (task 1003791) unpacks the published tarball to `<core root>/.module-assess-<run>/<key>/` (gitignored, removed after), runs each `tests/*.mjs` in its own node process with a timeout and a credential-free environment — regardless of `isModuleEnabled`, which skips every default-off catalog module in the unit gate — and appends one `tests` row: `scored` = % of test files passing (sample_size = files), `no_data` = declares no tests, `not_scored` = tarball unreadable. The store path **refuses unless `MODULE_TEST_SANDBOX=1`** — a published module's tests run only in a separate testing environment with no secrets on disk, never on the control plane (owner, 2026-09-30). `--dir modules/<key>` is a DB-free dry run on your own checkout. Nothing calls it on publish yet (task 1003794 wires that).
275
+ Signals that fill it: **Tests** — `scripts/gds/module-assess-tests.js <version id>` (task 1003791) unpacks the published tarball to `<core root>/.module-assess-<run>/<key>/` (gitignored, removed after), runs each `tests/*.mjs` in its own node process with a timeout and a credential-free environment — regardless of `isModuleEnabled`, which skips every default-off catalog module in the unit gate — and appends one `tests` row: `scored` = % of test files passing (sample_size = files), `no_data` = declares no tests, `not_scored` = tarball unreadable. The store path **refuses unless `MODULE_TEST_SANDBOX=1`** — a published module's tests run only in a separate testing environment with no secrets on disk, never on the control plane (owner, 2026-09-30). `--dir modules/<key>` is a DB-free dry run on your own checkout. Nothing calls it on publish yet (task 1003794 wires that). **Security** — `scripts/gds/module-assess-security.js <version id>` (task 1003792, ADR 0343 D2 gate) appends one `security` row, `passed`/`failed` only: fails on a publish-denylist file, a `module.json` dependency fetched outside the registry (an allowlist: a plain npm name with a plain semver range or dist-tag, anything else fails and is never handed to npm), or a high/critical `npm audit` advisory (resolved metadata-only with `--ignore-scripts`; nothing of the module runs, so it is control-plane safe). An audit that cannot run is `not_scored` — the gate stays shut. Floating ranges and the `maintenance` posture (ADR 0166) are noted in `detail`, never failed on.
276
276
 
277
277
  Code: `src/bongos/module-entitlements.js` (`grantEntitlement`, `recordAcquired`, `revokeEntitlement`, `checkEntitlement`, `listEntitlements`). Read routes (own-scoped, `requireBuilder`): `GET /store/entitlements`, `GET /store/modules/:key/entitlement`. Install (task 1003785) grants a free module through `POST /store/modules/:key/acquire`; the buy action (area 8) will grant a paid one.
278
278
 
@@ -2739,5 +2739,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2739
2739
  landed since 1.20.39 with no explicit bump. run 36797045221. (task 1002620)
2740
2740
  1.20.41 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2741
2741
  landed since 1.20.40 with no explicit bump. run 36798842066. (task 1002620)
2742
+ 1.20.42 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2743
+ landed since 1.20.41 with no explicit bump. run 36801585300. (task 1002620)
2742
2744
  ---------------------------------------------------------------------------
2743
2745
  ```
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.41",
3
+ "version": "1.20.42",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.41",
9
+ "version": "1.20.42",
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.20.41",
3
+ "version": "1.20.42",
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",
@@ -8355,5 +8355,11 @@
8355
8355
  "id": "1004470",
8356
8356
  "text": "Projects that don't do pixel art or design styling no longer carry 18 unused AI commands in every session (about a third less to load); projects that want them turn them on with one setting."
8357
8357
  }
8358
+ ],
8359
+ "1.20.42": [
8360
+ {
8361
+ "id": "1003792",
8362
+ "text": "The module store can now run a security check on each module version: it fails a version that ships a secret file, pulls code from outside the official package registry, or depends on a package with a known serious vulnerabili"
8363
+ }
8358
8364
  ]
8359
8365
  }
@@ -216,4 +216,4 @@ async function verifyModuleArtifact(tgz, { key, maxBytes = MAX_TARBALL_BYTES, in
216
216
  return out;
217
217
  }
218
218
 
219
- module.exports = { MAX_TARBALL_BYTES, packModule, verifyModuleArtifact };
219
+ module.exports = { MAX_TARBALL_BYTES, packModule, verifyModuleArtifact, deniedFiles };
@@ -0,0 +1,251 @@
1
+ #!/usr/bin/env node
2
+ // scripts/gds/module-assess-security.js — the Security part of a module's
3
+ // assessment: a pass/fail GATE over one published store version, recorded as a
4
+ // module_assessment_signals row (task 1003792; ADR 0343 D2; table from core_265).
5
+ //
6
+ // WHY a gate and not a score. ADR 0343 D2: a version that fails the security
7
+ // check is not listed, whatever else it scores, and security is never averaged
8
+ // in — so a module cannot buy back a vulnerability with good tests. The outcome
9
+ // is therefore only ever passed / failed, or not_scored when the check could not
10
+ // be completed (which is NOT a pass: a gate that cannot run must not open).
11
+ //
12
+ // The check reuses what the project already enforces rather than re-typing it:
13
+ // 1. the publish denylist — module-artifact.js deniedFiles (the repo-wide
14
+ // matchesDeny list + credential-named files; ADR 0107 §4). Publish already
15
+ // refuses these; re-checking here means a rule added later still reaches
16
+ // versions published before it.
17
+ // 2. dependency SOURCES — every entry in module.json `dependencies` (ADR 0138)
18
+ // must be a plain registry package name with a plain semver range or dist-
19
+ // tag. That is an ALLOWLIST (registrySpec below), deliberately stricter than
20
+ // a list of bad protocols: a git/http/file, aliased or GitHub-shorthand spec
21
+ // — or anything else unrecognised — installs code from somewhere nobody
22
+ // vetted, and fails the gate. (It mirrors the artifact-scan floor's grading,
23
+ // but a core script may not require a module's files, so it is its own
24
+ // small rule.) Floating ranges and wildcards are only noted.
25
+ // 3. KNOWN VULNERABILITIES in those dependencies — only when every spec passed
26
+ // check 2, so nothing a module names is ever fetched from a place of its
27
+ // choosing and no `file:` path is ever resolved on this host. npm resolves the ranges in a
28
+ // throwaway directory (--package-lock-only --ignore-scripts: registry
29
+ // metadata only, no package code downloaded or run), then `npm audit`; any
30
+ // high or critical advisory fails the gate (the threshold scripts/security/
31
+ // dep-audit.js uses for the core, whose parser this reuses).
32
+ // And one input that is reported, never failed on: the `maintenance` posture
33
+ // (ADR 0166). "Undeclared", "deprecated" or "orphaned" is something a buyer
34
+ // should see, but it is upkeep, not a vulnerability — that call is recorded in
35
+ // the row's detail so the hall (task 1003799) can surface it.
36
+ //
37
+ // Nothing here executes the module's own code, so unlike module-assess-tests.js
38
+ // this is safe to run on the control plane.
39
+ //
40
+ // node scripts/gds/module-assess-security.js <store_module_versions.id> run + record
41
+ // node scripts/gds/module-assess-security.js --dir modules/<key> dry run (prints; no DB)
42
+
43
+ const fs = require('node:fs');
44
+ const os = require('node:os');
45
+ const path = require('node:path');
46
+ const { execFile } = require('node:child_process');
47
+ const { deniedFiles, verifyModuleArtifact } = require('./module-artifact');
48
+ const { extractNpmFindings } = require('../security/dep-audit');
49
+
50
+ const AUDIT_TIMEOUT_MS = 120_000;
51
+ // npm is a .cmd shim on Windows, which execFile cannot start without a shell; go
52
+ // through cmd.exe explicitly rather than shell:true (the args are fixed constants).
53
+ const npmCommand = (args) => (process.platform === 'win32'
54
+ ? ['cmd.exe', ['/d', '/s', '/c', 'npm', ...args]]
55
+ : ['npm', args]);
56
+
57
+ // Run one npm command in `cwd`; resolve { ok, stdout, stderr, error }. Never rejects.
58
+ function npmRun(args, cwd, { exec = execFile, timeoutMs = AUDIT_TIMEOUT_MS } = {}) {
59
+ return new Promise((resolve) => {
60
+ const [cmd, argv] = npmCommand(args);
61
+ exec(cmd, argv, { cwd, timeout: timeoutMs, maxBuffer: 32 * 1024 * 1024, windowsHide: true },
62
+ (err, stdout, stderr) => resolve({ err, stdout: String(stdout || ''), stderr: String(stderr || '') }));
63
+ });
64
+ }
65
+
66
+ // Audit a dependency map { name: range } for known high/critical advisories.
67
+ // Resolves { ok: true, findings } or { ok: false, reason } when npm could not
68
+ // resolve or audit (offline, unknown package, npm missing) — the caller treats
69
+ // that as not_scored, never as a pass.
70
+ async function auditDependencies(deps, { exec, timeoutMs } = {}) {
71
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'module-assess-security-'));
72
+ try {
73
+ fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'module-assess-audit', private: true, version: '0.0.0', dependencies: deps }));
74
+ const lock = await npmRun(['install', '--package-lock-only', '--ignore-scripts', '--no-audit', '--no-fund'], dir, { exec, timeoutMs });
75
+ if (lock.err || !fs.existsSync(path.join(dir, 'package-lock.json'))) {
76
+ return { ok: false, reason: 'npm could not resolve the declared dependencies' };
77
+ }
78
+ // npm audit exits 1 when it finds anything; the JSON on stdout is the answer either way.
79
+ const audit = await npmRun(['audit', '--json'], dir, { exec, timeoutMs });
80
+ let parsed;
81
+ try { parsed = JSON.parse(audit.stdout || ''); } catch { return { ok: false, reason: 'npm audit produced no readable report' }; }
82
+ if (parsed && parsed.error) return { ok: false, reason: 'npm audit could not reach the advisory database' };
83
+ return { ok: true, findings: extractNpmFindings(parsed) };
84
+ } finally {
85
+ fs.rmSync(dir, { recursive: true, force: true });
86
+ }
87
+ }
88
+
89
+ // npm's package-name rule (scoped or not, lowercase, no path segments).
90
+ const NPM_NAME_RE = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/;
91
+ // A semver range: versions, x/* wildcards, ^ ~ comparators, hyphen and || unions,
92
+ // prerelease/build suffixes. No ':' '/' '#' '@' — so no protocol, path, alias or ref.
93
+ const RANGE_RE = /^[0-9A-Za-z.*^~<>=|\s+-]+$/;
94
+ const DIST_TAGS = new Set(['latest', 'next']);
95
+
96
+ // Grade one declared dependency. { ok: false } means it would install from
97
+ // somewhere other than the public registry (or cannot be read as a registry
98
+ // spec): the gate fails and it is never handed to npm. { ok: true, kind } is a
99
+ // registry spec; kind names how much the registry decides at install time.
100
+ function registrySpec(name, spec) {
101
+ const s = String(spec).trim();
102
+ if (!NPM_NAME_RE.test(name)) return { ok: false, kind: 'invalid package name' };
103
+ if (DIST_TAGS.has(s) || s === '*' || s === '' || /^[xX]$/.test(s)) return { ok: true, kind: 'wildcard' };
104
+ if (/^[a-z+]+:/i.test(s)) return { ok: false, kind: 'non-registry source' };
105
+ // Every whitespace/||-separated token must start with a comparator or a digit/x.
106
+ const tokens = s.split(/\s*\|\|\s*|\s+/).filter(Boolean);
107
+ if (!RANGE_RE.test(s) || !tokens.every((t) => t === '-' || /^(?:[\^~]|[<>]=?|=)?\s*[0-9xX*]/.test(t))) {
108
+ return { ok: false, kind: 'not a registry range' };
109
+ }
110
+ if (/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(s)) return { ok: true, kind: 'pinned' };
111
+ return { ok: true, kind: 'floating range' };
112
+ }
113
+
114
+ // The maintenance posture as a buyer would read it (ADR 0166).
115
+ function maintenancePosture(moduleJson) {
116
+ const m = moduleJson && moduleJson.maintenance;
117
+ return m && typeof m.status === 'string' ? m.status : 'undeclared';
118
+ }
119
+
120
+ // The gate over one version's files ([{ path, buf }], module-relative) and its
121
+ // parsed module.json. Returns the signal fields { outcome, score, sample_size,
122
+ // reason, detail }. `audit` is injectable so tests need no network.
123
+ async function assessSecurity(key, files, moduleJson, { audit = auditDependencies } = {}) {
124
+ const failures = [];
125
+ const notes = [];
126
+
127
+ const denied = deniedFiles(key, files.map((f) => f.path));
128
+ if (denied.length) failures.push({ check: 'denylist', files: denied });
129
+
130
+ const deps = (moduleJson && moduleJson.dependencies) || {};
131
+ const names = Object.keys(deps).sort();
132
+ const sources = [];
133
+ for (const name of names) {
134
+ const graded = registrySpec(name, deps[name]);
135
+ const entry = { package: name.slice(0, 214), spec: String(deps[name]).slice(0, 200), kind: graded.kind };
136
+ if (!graded.ok) sources.push(entry);
137
+ else if (graded.kind === 'wildcard') notes.push({ check: 'dependency_wildcard', ...entry });
138
+ else if (graded.kind === 'floating range') notes.push({ check: 'dependency_range', ...entry });
139
+ }
140
+ if (sources.length) failures.push({ check: 'dependency_source', dependencies: sources });
141
+
142
+ // Audit only an all-registry list: a failed source already fails the gate, and
143
+ // handing it to npm would make this host fetch (or resolve a file: path) of the
144
+ // module's choosing.
145
+ let advisories = [];
146
+ if (names.length && !sources.length) {
147
+ const a = await audit(deps);
148
+ if (!a.ok) {
149
+ return {
150
+ outcome: 'not_scored', score: null, sample_size: names.length,
151
+ reason: `the dependency audit could not run: ${a.reason}`,
152
+ detail: { failures, notes, maintenance: maintenancePosture(moduleJson) },
153
+ };
154
+ }
155
+ advisories = a.findings.map((f) => ({ package: f.package, severity: f.severity, id: f.id, summary: f.summary, fix: f.fix }));
156
+ if (advisories.length) failures.push({ check: 'vulnerable_dependency', advisories });
157
+ }
158
+
159
+ const maintenance = maintenancePosture(moduleJson);
160
+ if (maintenance !== 'maintained' && maintenance !== 'core-maintained') {
161
+ notes.push({ check: 'maintenance', status: maintenance });
162
+ }
163
+
164
+ const detail = { failures, notes, maintenance, dependencies_checked: names.length };
165
+ if (failures.length) {
166
+ return { outcome: 'failed', score: null, sample_size: names.length, reason: `failed: ${failures.map((f) => f.check).join(', ')}`, detail };
167
+ }
168
+ return {
169
+ outcome: 'passed', score: null, sample_size: names.length,
170
+ reason: names.length ? `passed: ${names.length} dependency(ies) from the registry, no high or critical advisory` : 'passed: no denied files and no dependencies',
171
+ detail,
172
+ };
173
+ }
174
+
175
+ // Append one Security row. Never UPDATEs (core_265 is append-only).
176
+ async function insertSecuritySignal(versionId, s, { db }) {
177
+ const { rows: [row] } = await db.query(
178
+ `INSERT INTO module_assessment_signals (version_id, part, outcome, score, sample_size, reason, detail)
179
+ VALUES ($1, 'security', $2, $3, $4, $5, $6)
180
+ RETURNING id, version_id, part, outcome, score, sample_size, reason, measured_at`,
181
+ [versionId, s.outcome, s.score, s.sample_size, s.reason, JSON.stringify(s.detail || {})]);
182
+ return row;
183
+ }
184
+
185
+ // Read and verify one published version, run the gate, record the result. A
186
+ // tarball that is missing or fails verification is not_scored — the gate could
187
+ // not be run, so it stays closed.
188
+ async function recordSecuritySignal(versionId, { db, storeDir, audit } = {}) {
189
+ const pool = db || require('../../src/bongos/pool').pool;
190
+ const { versionArtifactFile } = require('../../src/bongos/module-store');
191
+ const { rows: [ver] } = await pool.query(
192
+ 'SELECT id, module_key, version, artifact_path FROM store_module_versions WHERE id = $1', [versionId]);
193
+ if (!ver) return { ok: false, code: 'version_not_found', message: `no store_module_versions row ${versionId}` };
194
+
195
+ let signal;
196
+ try {
197
+ const tgz = await fs.promises.readFile(versionArtifactFile(ver, storeDir ? { dir: storeDir } : {}));
198
+ const v = await verifyModuleArtifact(tgz, { key: ver.module_key, includeFiles: true });
199
+ if (v.ok) {
200
+ signal = await assessSecurity(ver.module_key, v.files, v.moduleJson, { audit });
201
+ } else if (v.code === 'denied_content') {
202
+ signal = { outcome: 'failed', score: null, sample_size: null, reason: 'failed: denylist', detail: { failures: [{ check: 'denylist' }] } };
203
+ } else {
204
+ signal = { outcome: 'not_scored', score: null, sample_size: null, reason: `the tarball failed verification (${v.code})`, detail: {} };
205
+ }
206
+ } catch (e) {
207
+ signal = { outcome: 'not_scored', score: null, sample_size: null, reason: `the check could not run (${e.code || 'error'})`, detail: {} };
208
+ }
209
+ const row = await insertSecuritySignal(ver.id, signal, { db: pool });
210
+ return { ok: true, version: ver, signal: row };
211
+ }
212
+
213
+ function readModuleDir(dir) {
214
+ const out = [];
215
+ const walk = (rel) => {
216
+ for (const e of fs.readdirSync(path.join(dir, rel), { withFileTypes: true })) {
217
+ if (e.name === 'node_modules' || e.name === '.git') continue;
218
+ const r = rel ? `${rel}/${e.name}` : e.name;
219
+ if (e.isDirectory()) walk(r);
220
+ else if (e.isFile()) out.push({ path: r, buf: fs.readFileSync(path.join(dir, r)) });
221
+ }
222
+ };
223
+ walk('');
224
+ return out;
225
+ }
226
+
227
+ async function main(argv) {
228
+ const i = argv.indexOf('--dir');
229
+ if (i !== -1) {
230
+ const dir = path.resolve(argv[i + 1] || '');
231
+ const moduleJson = JSON.parse(fs.readFileSync(path.join(dir, 'module.json'), 'utf8'));
232
+ console.log(JSON.stringify(await assessSecurity(moduleJson.key, readModuleDir(dir), moduleJson), null, 2));
233
+ return 0;
234
+ }
235
+ const id = argv[0];
236
+ if (!/^\d+$/.test(String(id || ''))) {
237
+ console.error('usage: node scripts/gds/module-assess-security.js <store_module_versions.id> | --dir modules/<key>');
238
+ return 2;
239
+ }
240
+ const res = await recordSecuritySignal(id);
241
+ if (!res.ok) { console.error(res.message); return 1; }
242
+ const s = res.signal;
243
+ console.log(`${res.version.module_key} ${res.version.version}: security ${s.outcome} — ${s.reason} (signal ${s.id})`);
244
+ return 0;
245
+ }
246
+
247
+ if (require.main === module) {
248
+ main(process.argv.slice(2)).then((code) => process.exit(code), (e) => { console.error(e.stack || e.message); process.exit(1); });
249
+ }
250
+
251
+ module.exports = { assessSecurity, auditDependencies, recordSecuritySignal, insertSecuritySignal, maintenancePosture, registrySpec };
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.20.41'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.42'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -0,0 +1,216 @@
1
+ // tests/module_assess_security.mjs — the Security assessment gate (task 1003792, ADR 0343 D2).
2
+ // No database and no network: the dependency audit is injected (and auditDependencies
3
+ // is driven with a fake npm), the store row goes to a fake pg client, and the tarball
4
+ // is a REAL packed + verified one. Pinned:
5
+ // - the outcome is only ever passed / failed / not_scored — never a number (D2 gate)
6
+ // - denied files, a non-registry dependency source and a high/critical advisory fail
7
+ // - an audit that cannot run is not_scored, never a pass (a gate that cannot run stays shut)
8
+ // - floating ranges and the maintenance posture are reported, never failed on
9
+ //
10
+ // Run: node tests/module_assess_security.mjs
11
+
12
+ import { test } from 'node:test';
13
+ import { strict as assert } from 'node:assert';
14
+ import { createRequire } from 'node:module';
15
+ import fs from 'node:fs';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const { assessSecurity, auditDependencies, recordSecuritySignal, insertSecuritySignal, maintenancePosture, registrySpec } = require('../scripts/gds/module-assess-security.js');
21
+ const { packModule } = require('../scripts/gds/module-artifact.js');
22
+ const { relativeArtifactPath } = require('../src/bongos/module-store.js');
23
+
24
+ const file = (p, text = '') => ({ path: p, buf: Buffer.from(text) });
25
+ const MJ = (over = {}) => ({ key: 'weather', title: 'Weather', description: 'x', version: '1.2.0', coreVersion: '^1.0.0', contributes: {}, ...over });
26
+ const clean = async () => ({ ok: true, findings: [] });
27
+ const noAudit = async () => { throw new Error('the audit must not run for a module with no dependencies'); };
28
+
29
+ test('no dependencies and no denied files passes, with no number and no audit', async () => {
30
+ const s = await assessSecurity('weather', [file('module.json'), file('index.js')], MJ(), { audit: noAudit });
31
+ assert.equal(s.outcome, 'passed');
32
+ assert.equal(s.score, null, 'the gate never carries a score (core_265: security is pass/fail only)');
33
+ assert.equal(s.sample_size, 0);
34
+ });
35
+
36
+ test('a credential-named file fails the gate on the publish denylist', async () => {
37
+ const s = await assessSecurity('weather', [file('module.json'), file('.env', 'X=1')], MJ(), { audit: noAudit });
38
+ assert.equal(s.outcome, 'failed');
39
+ assert.deepEqual(s.detail.failures.map((f) => f.check), ['denylist']);
40
+ assert.deepEqual(s.detail.failures[0].files, ['modules/weather/.env']);
41
+ });
42
+
43
+ test('a dependency fetched from outside the registry fails, and is never handed to npm', async () => {
44
+ const specs = ['git+https://example.invalid/x.git', 'https://example.invalid/x.tgz', 'github:someone/thing', 'someone/thing',
45
+ 'npm:other@1.0.0', 'file:../../../../etc', 'link:../x', 'workspace:*', '1.0.0 || file:../x', 'git@github.com:a/b', 'rm -rf /', 'a#b'];
46
+ for (const spec of specs) {
47
+ const s = await assessSecurity('weather', [file('module.json')],
48
+ MJ({ dependencies: { lodash: '^4.17.21', thing: spec } }), { audit: noAudit });
49
+ assert.equal(s.outcome, 'failed', spec);
50
+ assert.equal(s.detail.failures[0].check, 'dependency_source', spec);
51
+ assert.deepEqual(s.detail.failures[0].dependencies.map((d) => d.package), ['thing'], spec);
52
+ }
53
+ const badName = await assessSecurity('weather', [file('module.json')], MJ({ dependencies: { '../evil': '1.0.0' } }), { audit: noAudit });
54
+ assert.equal(badName.outcome, 'failed');
55
+ });
56
+
57
+ test('registry specs: pinned, ranges and wildcards are allowed and told apart', () => {
58
+ const cases = [['1.2.3', 'pinned'], ['1.2.3-beta.1', 'pinned'], ['^14.26.4', 'floating range'], ['~1.2', 'floating range'],
59
+ ['>=1.0.0 <2.0.0', 'floating range'], ['1.x', 'floating range'], ['1.0.0 - 2.0.0', 'floating range'], ['^1 || ^2', 'floating range'],
60
+ ['*', 'wildcard'], ['latest', 'wildcard'], ['', 'wildcard']];
61
+ for (const [spec, kind] of cases) assert.deepEqual(registrySpec('lodash', spec), { ok: true, kind }, spec);
62
+ assert.equal(registrySpec('@scope/pkg', '^1.0.0').ok, true);
63
+ });
64
+
65
+ test('a wildcard is noted separately from a floating range', async () => {
66
+ const s = await assessSecurity('weather', [file('module.json')], MJ({ dependencies: { a: '*', b: '^1.0.0', c: '1.0.0' } }), { audit: clean });
67
+ assert.equal(s.outcome, 'passed');
68
+ assert.deepEqual(s.detail.notes.filter((n) => n.check.startsWith('dependency')).map((n) => [n.check, n.package]),
69
+ [['dependency_wildcard', 'a'], ['dependency_range', 'b']]);
70
+ });
71
+
72
+ test('a floating range is only noted', async () => {
73
+ const ok = await assessSecurity('weather', [file('module.json')], MJ({ dependencies: { lodash: '^4.17.21' } }), { audit: clean });
74
+ assert.equal(ok.outcome, 'passed');
75
+ assert.equal(ok.sample_size, 1);
76
+ assert.equal(ok.detail.notes.find((n) => n.check === 'dependency_range').package, 'lodash');
77
+ });
78
+
79
+ test('a high or critical advisory in a dependency fails the gate and names it', async () => {
80
+ const audit = async (deps) => {
81
+ assert.deepEqual(deps, { lodash: '^4.0.0' }, 'the audit sees exactly the declared dependencies');
82
+ return { ok: true, findings: [{ package: 'lodash', severity: 'critical', id: 'GHSA-x', summary: 'Prototype pollution', fix: 'lodash@4.17.21' }] };
83
+ };
84
+ const s = await assessSecurity('weather', [file('module.json')], MJ({ dependencies: { lodash: '^4.0.0' } }), { audit });
85
+ assert.equal(s.outcome, 'failed');
86
+ const v = s.detail.failures.find((f) => f.check === 'vulnerable_dependency');
87
+ assert.equal(v.advisories[0].id, 'GHSA-x');
88
+ });
89
+
90
+ test('an audit that cannot run is not_scored — never a pass', async () => {
91
+ const s = await assessSecurity('weather', [file('module.json')], MJ({ dependencies: { lodash: '^4.0.0' } }),
92
+ { audit: async () => ({ ok: false, reason: 'offline' }) });
93
+ assert.equal(s.outcome, 'not_scored');
94
+ assert.match(s.reason, /could not run/);
95
+ });
96
+
97
+ test('the maintenance posture is reported, never failed on', async () => {
98
+ assert.equal(maintenancePosture(MJ()), 'undeclared');
99
+ assert.equal(maintenancePosture(MJ({ maintenance: { status: 'orphaned' } })), 'orphaned');
100
+ for (const status of [undefined, 'deprecated', 'orphaned']) {
101
+ const mj = status ? MJ({ maintenance: { status, note: 'n' } }) : MJ();
102
+ const s = await assessSecurity('weather', [file('module.json')], mj, { audit: noAudit });
103
+ assert.equal(s.outcome, 'passed', String(status));
104
+ assert.equal(s.detail.notes.find((n) => n.check === 'maintenance').status, status || 'undeclared');
105
+ }
106
+ const kept = await assessSecurity('weather', [file('module.json')], MJ({ maintainer: 'a', maintenance: { status: 'maintained' } }), { audit: noAudit });
107
+ assert.equal(kept.detail.notes.length, 0, 'a maintained module has nothing to note');
108
+ });
109
+
110
+ // ---- auditDependencies: npm driven through a fake exec ----
111
+
112
+ function fakeNpm({ lockOk = true, auditOut }) {
113
+ const calls = [];
114
+ const exec = (cmd, argv, opts, cb) => {
115
+ const args = cmd === 'cmd.exe' ? argv.slice(argv.indexOf('npm') + 1) : argv;
116
+ calls.push({ args, pkg: JSON.parse(fs.readFileSync(path.join(opts.cwd, 'package.json'), 'utf8')) });
117
+ if (args[0] === 'install') {
118
+ if (lockOk) fs.writeFileSync(path.join(opts.cwd, 'package-lock.json'), '{}');
119
+ return cb(lockOk ? null : new Error('E404'), '', '');
120
+ }
121
+ return cb(new Error('exit 1'), auditOut, '');
122
+ };
123
+ return { calls, exec };
124
+ }
125
+
126
+ test('the audit resolves metadata only (no install scripts) and reads npm audit\'s JSON', async () => {
127
+ const auditOut = JSON.stringify({ vulnerabilities: { lodash: { severity: 'high', via: [{ source: 1, title: 'Bad', severity: 'high', url: 'u' }], fixAvailable: true }, tiny: { severity: 'low', via: [] } } });
128
+ const npm = fakeNpm({ auditOut });
129
+ const a = await auditDependencies({ lodash: '^4.0.0' }, { exec: npm.exec });
130
+ assert.equal(a.ok, true);
131
+ assert.deepEqual(a.findings.map((f) => f.package), ['lodash'], 'only high and critical count');
132
+ const install = npm.calls[0].args;
133
+ assert.equal(install[0], 'install');
134
+ assert.ok(install.includes('--package-lock-only') && install.includes('--ignore-scripts'), 'no package code is downloaded or run');
135
+ assert.deepEqual(npm.calls[0].pkg.dependencies, { lodash: '^4.0.0' });
136
+ });
137
+
138
+ test('an unresolvable dependency or an unreadable audit is a failed audit run, not a clean one', async () => {
139
+ assert.equal((await auditDependencies({ nope: '^1.0.0' }, { exec: fakeNpm({ lockOk: false }).exec })).ok, false);
140
+ assert.equal((await auditDependencies({ a: '^1.0.0' }, { exec: fakeNpm({ auditOut: 'not json' }).exec })).ok, false);
141
+ assert.equal((await auditDependencies({ a: '^1.0.0' }, { exec: fakeNpm({ auditOut: JSON.stringify({ error: { code: 'ENOAUDIT' } }) }).exec })).ok, false);
142
+ });
143
+
144
+ // ---- recordSecuritySignal: tarball in, one appended row out ----
145
+
146
+ function fakeDb(versionRow) {
147
+ const inserts = [];
148
+ return {
149
+ inserts,
150
+ async query(sql, params) {
151
+ if (/FROM store_module_versions/.test(sql)) return { rows: versionRow ? [versionRow] : [] };
152
+ if (/INSERT INTO module_assessment_signals/.test(sql)) {
153
+ assert.match(sql, /'security'/);
154
+ assert.doesNotMatch(sql, /UPDATE/i);
155
+ inserts.push(params);
156
+ return { rows: [{ id: 3, version_id: params[0], part: 'security', outcome: params[1] }] };
157
+ }
158
+ throw new Error(`unexpected query: ${sql}`);
159
+ },
160
+ };
161
+ }
162
+
163
+ function published(mj = MJ()) {
164
+ const src = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-sec-src-'));
165
+ fs.mkdirSync(path.join(src, 'weather'), { recursive: true });
166
+ fs.writeFileSync(path.join(src, 'weather', 'module.json'), JSON.stringify(mj));
167
+ fs.writeFileSync(path.join(src, 'weather', 'index.js'), 'module.exports = {};\n');
168
+ const { tgz } = packModule('weather', { modulesDir: src, now: () => new Date('2026-09-30T00:00:00Z'), modeOf: () => '644' });
169
+ const storeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mod-sec-store-'));
170
+ const final = path.join(storeDir, 'weather', 'weather-1.2.0.tgz');
171
+ fs.mkdirSync(path.dirname(final), { recursive: true });
172
+ fs.writeFileSync(final, tgz);
173
+ return { storeDir, final, row: { id: 42, module_key: 'weather', version: '1.2.0', artifact_path: relativeArtifactPath(final, { dir: storeDir }) } };
174
+ }
175
+
176
+ test('a published version is checked from its tarball and one security row is appended', async () => {
177
+ const { storeDir, row } = published(MJ({ dependencies: { lodash: '^4.17.21' } }));
178
+ const db = fakeDb(row);
179
+ let audited = null;
180
+ const res = await recordSecuritySignal(42, { db, storeDir, audit: async (d) => { audited = d; return { ok: true, findings: [] }; } });
181
+ assert.equal(res.ok, true);
182
+ assert.deepEqual(audited, { lodash: '^4.17.21' }, 'the dependencies come from the published module.json');
183
+ const [versionId, outcome, score, sample] = db.inserts[0];
184
+ assert.deepEqual([versionId, outcome, score, sample], [42, 'passed', null, 1]);
185
+ });
186
+
187
+ test('a missing or tampered tarball is not_scored, and an unknown version writes nothing', async () => {
188
+ const { storeDir, final, row } = published();
189
+ fs.writeFileSync(final, 'junk');
190
+ const db = fakeDb(row);
191
+ await recordSecuritySignal(42, { db, storeDir, audit: noAudit });
192
+ assert.equal(db.inserts[0][1], 'not_scored');
193
+ assert.doesNotMatch(db.inserts[0][4], /[\\/]/, 'no local path in the stored reason');
194
+
195
+ const none = fakeDb(null);
196
+ const res = await recordSecuritySignal(9, { db: none, audit: noAudit });
197
+ assert.equal(res.code, 'version_not_found');
198
+ assert.equal(none.inserts.length, 0);
199
+ });
200
+
201
+ test('insertSecuritySignal writes the security part with no score', async () => {
202
+ const seen = [];
203
+ await insertSecuritySignal(5, { outcome: 'failed', score: null, sample_size: 2, reason: 'r', detail: { failures: [] } },
204
+ { db: { async query(sql, params) { seen.push(params); return { rows: [{}] }; } } });
205
+ assert.deepEqual(seen[0], [5, 'failed', null, 2, 'r', '{"failures":[]}']);
206
+ });
207
+
208
+ test('a core script never requires a module file (the one-way rule)', () => {
209
+ const src = fs.readFileSync(new URL('../scripts/gds/module-assess-security.js', import.meta.url), 'utf8').replace(/\/\/.*$/gm, '');
210
+ assert.doesNotMatch(src, /require\([^)]*modules\//);
211
+ });
212
+
213
+ test('the gate never runs the module\'s own code', () => {
214
+ const src = fs.readFileSync(new URL('../scripts/gds/module-assess-security.js', import.meta.url), 'utf8').replace(/\/\/.*$/gm, '');
215
+ assert.doesNotMatch(src, /process\.execPath|require\(\s*path\.join|import\(/, 'no node process or require over module files');
216
+ });