mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-27 07:01:54 +02:00
v1.88.1.0 fix: harden credential boundaries and owned state (#2942)
* fix(settings): preserve symlinked settings targets
Resolve the selected target for locking, mutation, backup, and rollback; refuse target changes and preserve private modes. Addresses #2830.
* fix(redact): bind masking to original detected spans
Inspired by #2929's anchored-span diagnosis; independently implemented using normalization offsets. Addresses #2930 and the relocation portion of #2912 without changing detection sensitivity.
* fix(evals): exclude operator credentials from prefix admission
Adapts the credential-suffix screen proposed in #2636, with real launched-child regression coverage and deliberate provider-auth exceptions.
* fix(artifacts): retain custom allowlist rules on reinitialization
Preserve the exact user-owned suffix and publish only a successfully assembled replacement. Independently implements the repair reported in #2907.
* test(cso): verify exact masked reads and unmaskable payload refusal
* fix(cso): preserve exact filesystem identities through lease recovery
Preserve 64-bit device/inode identity and nanosecond race checks. Add native NTFS lifecycle coverage for #2927; retain ambiguous legacy-state refusal without claiming Windows PID-reuse recovery is resolved.
* fix(redact): bind pre-push scans to destination and preserve seam context
Uses #2935 (bd07318) as source evidence for push-target range and slice-overlap defects. Independently implemented; no cherry-pick or release metadata adoption.
* test(ci): gate native agent ownership and settings links on macOS
* fix(browse): bind agent lifetimes and cleanup to owned generations
Uses #2931 by Chris Hutton / Claude Fable 5.1 as attributed design input; independently implemented without broad sweeps or copied code. Keep uncertain children and locks rather than deleting foreign state.
* test(ci): include concurrent shutdown controls in the native macOS gate
* v1.88.1.0 fix: harden credential boundaries and owned state
* fix(redact): preserve target provenance and scan boundary semantics
* test(artifacts): read managed rules from atomic allowlist assembly
* fix: preserve native exit observations and fixture prerequisites
* fix: preserve UTF-16 offsets through redaction normalization
This commit is contained in:
+31
-31
@@ -121,6 +121,10 @@ export function normalizeWithMap(input: string): {
|
||||
normalized: string;
|
||||
map: number[];
|
||||
} {
|
||||
return normalizeOriginal(input);
|
||||
}
|
||||
|
||||
function normalizeOriginal(input: string, spanEnds?: number[]): { normalized: string; map: number[] } {
|
||||
const out: string[] = [];
|
||||
const map: number[] = [];
|
||||
let i = 0;
|
||||
@@ -133,6 +137,7 @@ export function normalizeWithMap(input: string): {
|
||||
for (const ch of rep) {
|
||||
out.push(ch);
|
||||
map.push(i);
|
||||
spanEnds?.push(i + ent.length);
|
||||
}
|
||||
i += ent.length;
|
||||
matchedEntity = true;
|
||||
@@ -150,9 +155,10 @@ export function normalizeWithMap(input: string): {
|
||||
ZERO_WIDTH.lastIndex = 0;
|
||||
|
||||
const norm = ch.normalize("NFKC");
|
||||
for (const nch of norm) {
|
||||
out.push(nch);
|
||||
for (let j = 0; j < norm.length; j++) {
|
||||
out.push(norm[j]);
|
||||
map.push(i);
|
||||
spanEnds?.push(i + 1);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
@@ -327,6 +333,12 @@ function emailAllowed(
|
||||
// ── The scan ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export function scan(input: string, opts: ScanOptions = {}): ScanResult {
|
||||
return scanInternal(input, opts);
|
||||
}
|
||||
|
||||
type OriginalSpan = { start: number; end: number };
|
||||
|
||||
function scanInternal(input: string, opts: ScanOptions, spans?: Map<Finding, OriginalSpan>): ScanResult {
|
||||
const repoVisibility: RepoVisibility = opts.repoVisibility ?? "unknown";
|
||||
let starts: number[] | null = null; // line index, built on the first finding
|
||||
// #1824: ?? only catches null/undefined, not NaN or <= 0. A bad value
|
||||
@@ -363,7 +375,8 @@ export function scan(input: string, opts: ScanOptions = {}): ScanResult {
|
||||
};
|
||||
}
|
||||
|
||||
const { normalized, map } = normalizeWithMap(input);
|
||||
const spanEnds: number[] | undefined = spans ? [] : undefined;
|
||||
const { normalized, map } = normalizeOriginal(input, spanEnds);
|
||||
const fenceRanges = toolFenceRanges(normalized);
|
||||
const allow = new Set(opts.allowlist ?? []);
|
||||
|
||||
@@ -379,8 +392,7 @@ export function scan(input: string, opts: ScanOptions = {}): ScanResult {
|
||||
if (m.index === re.lastIndex) re.lastIndex++;
|
||||
|
||||
const span = m[1] ?? m[0];
|
||||
const spanStartInMatch = m[1] !== undefined ? m[0].indexOf(m[1]) : 0;
|
||||
const normOffset = m.index + Math.max(0, spanStartInMatch);
|
||||
const normOffset = m.indices?.[1]?.[0] ?? m.index;
|
||||
|
||||
// Per-span placeholder suppression.
|
||||
if (isPlaceholderSpan(span)) continue;
|
||||
@@ -421,7 +433,7 @@ export function scan(input: string, opts: ScanOptions = {}): ScanResult {
|
||||
toolFenceDegraded = true;
|
||||
}
|
||||
|
||||
findings.push({
|
||||
const finding: Finding = {
|
||||
id: pat.id,
|
||||
tier: pat.tier,
|
||||
severity,
|
||||
@@ -433,7 +445,12 @@ export function scan(input: string, opts: ScanOptions = {}): ScanResult {
|
||||
autoRedactable: !!pat.autoRedactable,
|
||||
repoVisibility,
|
||||
...(toolFenceDegraded ? { toolFenceDegraded } : {}),
|
||||
});
|
||||
};
|
||||
findings.push(finding);
|
||||
if (spans) {
|
||||
const end = spanEnds?.[normOffset + span.length - 1];
|
||||
if (end !== undefined && end > origOffset) spans.set(finding, { start: origOffset, end });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -450,6 +467,7 @@ function withFlags(flags: string): string {
|
||||
let f = flags;
|
||||
if (!f.includes("g")) f += "g";
|
||||
if (!f.includes("m")) f += "m";
|
||||
if (!f.includes("d")) f += "d";
|
||||
return f;
|
||||
}
|
||||
|
||||
@@ -475,10 +493,11 @@ export function applyRedactions(
|
||||
opts: ScanOptions = {},
|
||||
): RedactResult {
|
||||
const ids = new Set(findingIds);
|
||||
const { findings } = scan(input, opts);
|
||||
const spans = new Map<Finding, OriginalSpan>();
|
||||
const { findings } = scanInternal(input, opts, spans);
|
||||
const targets = findings
|
||||
.filter((f) => ids.has(f.id) && f.autoRedactable)
|
||||
.map((f) => ({ f, ...locateSpan(input, f) }))
|
||||
.map((f) => ({ f, ...(spans.get(f) ?? { start: -1, end: -1 }) }))
|
||||
.filter((t) => t.start >= 0);
|
||||
|
||||
// Right-to-left so earlier offsets remain valid after splicing.
|
||||
@@ -528,9 +547,10 @@ const MARKER_ONLY_PATTERN_IDS = new Set(["pem.private_key", "gcp.service_account
|
||||
* structure-preserving path.)
|
||||
*/
|
||||
export function redactFindingSpans(input: string, opts: ScanOptions = {}): string | null {
|
||||
const { findings } = scan(input, opts);
|
||||
const spans = new Map<Finding, OriginalSpan>();
|
||||
const { findings } = scanInternal(input, opts, spans);
|
||||
if (findings.some((f) => MARKER_ONLY_PATTERN_IDS.has(f.id))) return null;
|
||||
const targets = findings.map((f) => ({ f, ...locateSpan(input, f) }));
|
||||
const targets = findings.map((f) => ({ f, ...(spans.get(f) ?? { start: -1, end: -1 }) }));
|
||||
if (targets.some((t) => t.start < 0)) return null;
|
||||
|
||||
// Coalesce overlapping/touching ranges — splicing two intersecting spans
|
||||
@@ -557,26 +577,6 @@ export function redactFindingSpans(input: string, opts: ScanOptions = {}): strin
|
||||
return body;
|
||||
}
|
||||
|
||||
function locateSpan(input: string, f: Finding): { start: number; end: number } {
|
||||
// Re-derive the offset from line/col on the original text.
|
||||
let offset = 0;
|
||||
let line = 1;
|
||||
while (line < f.line && offset < input.length) {
|
||||
if (input[offset] === "\n") line++;
|
||||
offset++;
|
||||
}
|
||||
offset += f.col - 1;
|
||||
const pat = PATTERNS_BY_ID[f.id];
|
||||
if (!pat) return { start: -1, end: -1 };
|
||||
const re = new RegExp(pat.regex.source, withFlags(pat.regex.flags));
|
||||
re.lastIndex = Math.max(0, offset - 2);
|
||||
const m = re.exec(input);
|
||||
if (!m) return { start: -1, end: -1 };
|
||||
const span = m[1] ?? m[0];
|
||||
const start = m.index + (m[1] !== undefined ? m[0].indexOf(m[1]) : 0);
|
||||
return { start, end: start + span.length };
|
||||
}
|
||||
|
||||
function inStructuralToken(body: string, start: number, end: number): boolean {
|
||||
// Markdown link target: [text](...span...). The span may sit anywhere inside
|
||||
// the parenthesized target (e.g. an email embedded in a URL). Walk backward
|
||||
|
||||
Reference in New Issue
Block a user