@massu/core 1.4.0-soak.0 → 1.5.0
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/commands/README.md +0 -3
- package/dist/cli.js +9423 -5453
- package/dist/hooks/auto-learning-pipeline.js +27 -1
- package/dist/hooks/classify-failure.js +27 -1
- package/dist/hooks/cost-tracker.js +27 -1
- package/dist/hooks/fix-detector.js +27 -1
- package/dist/hooks/incident-pipeline.js +27 -1
- package/dist/hooks/post-edit-context.js +27 -1
- package/dist/hooks/post-tool-use.js +27 -1
- package/dist/hooks/pre-compact.js +27 -1
- package/dist/hooks/pre-delete-check.js +27 -1
- package/dist/hooks/quality-event.js +27 -1
- package/dist/hooks/rule-enforcement-pipeline.js +27 -1
- package/dist/hooks/session-end.js +27 -1
- package/dist/hooks/session-start.js +2677 -2675
- package/dist/hooks/user-prompt.js +27 -1
- package/docs/AUTHORING-ADAPTERS.md +207 -0
- package/docs/SECURITY.md +250 -0
- package/package.json +10 -3
- package/src/adapter.ts +90 -0
- package/src/cli.ts +7 -0
- package/src/commands/adapters.ts +824 -0
- package/src/commands/config-check-drift.ts +1 -0
- package/src/commands/config-refresh.ts +4 -3
- package/src/commands/config-upgrade.ts +1 -0
- package/src/commands/doctor.ts +2 -0
- package/src/commands/init.ts +3 -1
- package/src/commands/template-engine.ts +0 -2
- package/src/commands/watch.ts +1 -1
- package/src/config.ts +71 -0
- package/src/detect/adapters/aspnet.ts +293 -0
- package/src/detect/adapters/discover.ts +469 -0
- package/src/detect/adapters/go-chi.ts +261 -0
- package/src/detect/adapters/index.ts +49 -0
- package/src/detect/adapters/phoenix.ts +277 -0
- package/src/detect/adapters/python-flask.ts +235 -0
- package/src/detect/adapters/rails.ts +279 -0
- package/src/detect/adapters/runner.ts +32 -0
- package/src/detect/adapters/spring.ts +284 -0
- package/src/detect/adapters/tree-sitter-loader.ts +171 -2
- package/src/detect/adapters/types.ts +19 -2
- package/src/detect/migrate.ts +4 -4
- package/src/detect/monorepo-detector.ts +1 -0
- package/src/hooks/post-tool-use.ts +1 -0
- package/src/hooks/session-start.ts +1 -0
- package/src/lib/fileLock.ts +203 -0
- package/src/lib/installLock.ts +31 -144
- package/src/lsp/auto-detect.ts +10 -1
- package/src/lsp/client.ts +188 -2
- package/src/memory-file-ingest.ts +1 -0
- package/src/security/adapter-origin.ts +130 -0
- package/src/security/adapter-verifier.ts +319 -0
- package/src/security/atomic-write.ts +164 -0
- package/src/security/fetcher.ts +200 -0
- package/src/security/install-tracking.ts +319 -0
- package/src/security/local-fingerprint.ts +225 -0
- package/src/security/manifest-cache.ts +333 -0
- package/src/security/manifest-schema.ts +129 -0
- package/src/security/registry-pubkey.generated.ts +35 -0
- package/src/security/telemetry.ts +320 -0
- package/src/watch/daemon.ts +1 -1
- package/src/watch/paths.ts +2 -2
- package/templates/aspnet/massu.config.yaml +57 -0
- package/templates/go-chi/massu.config.yaml +52 -0
- package/templates/phoenix/massu.config.yaml +54 -0
- package/templates/python-flask/massu.config.yaml +51 -0
- package/templates/rails/massu.config.yaml +56 -0
- package/templates/spring/massu.config.yaml +56 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Three-class adapter trust model (Plan 3c gap-47 + gap-48 + gap-50 deliverable).
|
|
3
|
+
*
|
|
4
|
+
* Every adapter that the loader is asked to load MUST classify into exactly
|
|
5
|
+
* ONE of the three classes below. The verifier dispatches per origin; an
|
|
6
|
+
* adapter that classifies into ZERO classes (or matches multiple) is REFUSED
|
|
7
|
+
* to load with a clear stderr message.
|
|
8
|
+
*
|
|
9
|
+
* The three classes:
|
|
10
|
+
*
|
|
11
|
+
* 1. CORE-BUNDLED — adapters that ship inside @massu/core itself at
|
|
12
|
+
* packages/core/src/detect/adapters/*.ts. Trust derives from @massu/core's
|
|
13
|
+
* own npm publish (provenance attestations) + the prepublish-check.sh
|
|
14
|
+
* audit + the user's explicit `npm install @massu/core` choice. These
|
|
15
|
+
* adapters do NOT appear in manifest.json (they have no package, no
|
|
16
|
+
* separate version, no separate tarball). Loader skips signature
|
|
17
|
+
* verification AND emits no warning — this is the trusted baseline.
|
|
18
|
+
* Plan 3b shipped 4 (FastAPI, Django, tRPC, SwiftUI); Phase 7 ships 31
|
|
19
|
+
* more (35 total).
|
|
20
|
+
*
|
|
21
|
+
* 2. REGISTRY-VERIFIED — third-party npm packages that EITHER match
|
|
22
|
+
* `@massu/adapter-*` glob OR declare `"massu-adapter": true` in their
|
|
23
|
+
* package.json. Trust derives from the manifest.json allowlist + per-
|
|
24
|
+
* package sha256 + Ed25519 manifest signature. The 5 first-shipped
|
|
25
|
+
* `@massu`-org packages (rails, phoenix, aspnet, spring, go-chi) live
|
|
26
|
+
* in this class — being `@massu`-org-published does NOT exempt them
|
|
27
|
+
* from manifest signing (gap-48 named-resolution).
|
|
28
|
+
*
|
|
29
|
+
* 3. LOCAL-EXPLICIT — TypeScript / JavaScript files listed by the user in
|
|
30
|
+
* `massu.config.yaml > adapters.local`. Trust derives from the user's
|
|
31
|
+
* explicit per-path entry + the postinstall-poisoning fingerprint check
|
|
32
|
+
* (gap-32 / gap-58). Loader emits a one-time stderr note per startup
|
|
33
|
+
* naming the loaded local path.
|
|
34
|
+
*
|
|
35
|
+
* Concretely, getAdapterOrigin() returns the union type below. Anything
|
|
36
|
+
* that doesn't match exactly one class is the unclassified branch — the
|
|
37
|
+
* verifier MUST refuse to load.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
export type AdapterOrigin = 'core-bundled' | 'registry-verified' | 'local-explicit';
|
|
41
|
+
|
|
42
|
+
export interface AdapterDescriptor {
|
|
43
|
+
/**
|
|
44
|
+
* Stable identifier the loader uses to address this adapter. For
|
|
45
|
+
* CORE-BUNDLED: the filename without `.ts` (e.g. `python-fastapi`).
|
|
46
|
+
* For REGISTRY-VERIFIED: the npm package name (e.g. `@massu/adapter-rails`).
|
|
47
|
+
* For LOCAL-EXPLICIT: the POSIX-normalized path from `adapters.local`.
|
|
48
|
+
*/
|
|
49
|
+
id: string;
|
|
50
|
+
origin: AdapterOrigin;
|
|
51
|
+
/**
|
|
52
|
+
* For REGISTRY-VERIFIED only: package version (semver). Undefined for
|
|
53
|
+
* CORE-BUNDLED (which inherits @massu/core's version) and LOCAL-EXPLICIT
|
|
54
|
+
* (which has no semver — local files don't publish).
|
|
55
|
+
*/
|
|
56
|
+
version?: string;
|
|
57
|
+
/**
|
|
58
|
+
* For REGISTRY-VERIFIED only: absolute path to the package directory in
|
|
59
|
+
* node_modules (or wherever the discovery scan found it). Used to compute
|
|
60
|
+
* the load-time sha256 (gap-37 install-time + load-time verification).
|
|
61
|
+
* Undefined for CORE-BUNDLED (resolved inside the @massu/core bundle) and
|
|
62
|
+
* LOCAL-EXPLICIT (use the path from adapters.local directly).
|
|
63
|
+
*/
|
|
64
|
+
packageDir?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface AdapterOriginInput {
|
|
68
|
+
/** The id under inspection. */
|
|
69
|
+
id: string;
|
|
70
|
+
/**
|
|
71
|
+
* Set of adapter ids that ship CORE-BUNDLED in @massu/core itself. The
|
|
72
|
+
* loader produces this set via `import.meta.glob`-style scan of
|
|
73
|
+
* packages/core/src/detect/adapters/*.ts. Pass ['python-fastapi',
|
|
74
|
+
* 'python-django', 'nextjs-trpc', 'swift-swiftui'] today; Phase 7 grows
|
|
75
|
+
* this to 35 entries.
|
|
76
|
+
*/
|
|
77
|
+
coreBundledIds: ReadonlySet<string>;
|
|
78
|
+
/**
|
|
79
|
+
* Optional: the npm package metadata (when id matches a discovered
|
|
80
|
+
* node_modules package). When present + `id` matches an `@massu/adapter-*`
|
|
81
|
+
* pattern OR `massuAdapter === true`, this is REGISTRY-VERIFIED.
|
|
82
|
+
*/
|
|
83
|
+
npmPackage?: {
|
|
84
|
+
name: string;
|
|
85
|
+
version: string;
|
|
86
|
+
massuAdapter: boolean;
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* Optional: set of POSIX-normalized adapter paths from
|
|
90
|
+
* `getConfig().adapters?.local ?? []`. When present + id matches one of
|
|
91
|
+
* these entries, this is LOCAL-EXPLICIT.
|
|
92
|
+
*/
|
|
93
|
+
configLocalPaths?: ReadonlySet<string>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Classify an adapter id into exactly one of the three trust classes, or
|
|
98
|
+
* return null if the id does not match any class. Loader MUST refuse to
|
|
99
|
+
* load null-classified adapters.
|
|
100
|
+
*
|
|
101
|
+
* Multi-class collision (id matches more than one class) is an error
|
|
102
|
+
* condition — caller should treat it as null + emit a clear stderr note
|
|
103
|
+
* naming all the matching classes. This shouldn't happen in practice
|
|
104
|
+
* because CORE-BUNDLED ids are kebab-case framework names while REGISTRY-
|
|
105
|
+
* VERIFIED ids start with `@` (npm scope) and LOCAL-EXPLICIT ids are file
|
|
106
|
+
* paths — they don't intersect under normal config.
|
|
107
|
+
*/
|
|
108
|
+
export function getAdapterOrigin(input: AdapterOriginInput): AdapterOrigin | null {
|
|
109
|
+
const matches: AdapterOrigin[] = [];
|
|
110
|
+
|
|
111
|
+
if (input.coreBundledIds.has(input.id)) {
|
|
112
|
+
matches.push('core-bundled');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (input.npmPackage) {
|
|
116
|
+
const isMassuOrgAdapter = /^@massu\/adapter-/.test(input.npmPackage.name);
|
|
117
|
+
const declaresMassuAdapter = input.npmPackage.massuAdapter === true;
|
|
118
|
+
if ((isMassuOrgAdapter || declaresMassuAdapter) && input.npmPackage.name === input.id) {
|
|
119
|
+
matches.push('registry-verified');
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (input.configLocalPaths && input.configLocalPaths.has(input.id)) {
|
|
124
|
+
matches.push('local-explicit');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Exactly one match → that class. Zero or multiple → unclassified (refuse).
|
|
128
|
+
if (matches.length === 1) return matches[0]!;
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adapter manifest signature + integrity verifier.
|
|
3
|
+
*
|
|
4
|
+
* Plan 3c Phase 5 deliverable (5F). The center of the supply-chain trust
|
|
5
|
+
* chain: every REGISTRY-VERIFIED adapter load (per the three-class trust
|
|
6
|
+
* model in adapter-origin.ts) goes through this module before the loader
|
|
7
|
+
* accepts it. Verification fails CLOSED — any failure path returns
|
|
8
|
+
* { ok: false, reason } and the loader refuses to load.
|
|
9
|
+
*
|
|
10
|
+
* Verification chain (all checks MUST pass):
|
|
11
|
+
*
|
|
12
|
+
* 1. Envelope shape passes EnvelopeSchema (manifest, manifest_b64,
|
|
13
|
+
* signature, manifest_sha256, signed_at, signing_key_id all present
|
|
14
|
+
* and well-typed).
|
|
15
|
+
* 2. base64-decode(envelope.manifest_b64) yields raw bytes B.
|
|
16
|
+
* 3. sha256(B) hex == envelope.manifest_sha256.
|
|
17
|
+
* 4. JSON.parse(B as utf-8) succeeds.
|
|
18
|
+
* 5. The parsed JSON deep-equals envelope.manifest (defense against
|
|
19
|
+
* publisher swapping the manifest_b64 and manifest fields independently).
|
|
20
|
+
* 6. ManifestBodySchema accepts the parsed JSON.
|
|
21
|
+
* 7. nacl.sign.detached.verify(B, base64-decode(signature), publicKey)
|
|
22
|
+
* returns true. publicKey is the bundled REGISTRY_PUBKEY_ED25519
|
|
23
|
+
* (caller passes it; default is the bundle from registry-pubkey.generated.ts).
|
|
24
|
+
* 8. envelope.signing_key_id == sha256(publicKey hex). This is the
|
|
25
|
+
* drift-detection mechanism for key rotation (Plan 3c gap-54): a
|
|
26
|
+
* stale @massu/core that holds an old pubkey reading a manifest
|
|
27
|
+
* signed by the new pubkey will see signing_key_id mismatch and
|
|
28
|
+
* refuse — the cache is then marked STALE-DUE-TO-ROTATION (handled
|
|
29
|
+
* by the cache module, not here).
|
|
30
|
+
* 9. manifest.manifest_schema_version >= MIN_KNOWN_SCHEMA_VERSION (else
|
|
31
|
+
* REFUSE — a future v2 that drops fields v1 expects is incompatible).
|
|
32
|
+
* > KNOWN_MAX_SCHEMA_VERSION → continue with warning (additive
|
|
33
|
+
* forward-compat per gap-56).
|
|
34
|
+
*
|
|
35
|
+
* Why every step matters:
|
|
36
|
+
* - Step 3 catches manifest_b64 tampering (someone swapped the bytes
|
|
37
|
+
* between the publisher's signing and the consumer's verify).
|
|
38
|
+
* - Step 5 catches publisher bugs that emit two non-equal views of
|
|
39
|
+
* the same field (this is the canonicalization-gap class — the
|
|
40
|
+
* publisher's self-check at registry-publish.sh enforces the same
|
|
41
|
+
* invariant before deploy, but we re-check on the consumer side).
|
|
42
|
+
* - Step 7 is the actual signature verification.
|
|
43
|
+
* - Step 8 protects against attackers who know an old key — they can
|
|
44
|
+
* sign a valid manifest under the old key, but signing_key_id will
|
|
45
|
+
* mismatch the bundled pubkey and the loader refuses.
|
|
46
|
+
*
|
|
47
|
+
* Test fixtures sign against the real Phase D Ed25519 private key via
|
|
48
|
+
* `bash scripts/sign-fixture-manifest.sh` (operator-runs, reads from
|
|
49
|
+
* macOS Keychain) producing signed envelopes that round-trip the verifier
|
|
50
|
+
* end-to-end without needing the live registry.
|
|
51
|
+
*/
|
|
52
|
+
import { createHash } from 'node:crypto';
|
|
53
|
+
import nacl from 'tweetnacl';
|
|
54
|
+
import { z } from 'zod';
|
|
55
|
+
import {
|
|
56
|
+
EnvelopeSchema,
|
|
57
|
+
ManifestBodySchema,
|
|
58
|
+
KNOWN_MAX_SCHEMA_VERSION,
|
|
59
|
+
MIN_KNOWN_SCHEMA_VERSION,
|
|
60
|
+
type Envelope,
|
|
61
|
+
type ManifestBody,
|
|
62
|
+
} from './manifest-schema.js';
|
|
63
|
+
import {
|
|
64
|
+
REGISTRY_PUBKEY_ED25519,
|
|
65
|
+
REGISTRY_PUBKEY_FINGERPRINT_HEX,
|
|
66
|
+
KNOWN_PUBKEY_FINGERPRINTS,
|
|
67
|
+
} from './registry-pubkey.generated.js';
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* CR-9 audit L2 fix: assert at module init that the bundled pubkey's
|
|
71
|
+
* fingerprint is in the historically-trusted allowlist. A bundled key
|
|
72
|
+
* that doesn't appear here would refuse to load — defense against a
|
|
73
|
+
* future build that swaps the pem to an unauthorized key without
|
|
74
|
+
* updating the allowlist (which would otherwise pass --check at build
|
|
75
|
+
* time only if KNOWN_PUBKEY_FINGERPRINTS was also tampered).
|
|
76
|
+
*/
|
|
77
|
+
if (!KNOWN_PUBKEY_FINGERPRINTS.has(REGISTRY_PUBKEY_FINGERPRINT_HEX)) {
|
|
78
|
+
throw new Error(
|
|
79
|
+
`@massu/core: bundled pubkey fingerprint ${REGISTRY_PUBKEY_FINGERPRINT_HEX.slice(0, 16)}... ` +
|
|
80
|
+
`is not in KNOWN_PUBKEY_FINGERPRINTS. This @massu/core build appears tampered. ` +
|
|
81
|
+
`Refusing to load.`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface VerifyManifestInput {
|
|
86
|
+
/** Raw envelope JSON, parsed but not yet validated. */
|
|
87
|
+
envelope: unknown;
|
|
88
|
+
/**
|
|
89
|
+
* The bundled Ed25519 public key (32 raw bytes). Caller passes the
|
|
90
|
+
* bundled key from registry-pubkey.generated.ts. Test-only override
|
|
91
|
+
* to swap keys for fixture signing.
|
|
92
|
+
*/
|
|
93
|
+
publicKey?: Uint8Array;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type VerifyManifestResult =
|
|
97
|
+
| { ok: true; envelope: Envelope; manifest: ManifestBody; warnings: string[] }
|
|
98
|
+
| { ok: false; reason: string };
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Compute sha256 hex of raw bytes. Helper used at multiple verification
|
|
102
|
+
* steps; centralized to avoid drift between callsites.
|
|
103
|
+
*/
|
|
104
|
+
function sha256Hex(bytes: Uint8Array): string {
|
|
105
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* JSON.parse reviver that strips prototype-pollution keys. CR-9 audit H2
|
|
110
|
+
* fix: a malicious-but-signed manifest could include `"__proto__":{...}`
|
|
111
|
+
* as an own enumerable key that the schema's `.passthrough()` would
|
|
112
|
+
* preserve into downstream consumers. Returning undefined from a reviver
|
|
113
|
+
* tells JSON.parse to omit that key entirely.
|
|
114
|
+
*/
|
|
115
|
+
function reviver(key: string, value: unknown): unknown {
|
|
116
|
+
if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
return value;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Deep-equal check for the parsed JSON tree of envelope.manifest vs the
|
|
124
|
+
* JSON parsed from base64-decode(envelope.manifest_b64). Both inputs come
|
|
125
|
+
* from the same envelope so they MUST be deep-equal — any mismatch is a
|
|
126
|
+
* publisher bug or active tampering. Uses canonical JSON.stringify with
|
|
127
|
+
* sorted keys so the comparison ignores key-order differences.
|
|
128
|
+
*/
|
|
129
|
+
function jsonDeepEqualByCanonical(a: unknown, b: unknown): boolean {
|
|
130
|
+
const ca = canonicalJsonStringify(a);
|
|
131
|
+
const cb = canonicalJsonStringify(b);
|
|
132
|
+
return ca === cb;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function canonicalJsonStringify(value: unknown): string {
|
|
136
|
+
if (value === null) return 'null';
|
|
137
|
+
if (Array.isArray(value)) {
|
|
138
|
+
return '[' + value.map(canonicalJsonStringify).join(',') + ']';
|
|
139
|
+
}
|
|
140
|
+
if (typeof value === 'object' && value !== undefined) {
|
|
141
|
+
const obj = value as Record<string, unknown>;
|
|
142
|
+
const keys = Object.keys(obj).sort();
|
|
143
|
+
return '{' + keys.map((k) => JSON.stringify(k) + ':' + canonicalJsonStringify(obj[k])).join(',') + '}';
|
|
144
|
+
}
|
|
145
|
+
return JSON.stringify(value);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Verify a registry adapter manifest envelope. See module-level doc for the
|
|
150
|
+
* full chain. Returns ok=true with parsed envelope+manifest+warnings on
|
|
151
|
+
* success, or ok=false with a specific reason on any failure.
|
|
152
|
+
*
|
|
153
|
+
* This function does NOT cache, NOT fetch, and NOT mutate any state. Pure
|
|
154
|
+
* verification of a given input. The caller (cache module + CLI) is
|
|
155
|
+
* responsible for cache I/O and refusing-to-load semantics.
|
|
156
|
+
*/
|
|
157
|
+
export function verifyManifest(input: VerifyManifestInput): VerifyManifestResult {
|
|
158
|
+
const publicKey = input.publicKey ?? REGISTRY_PUBKEY_ED25519;
|
|
159
|
+
const warnings: string[] = [];
|
|
160
|
+
|
|
161
|
+
// Step 1 — envelope shape
|
|
162
|
+
const envelopeParsed = EnvelopeSchema.safeParse(input.envelope);
|
|
163
|
+
if (!envelopeParsed.success) {
|
|
164
|
+
const issues = envelopeParsed.error.issues
|
|
165
|
+
.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)
|
|
166
|
+
.join('; ');
|
|
167
|
+
return { ok: false, reason: `envelope shape invalid: ${issues}` };
|
|
168
|
+
}
|
|
169
|
+
const envelope = envelopeParsed.data;
|
|
170
|
+
|
|
171
|
+
// Step 2 — base64-decode manifest_b64
|
|
172
|
+
let manifestBytes: Buffer;
|
|
173
|
+
try {
|
|
174
|
+
manifestBytes = Buffer.from(envelope.manifest_b64, 'base64');
|
|
175
|
+
} catch (err) {
|
|
176
|
+
return { ok: false, reason: `manifest_b64 base64 decode failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
177
|
+
}
|
|
178
|
+
if (manifestBytes.length === 0) {
|
|
179
|
+
return { ok: false, reason: 'manifest_b64 decoded to zero bytes' };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Step 3 — sha256 round-trip
|
|
183
|
+
const computedSha = sha256Hex(manifestBytes);
|
|
184
|
+
if (computedSha !== envelope.manifest_sha256) {
|
|
185
|
+
return {
|
|
186
|
+
ok: false,
|
|
187
|
+
reason:
|
|
188
|
+
`manifest_sha256 mismatch: computed ${computedSha}, envelope claims ${envelope.manifest_sha256}. ` +
|
|
189
|
+
`manifest_b64 was tampered with after signing.`,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Step 4 — JSON.parse manifest_b64 bytes.
|
|
194
|
+
// CR-9 audit H2 fix: the JSON.parse reviver strips __proto__/constructor/
|
|
195
|
+
// prototype keys at parse time so they cannot be smuggled through the
|
|
196
|
+
// verifier into downstream consumers. Without this, a malicious-but-
|
|
197
|
+
// signed manifest could plant prototype-pollution-shaped values that
|
|
198
|
+
// .passthrough() preserves verbatim.
|
|
199
|
+
let manifestFromBytes: unknown;
|
|
200
|
+
try {
|
|
201
|
+
manifestFromBytes = JSON.parse(manifestBytes.toString('utf-8'), reviver);
|
|
202
|
+
} catch (err) {
|
|
203
|
+
return {
|
|
204
|
+
ok: false,
|
|
205
|
+
reason: `manifest_b64 does not decode to valid JSON: ${err instanceof Error ? err.message : String(err)}`,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Step 6 — manifest body schema (run BEFORE step-5 deep-equal so both
|
|
210
|
+
// sides of the deep-equal comparison go through the same Zod defaults +
|
|
211
|
+
// transforms; otherwise an additive optional field with `.default()` on
|
|
212
|
+
// one side but not the other would cause spurious deep-equal failures).
|
|
213
|
+
// CR-9 audit M2 fix.
|
|
214
|
+
const manifestParsed = ManifestBodySchema.safeParse(manifestFromBytes);
|
|
215
|
+
if (!manifestParsed.success) {
|
|
216
|
+
const issues = manifestParsed.error.issues
|
|
217
|
+
.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)
|
|
218
|
+
.join('; ');
|
|
219
|
+
return { ok: false, reason: `manifest body shape invalid: ${issues}` };
|
|
220
|
+
}
|
|
221
|
+
const manifest = manifestParsed.data;
|
|
222
|
+
|
|
223
|
+
// Step 5 — deep-equal vs envelope.manifest. Compare AFTER both sides
|
|
224
|
+
// have been parsed by ManifestBodySchema so defaults are applied
|
|
225
|
+
// identically.
|
|
226
|
+
if (!jsonDeepEqualByCanonical(manifest, envelope.manifest)) {
|
|
227
|
+
return {
|
|
228
|
+
ok: false,
|
|
229
|
+
reason:
|
|
230
|
+
`manifest_b64 (decoded) does not deep-equal envelope.manifest field. ` +
|
|
231
|
+
`Publisher emitted inconsistent envelope — refusing to trust either view.`,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Step 7 — Ed25519 signature verify
|
|
236
|
+
let signatureBytes: Buffer;
|
|
237
|
+
try {
|
|
238
|
+
signatureBytes = Buffer.from(envelope.signature, 'base64');
|
|
239
|
+
} catch (err) {
|
|
240
|
+
return { ok: false, reason: `signature base64 decode failed: ${err instanceof Error ? err.message : String(err)}` };
|
|
241
|
+
}
|
|
242
|
+
if (signatureBytes.length !== nacl.sign.signatureLength) {
|
|
243
|
+
return {
|
|
244
|
+
ok: false,
|
|
245
|
+
reason: `signature byte length ${signatureBytes.length} != expected ${nacl.sign.signatureLength}`,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
if (publicKey.length !== nacl.sign.publicKeyLength) {
|
|
249
|
+
return {
|
|
250
|
+
ok: false,
|
|
251
|
+
reason: `publicKey byte length ${publicKey.length} != expected ${nacl.sign.publicKeyLength}`,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
const sigOk = nacl.sign.detached.verify(
|
|
255
|
+
new Uint8Array(manifestBytes),
|
|
256
|
+
new Uint8Array(signatureBytes),
|
|
257
|
+
publicKey,
|
|
258
|
+
);
|
|
259
|
+
if (!sigOk) {
|
|
260
|
+
return {
|
|
261
|
+
ok: false,
|
|
262
|
+
reason: `Ed25519 signature verification failed against bundled public key ` +
|
|
263
|
+
`(fingerprint ${sha256Hex(publicKey).slice(0, 16)}...). ` +
|
|
264
|
+
`Manifest was either signed by a different key OR the signature is corrupt.`,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Step 8 — signing_key_id matches sha256(publicKey)
|
|
269
|
+
const expectedKeyId = sha256Hex(publicKey);
|
|
270
|
+
if (envelope.signing_key_id !== expectedKeyId) {
|
|
271
|
+
return {
|
|
272
|
+
ok: false,
|
|
273
|
+
reason:
|
|
274
|
+
`signing_key_id mismatch: envelope claims ${envelope.signing_key_id}, ` +
|
|
275
|
+
`bundled pubkey sha256 is ${expectedKeyId}. ` +
|
|
276
|
+
`This indicates a key rotation; cache must refresh from the live registry, ` +
|
|
277
|
+
`or @massu/core must be upgraded to a version with the new bundled pubkey.`,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Step 8b — every manifest entry's signing_key_id MUST equal the envelope's
|
|
282
|
+
// signing_key_id. CR-9 audit M3 fix: per-entry signing_key_id was parsed
|
|
283
|
+
// but never validated. v1 always uses the single registry key per
|
|
284
|
+
// SECURITY.md; a future federated v2 may countersign per-entry, in which
|
|
285
|
+
// case this check moves into a per-entry-key verification loop. Today,
|
|
286
|
+
// any divergence between entry.signing_key_id and envelope.signing_key_id
|
|
287
|
+
// indicates either a publisher bug or a mixed-signing-key attack.
|
|
288
|
+
for (const entry of manifest.adapters) {
|
|
289
|
+
if (entry.signing_key_id !== envelope.signing_key_id) {
|
|
290
|
+
return {
|
|
291
|
+
ok: false,
|
|
292
|
+
reason:
|
|
293
|
+
`manifest entry ${entry.package} has signing_key_id ${entry.signing_key_id} ` +
|
|
294
|
+
`which does not match envelope signing_key_id ${envelope.signing_key_id}. ` +
|
|
295
|
+
`v1 manifests use a single registry key for every entry; this divergence ` +
|
|
296
|
+
`indicates either a publisher bug or mixed-key attack — refusing.`,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Step 9 — schema version compat
|
|
302
|
+
if (manifest.manifest_schema_version < MIN_KNOWN_SCHEMA_VERSION) {
|
|
303
|
+
return {
|
|
304
|
+
ok: false,
|
|
305
|
+
reason:
|
|
306
|
+
`manifest_schema_version ${manifest.manifest_schema_version} < MIN ${MIN_KNOWN_SCHEMA_VERSION}. ` +
|
|
307
|
+
`This @massu/core does not support reading this manifest; upgrade required.`,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
if (manifest.manifest_schema_version > KNOWN_MAX_SCHEMA_VERSION) {
|
|
311
|
+
warnings.push(
|
|
312
|
+
`Adapter registry uses schema v${manifest.manifest_schema_version}, this @massu/core ` +
|
|
313
|
+
`supports up to v${KNOWN_MAX_SCHEMA_VERSION}. Some adapter metadata may be ignored. ` +
|
|
314
|
+
`Upgrade @massu/core to access new features.`,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return { ok: true, envelope, manifest, warnings };
|
|
319
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic write-then-rename primitive for security-relevant state files.
|
|
3
|
+
*
|
|
4
|
+
* Plan 3c gap-37 + gap-41 deliverable: extracted from init.ts:writeConfigAtomic
|
|
5
|
+
* (which used to inline this pattern with a hardcoded 0o644 mode and a
|
|
6
|
+
* config-specific YAML/Zod validation step) so that BOTH init.ts and the new
|
|
7
|
+
* Phase 5 security modules import the same helper. CR-46 / Rule 0 compliance:
|
|
8
|
+
* no parallel third atomic-write helper exists in the codebase.
|
|
9
|
+
*
|
|
10
|
+
* Guarantees:
|
|
11
|
+
* 1. Parent directory is created (recursively) with `ensureParentDirMode` if
|
|
12
|
+
* passed (typically 0o700 for ~/.massu/ per gap-37). Existing parent dirs
|
|
13
|
+
* are NOT chmod'd — that would clobber an operator's deliberate widening.
|
|
14
|
+
* 2. Content is written to `${path}.tmp` via openSync + writeSync + fsyncSync
|
|
15
|
+
* + closeSync. The fsync is REQUIRED (xfs / ext4 `data=writeback` will
|
|
16
|
+
* rename-before-data on crash without it; init.ts iter-7 fix codified this).
|
|
17
|
+
* 3. After tmp is durably on disk, renameSync moves it to the final path
|
|
18
|
+
* atomically (POSIX guarantees readers see EITHER old OR new, never torn).
|
|
19
|
+
* 4. If `mode` is provided, chmodSync is applied AFTER rename so the final
|
|
20
|
+
* mode is exact (openSync's third arg is masked by the process umask;
|
|
21
|
+
* explicit chmod is the only way to guarantee 0o600 on systems with
|
|
22
|
+
* umask 0o022 or stricter).
|
|
23
|
+
* 5. ANY error during the write/rename sequence triggers tmp cleanup before
|
|
24
|
+
* the error propagates. Original file at `path` is untouched on error.
|
|
25
|
+
*
|
|
26
|
+
* Concurrency:
|
|
27
|
+
* - Reader side: NO lock acquired. POSIX renameSync is atomic; readers see
|
|
28
|
+
* EITHER old OR new content, never torn. This is sufficient for the cache
|
|
29
|
+
* read paths (manifest cache, fingerprint cache).
|
|
30
|
+
* - Writer side: this helper does NOT serialize concurrent writers. If two
|
|
31
|
+
* processes call atomicWrite on the same path concurrently, both will
|
|
32
|
+
* succeed but the second will silently overwrite the first. For Phase 5's
|
|
33
|
+
* manifest cache (where racing `adapters refresh` invocations from the 3a
|
|
34
|
+
* watcher + manual install + coverage CLI are plausible per gap-59), the
|
|
35
|
+
* caller must acquire the advisory lock at `~/.massu/.adapter-manifest.lock`
|
|
36
|
+
* FIRST via `withFileLock()` (sibling helper). This module's job is just
|
|
37
|
+
* the write atomicity, not write serialization.
|
|
38
|
+
*
|
|
39
|
+
* Use this helper for any file that:
|
|
40
|
+
* - Gates security decisions (cache invalidation, signature verification,
|
|
41
|
+
* path fingerprinting), OR
|
|
42
|
+
* - Must survive crash / SIGKILL / power loss without ending up zero-byte, OR
|
|
43
|
+
* - Has concurrent readers that must never see partial content.
|
|
44
|
+
*/
|
|
45
|
+
import {
|
|
46
|
+
chmodSync,
|
|
47
|
+
closeSync,
|
|
48
|
+
existsSync,
|
|
49
|
+
fsyncSync,
|
|
50
|
+
mkdirSync,
|
|
51
|
+
openSync,
|
|
52
|
+
renameSync,
|
|
53
|
+
rmSync,
|
|
54
|
+
statSync,
|
|
55
|
+
writeSync,
|
|
56
|
+
} from 'node:fs';
|
|
57
|
+
import { dirname } from 'node:path';
|
|
58
|
+
|
|
59
|
+
export interface AtomicWriteOptions {
|
|
60
|
+
/**
|
|
61
|
+
* File mode applied to the final path via chmodSync after rename.
|
|
62
|
+
* Defaults to undefined (existing-mode preservation, or system default for
|
|
63
|
+
* new files). Pass 0o600 for security-relevant cache files (manifest,
|
|
64
|
+
* fingerprint, etc.) per Plan 3c gap-37.
|
|
65
|
+
*/
|
|
66
|
+
mode?: number;
|
|
67
|
+
/**
|
|
68
|
+
* If passed AND the parent directory does NOT exist, mkdirSync creates it
|
|
69
|
+
* with this mode. Pass 0o700 for ~/.massu/ per Plan 3c gap-37 deliverable.
|
|
70
|
+
* Existing parent directories are NOT chmod'd — operators may have
|
|
71
|
+
* deliberately widened the dir for sharing/sync.
|
|
72
|
+
*/
|
|
73
|
+
ensureParentDirMode?: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface AtomicWriteResult {
|
|
77
|
+
/** True when the rename succeeded and the final mode (if requested) is in place. */
|
|
78
|
+
written: boolean;
|
|
79
|
+
/** Error message when written is false. */
|
|
80
|
+
error?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Atomically write `content` to `path`. See module-level doc for guarantees.
|
|
85
|
+
*
|
|
86
|
+
* Returns `{ written: true }` on success, `{ written: false, error }` on
|
|
87
|
+
* failure (tmp file is cleaned up; original `path` is untouched).
|
|
88
|
+
*/
|
|
89
|
+
export function atomicWrite(
|
|
90
|
+
path: string,
|
|
91
|
+
content: string | Buffer,
|
|
92
|
+
opts: AtomicWriteOptions = {},
|
|
93
|
+
): AtomicWriteResult {
|
|
94
|
+
const tmpPath = `${path}.tmp`;
|
|
95
|
+
const parentDir = dirname(path);
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
if (!existsSync(parentDir)) {
|
|
99
|
+
const mkdirOpts: { recursive: true; mode?: number } = { recursive: true };
|
|
100
|
+
if (opts.ensureParentDirMode !== undefined) {
|
|
101
|
+
mkdirOpts.mode = opts.ensureParentDirMode;
|
|
102
|
+
}
|
|
103
|
+
mkdirSync(parentDir, mkdirOpts);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const buf = typeof content === 'string' ? Buffer.from(content, 'utf-8') : content;
|
|
107
|
+
const openMode = opts.mode ?? 0o644;
|
|
108
|
+
const fd = openSync(tmpPath, 'w', openMode);
|
|
109
|
+
try {
|
|
110
|
+
writeSync(fd, buf, 0, buf.length, 0);
|
|
111
|
+
fsyncSync(fd);
|
|
112
|
+
} finally {
|
|
113
|
+
closeSync(fd);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (opts.mode !== undefined) {
|
|
117
|
+
// CR-9 audit M1 fix: chmod the TMP file BEFORE rename so the final
|
|
118
|
+
// file appears with the correct mode atomically. Without this, the
|
|
119
|
+
// prior post-rename chmod left a microsecond TOCTOU window where the
|
|
120
|
+
// file existed at the final path with umask-default mode (e.g.
|
|
121
|
+
// 0o644) — an attacker process polling the cache file could read
|
|
122
|
+
// the contents before chmod fires. With chmod-before-rename, the
|
|
123
|
+
// rename atomically delivers a file already at mode 0o600.
|
|
124
|
+
chmodSync(tmpPath, opts.mode);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
renameSync(tmpPath, path);
|
|
128
|
+
|
|
129
|
+
return { written: true };
|
|
130
|
+
} catch (err) {
|
|
131
|
+
if (existsSync(tmpPath)) {
|
|
132
|
+
try {
|
|
133
|
+
rmSync(tmpPath, { force: true });
|
|
134
|
+
} catch {
|
|
135
|
+
// Tmp cleanup is best-effort; primary error wins.
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return { written: false, error: err instanceof Error ? err.message : String(err) };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Stat a path and return whether it is group-writable or world-writable.
|
|
144
|
+
* Used by Plan 3c gap-37 deliverable to emit a stderr warning when ~/.massu/
|
|
145
|
+
* is shared across users via NFS / symlink / chmod widening — security-
|
|
146
|
+
* relevant cache files MUST NOT be readable by other accounts on shared
|
|
147
|
+
* systems.
|
|
148
|
+
*
|
|
149
|
+
* CR-9 audit L4 fix: returns `null` (unknown) on stat error instead of
|
|
150
|
+
* `false` (definitely-not-writable). Caller treats unknown as "warn"
|
|
151
|
+
* since a stat error could itself indicate adversarial filesystem state
|
|
152
|
+
* (mounted by another user, ownership flip, etc.). Returning false
|
|
153
|
+
* silently in that path was a defense-bypass.
|
|
154
|
+
*/
|
|
155
|
+
export function isGroupOrWorldWritable(path: string): boolean | null {
|
|
156
|
+
if (!existsSync(path)) return false;
|
|
157
|
+
try {
|
|
158
|
+
const mode = statSync(path).mode;
|
|
159
|
+
// 0o020 = group-write, 0o002 = other-write
|
|
160
|
+
return (mode & 0o022) !== 0;
|
|
161
|
+
} catch {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
}
|