@massu/core 1.4.0 → 1.5.1
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/dist/cli.js +9431 -5167
- package/dist/hooks/auto-learning-pipeline.js +18 -0
- package/dist/hooks/classify-failure.js +18 -0
- package/dist/hooks/cost-tracker.js +18 -0
- package/dist/hooks/fix-detector.js +18 -0
- package/dist/hooks/incident-pipeline.js +18 -0
- package/dist/hooks/post-edit-context.js +18 -0
- package/dist/hooks/post-tool-use.js +18 -0
- package/dist/hooks/pre-compact.js +18 -0
- package/dist/hooks/pre-delete-check.js +18 -0
- package/dist/hooks/quality-event.js +18 -0
- package/dist/hooks/rule-enforcement-pipeline.js +18 -0
- package/dist/hooks/session-end.js +18 -0
- package/dist/hooks/session-start.js +2952 -2740
- package/dist/hooks/user-prompt.js +18 -0
- package/docs/AUTHORING-ADAPTERS.md +207 -0
- package/docs/SECURITY.md +250 -0
- package/package.json +7 -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 +1 -0
- package/src/commands/config-upgrade.ts +1 -0
- package/src/commands/doctor.ts +2 -0
- package/src/commands/init.ts +151 -2
- package/src/config.ts +63 -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 +50 -0
- package/src/detect/adapters/types.ts +18 -0
- package/src/detect/framework-detector.ts +26 -0
- package/src/detect/manifest-registry.ts +261 -0
- package/src/detect/monorepo-detector.ts +1 -0
- package/src/detect/package-detector.ts +162 -62
- package/src/detect/source-dir-detector.ts +7 -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/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/templates/aspnet/massu.config.yaml +61 -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,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
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTPS fetch helper with strict host allowlist + bounded timeout.
|
|
3
|
+
*
|
|
4
|
+
* Plan 3c Phase 5 deliverable (gap-3 / Phase 5 deps line 114). The Phase 5
|
|
5
|
+
* adapter-verifier is the only consumer; fetching from arbitrary URLs is
|
|
6
|
+
* forbidden. Allowlist:
|
|
7
|
+
* - registry.massu.ai (manifest fetch)
|
|
8
|
+
* - telemetry.massu.ai (adapter-discovery telemetry)
|
|
9
|
+
*
|
|
10
|
+
* Why an allowlist: an unrestricted fetch primitive in @massu/core would
|
|
11
|
+
* be a supply-chain liability — a future bug or compromised adapter could
|
|
12
|
+
* exfiltrate data to attacker-controlled hosts. The allowlist lives in
|
|
13
|
+
* THIS module's source code (single source of truth), not in
|
|
14
|
+
* massu.config.yaml — operators cannot widen it without auditing this
|
|
15
|
+
* file.
|
|
16
|
+
*
|
|
17
|
+
* Why this module instead of a third-party http lib: Node 18+ has a built-in
|
|
18
|
+
* fetch (per Plan 3c gap-3 audit-fact line 46 — "no deps for `node-fetch`").
|
|
19
|
+
* This module wraps it with the allowlist + timeout + JSON parsing in a
|
|
20
|
+
* single typed surface. The verifier doesn't need redirects, retry, or
|
|
21
|
+
* streaming — those features are attack surface we don't want.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
25
|
+
/**
|
|
26
|
+
* CR-9 audit H3 fix: hard cap on response body size. Without this, a
|
|
27
|
+
* compromised CDN (or TLS-MITM) could serve a multi-GB body that exceeds
|
|
28
|
+
* available heap before the timeout fires. The manifest is on the order
|
|
29
|
+
* of KB; even with 1000 adapter entries it should fit comfortably under
|
|
30
|
+
* 1 MB. The 10 MB ceiling here is generous enough to never affect
|
|
31
|
+
* legitimate manifests AND tight enough to bound damage from a malicious
|
|
32
|
+
* upstream.
|
|
33
|
+
*/
|
|
34
|
+
const DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Allowlist of hosts the fetcher will GET from. Exported for unit testing
|
|
38
|
+
* but intentionally read-only (`as const`) — runtime mutation is impossible.
|
|
39
|
+
* To widen the allowlist for a new use case, edit this constant in source
|
|
40
|
+
* and ship a `@massu/core` minor release.
|
|
41
|
+
*/
|
|
42
|
+
export const ALLOWED_HOSTS = [
|
|
43
|
+
'registry.massu.ai',
|
|
44
|
+
'telemetry.massu.ai',
|
|
45
|
+
] as const;
|
|
46
|
+
|
|
47
|
+
export interface FetchUrlOptions {
|
|
48
|
+
/** Maximum time the request may take, in milliseconds. Defaults to 10s. */
|
|
49
|
+
timeoutMs?: number;
|
|
50
|
+
/**
|
|
51
|
+
* Override the allowlist for tests. Production code MUST NOT pass this —
|
|
52
|
+
* the default ALLOWED_HOSTS is the production contract. Test-only.
|
|
53
|
+
*/
|
|
54
|
+
allowedHosts?: readonly string[];
|
|
55
|
+
/**
|
|
56
|
+
* Override the response body size cap (test-only). Defaults to 10 MB.
|
|
57
|
+
* CR-9 audit H3 fix: bounds OOM exposure from a malicious upstream.
|
|
58
|
+
*/
|
|
59
|
+
maxBodyBytes?: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface FetchUrlResult {
|
|
63
|
+
status: number;
|
|
64
|
+
/** Response body as UTF-8 string. */
|
|
65
|
+
body: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class FetchAllowlistError extends Error {
|
|
69
|
+
constructor(public readonly url: string, public readonly host: string) {
|
|
70
|
+
super(
|
|
71
|
+
`Refusing to fetch ${url}: host '${host}' is not in the @massu/core ` +
|
|
72
|
+
`fetcher allowlist. Allowed hosts: ${ALLOWED_HOSTS.join(', ')}. ` +
|
|
73
|
+
`Widening the allowlist requires editing packages/core/src/security/fetcher.ts ` +
|
|
74
|
+
`at the source level.`,
|
|
75
|
+
);
|
|
76
|
+
this.name = 'FetchAllowlistError';
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class FetchTimeoutError extends Error {
|
|
81
|
+
constructor(public readonly url: string, public readonly timeoutMs: number) {
|
|
82
|
+
super(`Fetch of ${url} timed out after ${timeoutMs}ms`);
|
|
83
|
+
this.name = 'FetchTimeoutError';
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export class FetchBodySizeError extends Error {
|
|
88
|
+
constructor(public readonly url: string, public readonly maxBodyBytes: number) {
|
|
89
|
+
super(
|
|
90
|
+
`Fetch of ${url} exceeded body size cap of ${maxBodyBytes} bytes. ` +
|
|
91
|
+
`This indicates either a malicious upstream OR an unexpectedly large ` +
|
|
92
|
+
`legitimate response. Refusing to load.`,
|
|
93
|
+
);
|
|
94
|
+
this.name = 'FetchBodySizeError';
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* GET an HTTPS URL with allowlist enforcement + timeout. Returns the response
|
|
100
|
+
* body as a string (caller is responsible for JSON.parse + schema validation).
|
|
101
|
+
*
|
|
102
|
+
* Throws:
|
|
103
|
+
* - FetchAllowlistError if the URL's host is not in ALLOWED_HOSTS (or the
|
|
104
|
+
* test override). Includes the host name in the error so operators can
|
|
105
|
+
* debug misconfigurations.
|
|
106
|
+
* - FetchTimeoutError if the request exceeds the timeout.
|
|
107
|
+
* - TypeError if the URL does not parse as a valid URL.
|
|
108
|
+
* - Error (with .cause set to the underlying network error) for other failures.
|
|
109
|
+
*/
|
|
110
|
+
export async function fetchUrl(url: string, opts: FetchUrlOptions = {}): Promise<FetchUrlResult> {
|
|
111
|
+
const allowedHosts = opts.allowedHosts ?? ALLOWED_HOSTS;
|
|
112
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
113
|
+
const maxBodyBytes = opts.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
|
114
|
+
|
|
115
|
+
let parsed: URL;
|
|
116
|
+
try {
|
|
117
|
+
parsed = new URL(url);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
throw new TypeError(`fetchUrl: invalid URL: ${url}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (parsed.protocol !== 'https:') {
|
|
123
|
+
throw new FetchAllowlistError(url, parsed.host);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (!allowedHosts.includes(parsed.host as (typeof ALLOWED_HOSTS)[number])) {
|
|
127
|
+
throw new FetchAllowlistError(url, parsed.host);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const controller = new AbortController();
|
|
131
|
+
const timeoutHandle = setTimeout(() => controller.abort(), timeoutMs);
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const response = await fetch(url, {
|
|
135
|
+
method: 'GET',
|
|
136
|
+
signal: controller.signal,
|
|
137
|
+
redirect: 'error', // refuse redirects — would defeat the allowlist
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// CR-9 audit H3 fix: stream the body with a hard byte cap. Without this,
|
|
141
|
+
// a malicious upstream could OOM the host with a multi-GB response
|
|
142
|
+
// before the wall-clock timeout fires. content-length header check is
|
|
143
|
+
// a fast pre-filter; the streaming reader is the actual enforcement.
|
|
144
|
+
// Headers can be absent in test mocks; treat missing as "no pre-filter".
|
|
145
|
+
if (response.headers && typeof response.headers.get === 'function') {
|
|
146
|
+
const contentLengthHeader = response.headers.get('content-length');
|
|
147
|
+
if (contentLengthHeader !== null && contentLengthHeader !== undefined) {
|
|
148
|
+
const declared = Number.parseInt(contentLengthHeader, 10);
|
|
149
|
+
if (Number.isFinite(declared) && declared > maxBodyBytes) {
|
|
150
|
+
throw new FetchBodySizeError(url, maxBodyBytes);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const body = response.body;
|
|
156
|
+
if (body && typeof body.getReader === 'function') {
|
|
157
|
+
// Streaming path: bound the read by maxBodyBytes. Used in production
|
|
158
|
+
// (Node 18+ fetch returns a ReadableStream).
|
|
159
|
+
const reader = body.getReader();
|
|
160
|
+
const chunks: Uint8Array[] = [];
|
|
161
|
+
let received = 0;
|
|
162
|
+
while (true) {
|
|
163
|
+
const { value, done } = await reader.read();
|
|
164
|
+
if (done) break;
|
|
165
|
+
if (value) {
|
|
166
|
+
received += value.byteLength;
|
|
167
|
+
if (received > maxBodyBytes) {
|
|
168
|
+
try { await reader.cancel(); } catch { /* best effort */ }
|
|
169
|
+
throw new FetchBodySizeError(url, maxBodyBytes);
|
|
170
|
+
}
|
|
171
|
+
chunks.push(value);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const merged = new Uint8Array(received);
|
|
175
|
+
let offset = 0;
|
|
176
|
+
for (const chunk of chunks) {
|
|
177
|
+
merged.set(chunk, offset);
|
|
178
|
+
offset += chunk.byteLength;
|
|
179
|
+
}
|
|
180
|
+
return { status: response.status, body: new TextDecoder().decode(merged) };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Fallback when response.body is not a ReadableStream (e.g. test
|
|
184
|
+
// mocks that only stub `text()`). The size cap is still enforced
|
|
185
|
+
// post-read because the text length is the byte length for ASCII +
|
|
186
|
+
// a strict upper bound for UTF-8 content.
|
|
187
|
+
const text = await response.text();
|
|
188
|
+
if (text.length > maxBodyBytes) {
|
|
189
|
+
throw new FetchBodySizeError(url, maxBodyBytes);
|
|
190
|
+
}
|
|
191
|
+
return { status: response.status, body: text };
|
|
192
|
+
} catch (err) {
|
|
193
|
+
if (err instanceof Error && err.name === 'AbortError') {
|
|
194
|
+
throw new FetchTimeoutError(url, timeoutMs);
|
|
195
|
+
}
|
|
196
|
+
throw err;
|
|
197
|
+
} finally {
|
|
198
|
+
clearTimeout(timeoutHandle);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Install-time + load-time sha256 verification of REGISTRY-VERIFIED adapter
|
|
3
|
+
* package contents (Plan 3c gap-37).
|
|
4
|
+
*
|
|
5
|
+
* Two checks defend against post-install tampering:
|
|
6
|
+
*
|
|
7
|
+
* 1. INSTALL-time: when the operator runs `npx massu adapters install <pkg>`,
|
|
8
|
+
* this module walks the installed package's directory, computes a content-
|
|
9
|
+
* addressed sha256, AND verifies it equals the sha256 the signed registry
|
|
10
|
+
* manifest entry pinned. On match → record in
|
|
11
|
+
* ~/.massu/adapter-manifest-installed.json with the version + timestamp.
|
|
12
|
+
* Mismatch → refuse to register; the operator should `npm uninstall` the
|
|
13
|
+
* suspicious package.
|
|
14
|
+
*
|
|
15
|
+
* 2. LOAD-time: discovery (detect/adapters/discover.ts) re-computes the
|
|
16
|
+
* package's sha256 on every startup, compares to the install-time recorded
|
|
17
|
+
* hash from the sidecar file. Mismatch → REFUSE to load (post-install
|
|
18
|
+
* tampering of unpacked files in node_modules). The load-time check
|
|
19
|
+
* compares to the LOCAL sidecar, NOT the live registry — re-fetching
|
|
20
|
+
* from the registry every startup would be a network dependency on the
|
|
21
|
+
* boot path (per Plan 3c gap-3 deliverable: NO network on boot).
|
|
22
|
+
*
|
|
23
|
+
* Why content-addressed (not tarball sha256):
|
|
24
|
+
* The published tarball's sha256 is a moving target — npm tarballs include
|
|
25
|
+
* timestamps + permission bits that vary across operating systems. The
|
|
26
|
+
* SAME tarball extracted to two different machines produces different
|
|
27
|
+
* tarball hashes but identical FILE CONTENT hashes. Content-addressed
|
|
28
|
+
* hashing (sha256 of canonical-stringified path:fileSha pairs, sorted by
|
|
29
|
+
* path) is stable across machines + filesystems.
|
|
30
|
+
*
|
|
31
|
+
* Per CR-46 / Rule 0 single-source-of-truth: this is the ONLY recursive
|
|
32
|
+
* directory hashing in @massu/core. Future modules that need to hash
|
|
33
|
+
* directories MUST consume sha256OfDir from here, not re-implement.
|
|
34
|
+
*/
|
|
35
|
+
import { readFileSync, readdirSync, lstatSync, existsSync } from 'node:fs';
|
|
36
|
+
import { join, relative, sep } from 'node:path';
|
|
37
|
+
import { homedir } from 'node:os';
|
|
38
|
+
import { resolve } from 'node:path';
|
|
39
|
+
import { createHash } from 'node:crypto';
|
|
40
|
+
import { z } from 'zod';
|
|
41
|
+
import { atomicWrite } from './atomic-write.js';
|
|
42
|
+
|
|
43
|
+
export const INSTALLED_MANIFEST_PATH = resolve(homedir(), '.massu', 'adapter-manifest-installed.json');
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Compute a content-addressed sha256 of a directory recursively. Stable
|
|
47
|
+
* across machines + filesystems: sorts files by relative POSIX path,
|
|
48
|
+
* hashes each file's bytes, concatenates `<path>\0<sha256-hex>\n` per
|
|
49
|
+
* file, then sha256s the whole concatenation.
|
|
50
|
+
*
|
|
51
|
+
* Symlinks are NOT followed (they're hashed as their target path content).
|
|
52
|
+
* Directory entries (themselves) are NOT hashed (their content is implied
|
|
53
|
+
* by the files inside). Files exceeding `maxFileBytes` (default 64 MB)
|
|
54
|
+
* abort with an error — adapter packages should not ship huge binary
|
|
55
|
+
* blobs; the cap is a sanity ceiling against accidental misuse.
|
|
56
|
+
*
|
|
57
|
+
* Excluded patterns (NEVER hashed; they are install-time artifacts that
|
|
58
|
+
* vary across machines):
|
|
59
|
+
* - .git/
|
|
60
|
+
* - node_modules/ (transitive deps; their integrity is npm's concern)
|
|
61
|
+
* - any path containing /.cache/ or /.tmp/
|
|
62
|
+
*/
|
|
63
|
+
const DEFAULT_MAX_FILE_BYTES = 64 * 1024 * 1024;
|
|
64
|
+
/**
|
|
65
|
+
* Directory names that sha256OfDir EXCLUDES from hashing — these are
|
|
66
|
+
* install-time artifacts that vary across machines (.git history,
|
|
67
|
+
* transitive deps, build caches, scratch dirs). Their CONTENT is not
|
|
68
|
+
* part of the adapter package's content-addressable hash.
|
|
69
|
+
*
|
|
70
|
+
* CR-9 audit M5 + iter-2 audit MED-NEW-1/-2 enforcement: a published
|
|
71
|
+
* npm tarball MUST NOT ship these directories. Any package that does
|
|
72
|
+
* is refused at install + resign + load-time discovery via
|
|
73
|
+
* `containsHiddenDirs()` below. Without these refusals, a malicious
|
|
74
|
+
* tarball could smuggle payload files under `.git/payload.js` (excluded
|
|
75
|
+
* from the hash) and have them require()'d by the legitimate adapter
|
|
76
|
+
* at runtime — hash matches, payload runs.
|
|
77
|
+
*/
|
|
78
|
+
export const EXCLUDED_DIR_NAMES: ReadonlySet<string> = new Set(['.git', 'node_modules', '.cache', '.tmp']);
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Returns the first hidden-dir name found in `packageDir`, or null if
|
|
82
|
+
* none are present. Caller (install / resign / discovery) refuses the
|
|
83
|
+
* package on non-null return.
|
|
84
|
+
*/
|
|
85
|
+
export function containsHiddenDirs(packageDir: string): string | null {
|
|
86
|
+
for (const hidden of EXCLUDED_DIR_NAMES) {
|
|
87
|
+
if (existsSync(`${packageDir}/${hidden}`)) {
|
|
88
|
+
return hidden;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface Sha256OfDirOpts {
|
|
95
|
+
/** Override the file-size cap (test-only). */
|
|
96
|
+
maxFileBytes?: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function sha256OfDir(dir: string, opts: Sha256OfDirOpts = {}): string {
|
|
100
|
+
const maxFileBytes = opts.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES;
|
|
101
|
+
const files: Array<{ relativePath: string; absPath: string }> = [];
|
|
102
|
+
|
|
103
|
+
function walk(currentDir: string): void {
|
|
104
|
+
let entries: string[];
|
|
105
|
+
try {
|
|
106
|
+
entries = readdirSync(currentDir);
|
|
107
|
+
} catch {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
for (const entry of entries.sort()) {
|
|
111
|
+
const absPath = join(currentDir, entry);
|
|
112
|
+
let lst;
|
|
113
|
+
try {
|
|
114
|
+
// CR-9 audit H1 fix: lstatSync (not statSync) so symlinks are
|
|
115
|
+
// detected + skipped without following. Following symlinks would
|
|
116
|
+
// expose a read-anywhere primitive (a malicious dist/x.js -> /etc/shadow
|
|
117
|
+
// would have the target's content captured into the hash) AND would
|
|
118
|
+
// cause readFileSync to block on named-pipe targets.
|
|
119
|
+
lst = lstatSync(absPath);
|
|
120
|
+
} catch {
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (lst.isSymbolicLink()) {
|
|
124
|
+
// Skip symlinks entirely — they're not part of the package's
|
|
125
|
+
// content-addressable hash. A package author who legitimately wants
|
|
126
|
+
// to ship a symlink (rare) must replace it with a real file at
|
|
127
|
+
// publish time.
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (lst.isDirectory()) {
|
|
131
|
+
if (EXCLUDED_DIR_NAMES.has(entry)) continue;
|
|
132
|
+
walk(absPath);
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (!lst.isFile()) continue;
|
|
136
|
+
if (lst.size > maxFileBytes) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`sha256OfDir: file ${absPath} exceeds maxFileBytes (${lst.size} > ${maxFileBytes}); ` +
|
|
139
|
+
`adapter packages should not ship files this large.`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
const rel = relative(dir, absPath).split(sep).join('/');
|
|
143
|
+
files.push({ relativePath: rel, absPath });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
walk(dir);
|
|
147
|
+
|
|
148
|
+
// Sort by relative POSIX path so the hash is stable regardless of
|
|
149
|
+
// readdir order (some filesystems return entries in inode order).
|
|
150
|
+
files.sort((a, b) => (a.relativePath < b.relativePath ? -1 : a.relativePath > b.relativePath ? 1 : 0));
|
|
151
|
+
|
|
152
|
+
const top = createHash('sha256');
|
|
153
|
+
for (const f of files) {
|
|
154
|
+
const fileHash = createHash('sha256').update(readFileSync(f.absPath)).digest('hex');
|
|
155
|
+
top.update(f.relativePath, 'utf-8');
|
|
156
|
+
top.update('\0', 'utf-8');
|
|
157
|
+
top.update(fileHash, 'utf-8');
|
|
158
|
+
top.update('\n', 'utf-8');
|
|
159
|
+
}
|
|
160
|
+
return top.digest('hex');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Per-package install record. Keyed by package name in the sidecar's
|
|
165
|
+
* top-level object.
|
|
166
|
+
*/
|
|
167
|
+
const InstallEntrySchema = z.object({
|
|
168
|
+
// CR-9 iter-3 audit LOW-NEW3-1 fix: reject control characters in version.
|
|
169
|
+
// Without this, a same-user attacker writing the install-tracking
|
|
170
|
+
// sidecar could embed ANSI escapes (or other control chars) in version,
|
|
171
|
+
// log-injecting via runAdaptersResign's `${name}@${entry.version}`
|
|
172
|
+
// stderr emits. The regex permits printable ASCII (0x20-0x7e) plus
|
|
173
|
+
// common semver characters; control chars (0x00-0x1f, 0x7f) are
|
|
174
|
+
// rejected at parse time. Schema-level validation closes the vector
|
|
175
|
+
// at every callsite that reads the sidecar — no per-callsite escaping
|
|
176
|
+
// gymnastics needed.
|
|
177
|
+
version: z.string().min(1).regex(/^[\x20-\x7e]+$/, 'version must be printable ASCII (no control characters)'),
|
|
178
|
+
installed_sha256: z.string().regex(/^[0-9a-f]{64}$/),
|
|
179
|
+
manifest_sha256: z.string().regex(/^[0-9a-f]{64}$/),
|
|
180
|
+
ts: z.string().min(1).regex(/^[\x20-\x7e]+$/, 'ts must be printable ASCII (no control characters)'),
|
|
181
|
+
}).strict();
|
|
182
|
+
export type InstallEntry = z.infer<typeof InstallEntrySchema>;
|
|
183
|
+
|
|
184
|
+
const InstalledManifestSchema = z.record(z.string(), InstallEntrySchema);
|
|
185
|
+
export type InstalledManifest = z.infer<typeof InstalledManifestSchema>;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Read the install-tracking sidecar at ~/.massu/adapter-manifest-installed.json.
|
|
189
|
+
* Returns an empty object when the file is absent OR fails parse / schema —
|
|
190
|
+
* caller should treat absent + corrupt the same way (no install records, so
|
|
191
|
+
* REGISTRY-VERIFIED adapters cannot satisfy the load-time check + are refused
|
|
192
|
+
* until reinstalled via `npx massu adapters install`).
|
|
193
|
+
*/
|
|
194
|
+
export function readInstalledManifest(path: string = INSTALLED_MANIFEST_PATH): InstalledManifest {
|
|
195
|
+
if (!existsSync(path)) return {};
|
|
196
|
+
let raw: unknown;
|
|
197
|
+
try {
|
|
198
|
+
raw = JSON.parse(readFileSync(path, 'utf-8'));
|
|
199
|
+
} catch {
|
|
200
|
+
return {};
|
|
201
|
+
}
|
|
202
|
+
const parsed = InstalledManifestSchema.safeParse(raw);
|
|
203
|
+
return parsed.success ? parsed.data : {};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export type InstallTrackingWriteResult = { written: true } | { written: false; error: string };
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Write a single package's install entry to the sidecar (additive). Reads
|
|
210
|
+
* the current sidecar, updates the named key, writes back atomically with
|
|
211
|
+
* mode 0o600. Concurrent calls to this function for DIFFERENT package
|
|
212
|
+
* names are NOT serialized — the manifest cache lock at
|
|
213
|
+
* ~/.massu/.adapter-manifest.lock applies only to the cache, not this
|
|
214
|
+
* sidecar. Since the install flow runs interactively (one CLI invocation
|
|
215
|
+
* at a time per shell), racing writes are not a practical concern.
|
|
216
|
+
*/
|
|
217
|
+
export function writeInstalledManifestEntry(
|
|
218
|
+
packageName: string,
|
|
219
|
+
entry: InstallEntry,
|
|
220
|
+
path: string = INSTALLED_MANIFEST_PATH,
|
|
221
|
+
): InstallTrackingWriteResult {
|
|
222
|
+
// CR-9 iter-4 audit LOW-NEW4-1 fix: validate entry against the schema
|
|
223
|
+
// BEFORE writing. TypeScript types do NOT enforce Zod regex constraints
|
|
224
|
+
// at runtime; without this .parse(), a caller passing a control-char-
|
|
225
|
+
// contaminated entry (e.g. version from an attacker-controlled
|
|
226
|
+
// node_modules package.json) would write the malformed entry to disk,
|
|
227
|
+
// and the corresponding stderr emit at the install/resign callsite
|
|
228
|
+
// would log-inject before the next read-time validation could catch it.
|
|
229
|
+
const validated = InstallEntrySchema.safeParse(entry);
|
|
230
|
+
if (!validated.success) {
|
|
231
|
+
const issues = validated.error.issues
|
|
232
|
+
.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`)
|
|
233
|
+
.join('; ');
|
|
234
|
+
return { written: false, error: `install-tracking entry shape invalid: ${issues}` };
|
|
235
|
+
}
|
|
236
|
+
const current = readInstalledManifest(path);
|
|
237
|
+
current[packageName] = validated.data;
|
|
238
|
+
const result = atomicWrite(path, JSON.stringify(current, null, 2), {
|
|
239
|
+
mode: 0o600,
|
|
240
|
+
ensureParentDirMode: 0o700,
|
|
241
|
+
});
|
|
242
|
+
if (!result.written) {
|
|
243
|
+
return { written: false, error: result.error ?? 'unknown atomicWrite error' };
|
|
244
|
+
}
|
|
245
|
+
return { written: true };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Remove a single package's install entry from the sidecar. Used by
|
|
250
|
+
* `npx massu adapters resign --uninstall` (or by future cleanup tooling).
|
|
251
|
+
* No-op if the entry is absent; sidecar file persists.
|
|
252
|
+
*/
|
|
253
|
+
export function removeInstalledManifestEntry(
|
|
254
|
+
packageName: string,
|
|
255
|
+
path: string = INSTALLED_MANIFEST_PATH,
|
|
256
|
+
): InstallTrackingWriteResult {
|
|
257
|
+
const current = readInstalledManifest(path);
|
|
258
|
+
if (!(packageName in current)) {
|
|
259
|
+
return { written: true };
|
|
260
|
+
}
|
|
261
|
+
delete current[packageName];
|
|
262
|
+
const result = atomicWrite(path, JSON.stringify(current, null, 2), {
|
|
263
|
+
mode: 0o600,
|
|
264
|
+
ensureParentDirMode: 0o700,
|
|
265
|
+
});
|
|
266
|
+
if (!result.written) {
|
|
267
|
+
return { written: false, error: result.error ?? 'unknown atomicWrite error' };
|
|
268
|
+
}
|
|
269
|
+
return { written: true };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export type IntegrityCheckResult =
|
|
273
|
+
| { kind: 'ok'; entry: InstallEntry }
|
|
274
|
+
| { kind: 'no-entry'; reason: string }
|
|
275
|
+
| { kind: 'drift'; entry: InstallEntry; computedSha: string; reason: string };
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Load-time integrity check for an installed REGISTRY-VERIFIED adapter
|
|
279
|
+
* package. Compares the package directory's CURRENT sha256OfDir output
|
|
280
|
+
* to the install-time hash recorded in the sidecar. Mismatch → drift,
|
|
281
|
+
* caller refuses to load.
|
|
282
|
+
*
|
|
283
|
+
* Caller (typically discoverAdapters) interprets:
|
|
284
|
+
* - 'ok' → load is safe
|
|
285
|
+
* - 'no-entry' → package is in node_modules but never registered via
|
|
286
|
+
* `massu adapters install`; refuse + tell operator to run install
|
|
287
|
+
* - 'drift' → contents tampered after install; REFUSE; tell operator
|
|
288
|
+
* to `npm uninstall` + `npm install` + `massu adapters install`
|
|
289
|
+
*/
|
|
290
|
+
export function verifyInstalledIntegrity(
|
|
291
|
+
packageName: string,
|
|
292
|
+
packageDir: string,
|
|
293
|
+
sidecarPath: string = INSTALLED_MANIFEST_PATH,
|
|
294
|
+
): IntegrityCheckResult {
|
|
295
|
+
const installed = readInstalledManifest(sidecarPath);
|
|
296
|
+
const entry = installed[packageName];
|
|
297
|
+
if (!entry) {
|
|
298
|
+
return {
|
|
299
|
+
kind: 'no-entry',
|
|
300
|
+
reason:
|
|
301
|
+
`${packageName} is in node_modules but has no install-tracking entry in ~/.massu/adapter-manifest-installed.json. ` +
|
|
302
|
+
`Run \`npx massu adapters install ${packageName}\` to register it.`,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
const computedSha = sha256OfDir(packageDir);
|
|
306
|
+
if (computedSha !== entry.installed_sha256) {
|
|
307
|
+
return {
|
|
308
|
+
kind: 'drift',
|
|
309
|
+
entry,
|
|
310
|
+
computedSha,
|
|
311
|
+
reason:
|
|
312
|
+
`${packageName}@${entry.version} contents changed after install: ` +
|
|
313
|
+
`expected sha256 ${entry.installed_sha256.slice(0, 16)}..., got ${computedSha.slice(0, 16)}.... ` +
|
|
314
|
+
`This indicates post-install tampering of the package files. Recover by running ` +
|
|
315
|
+
`\`npm uninstall ${packageName} && npm install ${packageName} && npx massu adapters install ${packageName}\`.`,
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
return { kind: 'ok', entry };
|
|
319
|
+
}
|