Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 25 additions & 16 deletions src/domain/job-operation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,21 +154,6 @@ export type JobOperation =
| ForbiddenJobOperation
| UnknownJobOperation;

/**
* Membership is backed by a `Map`, not a plain object.
*
* A plain-object lookup inherits `Object.prototype`, so `'toString'`,
* `'constructor'`, and `'__proto__'` would resolve to a truthy entry. A `Map`
* has no prototype chain for keys. Same reasoning as PR 002's taxonomy.
*/
const OPERATION_LOOKUP: ReadonlyMap<string, RepairAuthorizableOperation | ForbiddenJobOperation> =
new Map<string, RepairAuthorizableOperation | ForbiddenJobOperation>([
...REPAIR_AUTHORIZABLE_OPERATIONS.map(
(operation) => [operation, operation] as const,
),
...FORBIDDEN_OPERATIONS.map((operation) => [operation, operation] as const),
]);

/**
* Resolve an untrusted operation name to a modeled member.
*
Expand All @@ -179,12 +164,36 @@ const OPERATION_LOOKUP: ReadonlyMap<string, RepairAuthorizableOperation | Forbid
* Anything unrecognised resolves to {@link UNKNOWN_JOB_OPERATION}, including
* the literal string `'unknown'` — the sentinel names the absence of a model,
* so requesting it by name is still an unmodeled request. Never throws.
*
* ## Resolution is a membership test, never a lookup
*
* This is deliberately *not* backed by a keyed container. A plain object would
* inherit `Object.prototype`, so `'toString'` and `'constructor'` would resolve
* to a truthy entry; a `Map` fixes that but reintroduces the same class of
* problem one level down, because `Map.prototype.get` is resolved at call time
* and is replaceable by anything that runs after this module is initialized. A
* poisoned `get` returning `'source.edit'` would turn `'merge'` and
* `'shell.exec'` into a repair-authorizable operation, and the merge barrier
* would be evaluated against the substituted name rather than the requested one.
*
* {@link containsValue} touches no prototype method at all: it reads `length`
* and own indices of a frozen array. And the value returned on a hit is the
* caller's own `value`, never a value produced by the container. So this
* function can only ever return the exact string it was given or the unknown
* sentinel — there is no mechanism, poisoned or otherwise, by which one
* operation name can be resolved as a different one.
*/
export function resolveJobOperation(value: unknown): JobOperation {
if (typeof value !== 'string') {
return UNKNOWN_JOB_OPERATION;
}
return OPERATION_LOOKUP.get(value) ?? UNKNOWN_JOB_OPERATION;
if (containsValue(REPAIR_AUTHORIZABLE_OPERATIONS, value)) {
return value as RepairAuthorizableOperation;
}
if (containsValue(FORBIDDEN_OPERATIONS, value)) {
return value as ForbiddenJobOperation;
}
return UNKNOWN_JOB_OPERATION;
}

/** Type guard: is this a modeled operation a repair job may be authorized for? */
Expand Down
146 changes: 146 additions & 0 deletions tests/domain/job-authorization-invariants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@ import {
JOB_AUTHORIZATION,
JOB_AUTHORIZATION_REASON,
JOB_BOUNDS,
JOB_OPERATION,
operatorMergeAuthorizes,
readJobOperation,
REPAIR_AUTHORIZABLE_OPERATIONS,
resolveJobOperation,
satisfiesIndependentValidator,
UNKNOWN_JOB_OPERATION,
type ApprovalRecord,
type JobOperationRequest,
type OperatorMergeAuthorization,
Expand Down Expand Up @@ -713,6 +716,149 @@ describe('prototype pollution and inherited properties create no authority', ()
});
});

/* -------------------------------------------------------------------------
* Hostile mutation of the runtime itself
* ------------------------------------------------------------------------- */

/**
* Run `body` with `Map.prototype.get` replaced by one that answers
* `'source.edit'` to everything, then restore the captured descriptor.
*
* `source.edit` is the payload precisely because it is the canonical *allowed*
* operation: if operation resolution consults a poisonable container method,
* every forbidden and unmodeled name collapses onto the one operation an
* ordinary repair job is authorized to perform.
*
* The original descriptor is captured and restored in a `finally`, so a failing
* assertion inside `body` cannot leave the runtime poisoned for another test.
*/
function withPoisonedMapGet<T>(body: () => T): T {
const saved = Object.getOwnPropertyDescriptor(Map.prototype, 'get');
Object.defineProperty(Map.prototype, 'get', {
value: function poisonedGet(): string {
return JOB_OPERATION.SOURCE_EDIT;
},
writable: true,
configurable: true,
});
try {
return body();
} finally {
if (saved === undefined) {
Reflect.deleteProperty(Map.prototype, 'get');
} else {
Object.defineProperty(Map.prototype, 'get', saved);
}
}
}

describe('poisoning Map.prototype.get cannot re-resolve an operation', () => {
it('proves the poisoning is actually in effect', () => {
// Without this the whole section could pass vacuously, on a runtime that
// was never hostile in the first place.
const observed = withPoisonedMapGet(() => new Map<string, string>().get('anything'));

expect(observed).toBe(JOB_OPERATION.SOURCE_EDIT);
});

it('keeps merge resolving to merge', () => {
const resolved = withPoisonedMapGet(() => resolveJobOperation(FORBIDDEN_OPERATION.MERGE));

expect(resolved).toBe(FORBIDDEN_OPERATION.MERGE);
});

it('keeps auto_merge.enable resolving to auto_merge.enable', () => {
const resolved = withPoisonedMapGet(() =>
resolveJobOperation(FORBIDDEN_OPERATION.AUTO_MERGE_ENABLE),
);

expect(resolved).toBe(FORBIDDEN_OPERATION.AUTO_MERGE_ENABLE);
});

it('keeps an unmodeled operation resolving to unknown', () => {
const resolved = withPoisonedMapGet(() => resolveJobOperation('shell.exec'));

expect(resolved).toBe(UNKNOWN_JOB_OPERATION);
});

it('keeps source.edit resolving to source.edit', () => {
const resolved = withPoisonedMapGet(() => resolveJobOperation(JOB_OPERATION.SOURCE_EDIT));

expect(resolved).toBe(JOB_OPERATION.SOURCE_EDIT);
});

it('resolves every modeled operation to itself and nothing else', () => {
const modeled = [...REPAIR_AUTHORIZABLE_OPERATIONS, ...FORBIDDEN_OPERATIONS];

const resolved = withPoisonedMapGet(() => modeled.map((o) => resolveJobOperation(o)));

expect(resolved).toStrictEqual(modeled);
});

it('does not let a merge request reach ALLOW_ONCE', () => {
// The request carries valid `source.edit` operands, so nothing earlier in
// the evaluator can refuse it on an operand ground. The only thing standing
// between it and a permit is that `merge` still resolves as `merge`.
const decision = withPoisonedMapGet(() =>
authorizeJobOperation(
buildJob(),
buildEdit({ operation: FORBIDDEN_OPERATION.MERGE }),
),
);

expect(decision.operation).toBe(FORBIDDEN_OPERATION.MERGE);
expect(decision.decision).toBe(JOB_AUTHORIZATION.OPERATOR_REQUIRED);
expect(decision.reason).toBe(JOB_AUTHORIZATION_REASON.MERGE_IS_OPERATOR_ONLY);
expect(decision.mayExecuteOnce).toBe(false);
expect(decision.permit).toBeNull();
});

it('does not let an auto-merge request reach ALLOW_ONCE', () => {
const decision = withPoisonedMapGet(() =>
authorizeJobOperation(
buildJob(),
buildEdit({ operation: FORBIDDEN_OPERATION.AUTO_MERGE_ENABLE }),
),
);

expect(decision.operation).toBe(FORBIDDEN_OPERATION.AUTO_MERGE_ENABLE);
expect(decision.decision).toBe(JOB_AUTHORIZATION.DENY);
expect(decision.reason).toBe(JOB_AUTHORIZATION_REASON.OPERATION_FORBIDDEN);
expect(decision.mayExecuteOnce).toBe(false);
expect(decision.permit).toBeNull();
});

it('does not let an unmodeled request reach ALLOW_ONCE', () => {
const decision = withPoisonedMapGet(() =>
authorizeJobOperation(buildJob(), buildEdit({ operation: 'shell.exec' })),
);

expect(decision.operation).toBe(UNKNOWN_JOB_OPERATION);
expect(decision.decision).toBe(JOB_AUTHORIZATION.DENY);
expect(decision.reason).toBe(JOB_AUTHORIZATION_REASON.OPERATION_UNKNOWN);
expect(decision.mayExecuteOnce).toBe(false);
expect(decision.permit).toBeNull();
});

it('still authorizes a legitimate source.edit under the same poisoning', () => {
// Fail-closed is not enough on its own: a repair that refused everything
// would satisfy every assertion above and break the boundary instead.
const baseline = authorizeJobOperation(buildJob(), buildEdit());
const poisoned = withPoisonedMapGet(() => authorizeJobOperation(buildJob(), buildEdit()));

expect(poisoned.decision).toBe(JOB_AUTHORIZATION.ALLOW_ONCE);
expect(poisoned.reason).toBe(JOB_AUTHORIZATION_REASON.WITHIN_JOB_ENVELOPE);
expect(poisoned.mayExecuteOnce).toBe(true);
expect(JSON.stringify(poisoned)).toBe(JSON.stringify(baseline));
});

it('restores Map.prototype.get afterwards', () => {
withPoisonedMapGet(() => undefined);

expect(new Map<string, string>([['k', 'v']]).get('k')).toBe('v');
});
});

/* -------------------------------------------------------------------------
* Cross-boundary conventions
* ------------------------------------------------------------------------- */
Expand Down
Loading