@hominis/fireforge 0.16.1 → 0.16.2
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/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
## 0.16.0
|
|
4
4
|
|
|
5
|
+
### Security — eval-driven hardening for 0.16.0
|
|
6
|
+
|
|
7
|
+
- **Release workflow — shell injection via `${{ inputs.version }}` interpolation.** `.github/workflows/release.yml` previously interpolated `${{ inputs.version }}` directly into `npm version "${{ inputs.version }}" --no-git-tag-version`, so anyone with `actions:write` could trigger `workflow_dispatch` with a crafted version string (e.g. `1.0.0"; <command> #`) and execute arbitrary shell inside the job. The release job carries `contents: write`, `id-token: write`, and the `npm` trusted-publishing environment, so that shell would have had an open door to the publish credentials. The fix routes `${{ inputs.version }}` through an `env: INPUT_VERSION:` block on the `Bump version` step and references `"$INPUT_VERSION"` inside the `run:` script, so GitHub Actions substitutes the value into the process environment rather than into the shell source. The `Tag and push` step gets the same treatment for `${{ steps.version.outputs.version }}` — defense-in-depth, since `npm version`'s semver check already filters that interpolation, but the pattern is consistent and cheap.
|
|
8
|
+
- **`fireforge config` — prototype pollution via sentinel key segments.** `mutateConfig` in `src/core/config-mutate.ts` walks a dot-separated key through `getOrCreateChildRecord(parent, segment)` with no filter on `__proto__`, `constructor`, or `prototype`. `fireforge config __proto__.polluted 1 --force` therefore reached `parent["__proto__"]` and wrote a plain property onto `Object.prototype`, polluting every object in the Node process for the rest of the run. `--force` was the motivating pathway (the strict path guard rejects unknown top-level keys for non-sentinels), but the raw sink was also publicly re-exported from `src/core/config.ts`, widening the blast radius to any future caller. The fix rejects sentinel segments up-front in `mutateConfig` with a `ConfigError` before any clone or mutation — a single guard at the entry point covers both the descent loop and the final leaf assignment, and surfaces to the CLI as a normal "invalid key" failure rather than a crash. `readJson`'s existing reviver already strips the sentinels from loads, so input configs can't arrive pre-polluted.
|
|
9
|
+
- **`fireforge wire --dom` — asymmetric newline marker parser projection drift.** `parseHunksForFile` in `src/core/patch-parse.ts` tracked `` as a single `noNewlineAtEnd: boolean` — collapsing the old-side and new-side markers into one flag. Asymmetric trailing-newline changes (e.g. removing the newline from the old side while the new side keeps one, or vice versa) were indistinguishable from the symmetric case, so `applyPatchToContent` produced content that disagreed with `git apply` on whether to emit a trailing newline. The projection drift surfaced as phantom entries in `fireforge status` and wipe-safe reprojection work in `fireforge import` for patches that were otherwise clean. The fix splits the field into `noNewlineAtEndOld` / `noNewlineAtEndNew` and peeks the body line that the marker trails (`-` → old-only, `+` → new-only, ` ` context → both), and `applyPatchToContent` now reads `noNewlineAtEndNew` for the output-side newline decision because the content we emit corresponds to the new side. `extractNewFileContentFromDiff` is unchanged — new-file patches only contain `+` lines, so its separate `hasNoNewlineMarker` local is already correct by construction.
|
|
10
|
+
- **`fireforge wire --dom` — engine-relative inputs probed against CWD.** `src/commands/wire.ts` passed the raw `stripEnginePrefix(options.dom)` result to `pathExists` without joining `paths.engine` first, so a relative `--dom browser/base/content/foo.inc.xhtml` was probed inside the operator's shell directory and failed "DOM fragment file not found" even when the file existed in the correct engine location. The follow-on `isPathInsideRoot` and `toRootRelativePath` calls worked by coincidence — they internally `resolve(paths.engine, candidate)`, which masked the bug past the existence probe but only because those calls never hit the filesystem. The fix mirrors the pattern already in use in `src/commands/register.ts`: when the candidate is absolute probe it as-is, otherwise `join(paths.engine, candidate)` first. The error message still echoes the original operator input (not the internal joined path) so it remains copy-pasteable back into the CLI.
|
|
11
|
+
|
|
5
12
|
### Config — `--force`-written keys are now readable
|
|
6
13
|
|
|
7
14
|
- `fireforge config <key>` (read mode) now consults the raw `fireforge.json` document instead of the validated, typed config that `loadConfig` produces. Before this change, `fireforge config totallyUnknown value --force` succeeded (the key was persisted to disk via `writeConfigDocument`), but the corresponding `fireforge config totallyUnknown` read threw `Unknown config key: totallyUnknown` because `validateConfig` rebuilds a clean object containing only the schema-known fields — the forced key survived on disk but was invisible to the typed read path. A new `loadRawConfigDocument` helper in `src/core/config.ts` returns the raw JSON record, and the command's read branch now traverses that document. Writes are unaffected: schema validation still enforces shape for known keys, and `--force` continues to be the escape hatch for unknown keys.
|
|
@@ -135,7 +135,20 @@ export async function wireCommand(projectRoot, name, options = {}) {
|
|
|
135
135
|
const domCandidate = isExplicitAbsolutePath(options.dom)
|
|
136
136
|
? options.dom
|
|
137
137
|
: stripEnginePrefix(options.dom);
|
|
138
|
-
|
|
138
|
+
// `pathExists` resolves relative paths against CWD, so an engine-
|
|
139
|
+
// relative `domCandidate` (e.g. `browser/base/content/foo.inc.xhtml`)
|
|
140
|
+
// would be probed inside the operator's shell directory rather than
|
|
141
|
+
// the engine root and fail "DOM fragment file not found" even when
|
|
142
|
+
// the file is sitting at engine/<path>. Mirror `register.ts`: probe
|
|
143
|
+
// the absolute path as-is, otherwise join with `paths.engine` first.
|
|
144
|
+
// The `isPathInsideRoot` / `toRootRelativePath` calls below keep
|
|
145
|
+
// operating on `domCandidate` because they internally resolve
|
|
146
|
+
// relative candidates against the engine root, which matches the
|
|
147
|
+
// probe path we just built.
|
|
148
|
+
const domProbePath = isExplicitAbsolutePath(domCandidate)
|
|
149
|
+
? domCandidate
|
|
150
|
+
: join(paths.engine, domCandidate);
|
|
151
|
+
if (!(await pathExists(domProbePath))) {
|
|
139
152
|
throw new InvalidArgumentError(`DOM fragment file not found: ${options.dom}`, 'dom');
|
|
140
153
|
}
|
|
141
154
|
if (!isPathInsideRoot(paths.engine, domCandidate)) {
|
|
@@ -14,6 +14,23 @@ function cloneConfigDocument(config) {
|
|
|
14
14
|
}
|
|
15
15
|
return cloned;
|
|
16
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* Key segments that would walk into or rewrite the object prototype chain
|
|
19
|
+
* if used as plain property names. Blocked up-front so the descent in
|
|
20
|
+
* {@link mutateConfig} cannot be weaponized to mutate `Object.prototype`
|
|
21
|
+
* process-wide — e.g. `fireforge config __proto__.polluted 1 --force`
|
|
22
|
+
* would otherwise land in `getOrCreateChildRecord(raw, "__proto__")`.
|
|
23
|
+
*/
|
|
24
|
+
const SENTINEL_KEY_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
25
|
+
function assertNoSentinelSegments(key, parts) {
|
|
26
|
+
for (const part of parts) {
|
|
27
|
+
if (SENTINEL_KEY_SEGMENTS.has(part)) {
|
|
28
|
+
throw new ConfigError(`Config key "${key}" contains a reserved segment "${part}". ` +
|
|
29
|
+
'Segments "__proto__", "constructor", and "prototype" are not permitted ' +
|
|
30
|
+
'because they would mutate the object prototype chain.');
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
17
34
|
function getOrCreateChildRecord(parent, key) {
|
|
18
35
|
const existing = parent[key];
|
|
19
36
|
if (isObject(existing)) {
|
|
@@ -24,8 +41,13 @@ function getOrCreateChildRecord(parent, key) {
|
|
|
24
41
|
return child;
|
|
25
42
|
}
|
|
26
43
|
export function mutateConfig(config, key, value, skipValidation = false) {
|
|
27
|
-
const raw = cloneConfigDocument(config);
|
|
28
44
|
const parts = key.split('.');
|
|
45
|
+
// Reject prototype-chain sentinel segments before any write so
|
|
46
|
+
// `--force` cannot be used to mutate Object.prototype. This guard must
|
|
47
|
+
// run against the original key parts, not any subset — the final leaf
|
|
48
|
+
// assignment `current[lastPart] = value` would otherwise stay vulnerable.
|
|
49
|
+
assertNoSentinelSegments(key, parts);
|
|
50
|
+
const raw = cloneConfigDocument(config);
|
|
29
51
|
let current = raw;
|
|
30
52
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
31
53
|
const part = parts[i];
|
|
@@ -24,19 +24,30 @@ export declare function isNewFileInPatch(patchContent: string, targetFile: strin
|
|
|
24
24
|
*/
|
|
25
25
|
export declare function extractAffectedFiles(diffContent: string): string[];
|
|
26
26
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
27
|
+
* A single parsed hunk. `noNewlineAtEndOld` / `noNewlineAtEndNew` track the
|
|
28
|
+
* `` marker per side — the marker is a trailing
|
|
29
|
+
* annotation on the immediately preceding body line, and a `-` precedent
|
|
30
|
+
* sets only the old-side flag, a `+` sets only the new-side flag, and a
|
|
31
|
+
* context ` ` line sets both. Collapsing the two into one boolean makes the
|
|
32
|
+
* projection disagree with `git apply` on asymmetric trailing-newline
|
|
33
|
+
* changes (e.g. removing a newline on one side but not the other).
|
|
31
34
|
*/
|
|
32
|
-
export
|
|
35
|
+
export interface ParsedHunk {
|
|
33
36
|
oldStart: number;
|
|
34
37
|
oldCount: number;
|
|
35
38
|
newStart: number;
|
|
36
39
|
newCount: number;
|
|
37
40
|
lines: string[];
|
|
38
|
-
|
|
39
|
-
|
|
41
|
+
noNewlineAtEndOld: boolean;
|
|
42
|
+
noNewlineAtEndNew: boolean;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Parses hunks from a patch file for a specific target file.
|
|
46
|
+
* @param patchContent - The full patch content
|
|
47
|
+
* @param targetFile - The file path to extract hunks for
|
|
48
|
+
* @returns Array of hunk objects with line info and changes
|
|
49
|
+
*/
|
|
50
|
+
export declare function parseHunksForFile(patchContent: string, targetFile: string): ParsedHunk[];
|
|
40
51
|
/**
|
|
41
52
|
* Extracts conflicting file paths from git apply error message.
|
|
42
53
|
*/
|
|
@@ -104,14 +104,36 @@ export function parseHunksForFile(patchContent, targetFile) {
|
|
|
104
104
|
newStart: parseInt(hunkMatch[3] ?? '0', 10),
|
|
105
105
|
newCount: parseInt(hunkMatch[4] ?? '1', 10),
|
|
106
106
|
lines: [],
|
|
107
|
-
|
|
107
|
+
noNewlineAtEndOld: false,
|
|
108
|
+
noNewlineAtEndNew: false,
|
|
108
109
|
};
|
|
109
110
|
continue;
|
|
110
111
|
}
|
|
111
112
|
// Collect hunk lines
|
|
112
113
|
if (currentHunk) {
|
|
113
114
|
if (line === '\') {
|
|
114
|
-
|
|
115
|
+
// The marker is an annotation on the immediately preceding body
|
|
116
|
+
// line. Peek the last collected line to decide which side(s) the
|
|
117
|
+
// annotation applies to — a single boolean cannot represent the
|
|
118
|
+
// asymmetric case where only one side lacks the trailing newline.
|
|
119
|
+
const previous = currentHunk.lines[currentHunk.lines.length - 1] ?? '';
|
|
120
|
+
if (previous.startsWith('-')) {
|
|
121
|
+
currentHunk.noNewlineAtEndOld = true;
|
|
122
|
+
}
|
|
123
|
+
else if (previous.startsWith('+')) {
|
|
124
|
+
currentHunk.noNewlineAtEndNew = true;
|
|
125
|
+
}
|
|
126
|
+
else if (previous.startsWith(' ')) {
|
|
127
|
+
// Context line: present in both sides, so the trailing-newline
|
|
128
|
+
// absence applies to both. This is rare (it only happens when
|
|
129
|
+
// the hunk ends on an unchanged line that itself is the last
|
|
130
|
+
// line of the file) but real — git emits it.
|
|
131
|
+
currentHunk.noNewlineAtEndOld = true;
|
|
132
|
+
currentHunk.noNewlineAtEndNew = true;
|
|
133
|
+
}
|
|
134
|
+
// If the marker appears with no preceding body line (malformed
|
|
135
|
+
// diff), leave both flags false — the downstream apply logic
|
|
136
|
+
// will still produce a defined result.
|
|
115
137
|
}
|
|
116
138
|
else if (line.startsWith('+') || line.startsWith('-') || line.startsWith(' ')) {
|
|
117
139
|
currentHunk.lines.push(line);
|
|
@@ -105,7 +105,10 @@ export async function applyPatchToContent(content, patchPath, targetFile) {
|
|
|
105
105
|
const sortedHunks = [...hunks].sort((a, b) => b.oldStart - a.oldStart);
|
|
106
106
|
// The "no newline at end" marker applies to the last hunk in file order
|
|
107
107
|
// (highest oldStart), which is the *first* hunk in our reverse-sorted array.
|
|
108
|
-
|
|
108
|
+
// We read the new-side flag because the output we produce corresponds to
|
|
109
|
+
// the new side; asymmetric diffs (old lacks newline, new has one — or
|
|
110
|
+
// vice versa) would otherwise disagree with `git apply`.
|
|
111
|
+
const lastHunkNoNewline = sortedHunks[0]?.noNewlineAtEndNew ?? false;
|
|
109
112
|
for (const hunk of sortedHunks) {
|
|
110
113
|
const newLines = [];
|
|
111
114
|
// Compute actual old-line count from hunk body for cross-check
|