mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 15:04:37 +01:00
* pregate: starter policy, benign-finding allowlist, environment failures, per-run scratch
A repository with no .gstack/pregate.json now gets a warn (PREGATE_NO_POLICY)
with a starter policy printed, instead of PREGATE_NO_LANE on every touched
file; gstack-pregate init remains the explicit write. The policy gains an
allowlist for benign secrets/pii findings (env.value and secret never
allowlistable). Checks that cannot run because of the machine report
PREGATE_ENVIRONMENT (incomplete, with kind and evidence) apart from diff
failures. Every run writes its scratch under the state root, per run id.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* pregate: rename run-scratch module (strays rule) and map starter.ts to its test
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* pregate literals: match a module as a path fragment, require the constant name for a one-character value
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: pre-landing review fixes
guards: an exited guard is the machine's only on exit 127, never from its output;
secrets: clear an interrupted run's added-line copies before scanning;
environment: one fix table per kind, one ANSI strip, hoisted not-found vocabulary;
starter returns its lines; allowlistDigest reuses sha256; stale headers and the
PREGATE_INCOMPLETE fix clause updated; tests for the mixed verify branch, disk
precedence, allowlist boundaries, a positionless finding, head-policy strictness,
run-id edges and the unwritable state root.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* pregate: count a module's full file name without a directory as a consumer
consumersOf anchored both the stem and the full basename on a path
separator, so run('install.sh') or bash install.sh without a directory
stopped counting for a multi-character old value. Only the bare stem
needs the separator; the full file name is specific on its own.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* pregate: a diff cannot clear itself as an environment failure; a taken --run id gets its own directory
Adversarial review of the environment classifier and the scratch layout:
- exit 127 and 'command not found' are missing-binary only when the
command's own binary is off PATH (runner, guard and remote gate alike);
a script the diff controls that exits 127 is the diff's failure
- a local lane with no test summary is classified from its output only
when the runner was not seen starting a test file; a remote gate only
from its first 40 lines, before any test output could stream
- environment.proof records launch (certain) or output (inferred); an
output-inferred cause never says 'the diff is not at fault', its line
says '(inferred from output)', and verify names such checks
- a runner killed by a signal is PREGATE_INCOMPLETE, not missing-binary
- a --run id whose directory exists takes <id>-2 .. -9 instead of
sharing (and truncating) a live run's logs
- engine.* findings are never allowlistable; a pattern entry the scanner
gave no position for is reported as not tested instead of silently inert
- docs: synopsis --run, no-policy lanes wording, verify's code list,
guard exit 127 carve-out, PREGATE_SECRETS allowlist pointer, scratch
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* docs(pregate): PREGATE_INCOMPLETE names a signal-killed runner; CHANGELOG lists every doc edit
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
---------
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
76 lines
4.3 KiB
TypeScript
76 lines
4.3 KiB
TypeScript
/**
|
|
* pregate/allowlist — the policy's `allowlist` applied to the secrets check's
|
|
* findings (Lane D, GSTA-112; harness-evals #81/#83, GSTA-102, GSTA-106). An
|
|
* entry names a gstack-redact finding id or a non-secret category, and scopes
|
|
* it by path globs and/or a regular expression matched at the finding's
|
|
* position in the added line (anchored there, so a dummy token's shape never
|
|
* covers a real key that happens to share its line). `env.value`, the real
|
|
* value of a credential-named environment variable, is never consulted
|
|
* against the list: validation refuses an entry naming it, and this matcher
|
|
* keeps such a finding whatever the entries say (#3449).
|
|
*/
|
|
import { matchesAny } from '../ship-policy';
|
|
import { sha256 } from './context';
|
|
import { ALLOWLISTABLE_CATEGORIES, nonAllowlistable, type AllowlistEntry } from './config';
|
|
|
|
export interface AllowlistFinding {
|
|
id: string; category: string; severity: string; file: string; line?: number; col?: number;
|
|
/** The added line the finding sits on (for `pattern`); absent when the scanner gave no position. */
|
|
lineText?: string;
|
|
}
|
|
export interface Allowlisted<F extends AllowlistFinding = AllowlistFinding> { finding: F; index: number; entry: AllowlistEntry }
|
|
|
|
/** Whether `entry` names this finding: its id, or its category when the category may be named whole. */
|
|
function names(entry: AllowlistEntry, f: AllowlistFinding): boolean {
|
|
if (entry.finding === f.id) return true;
|
|
return (ALLOWLISTABLE_CATEGORIES as readonly string[]).includes(entry.finding) && entry.finding === f.category;
|
|
}
|
|
|
|
/** Whether a finding has the position a `pattern` needs: the added line and a 1-based column. */
|
|
const positioned = (f: AllowlistFinding) => f.lineText !== undefined && !!f.col;
|
|
|
|
/** The pattern, anchored at the finding's column in its added line and ending at a token boundary (so a short pattern never covers the head of a longer value); a finding with no position never matches a pattern. */
|
|
function patternHits(entry: AllowlistEntry, f: AllowlistFinding): boolean {
|
|
if (entry.pattern === undefined) return true;
|
|
if (!positioned(f)) return false;
|
|
let re: RegExp;
|
|
try { re = new RegExp(`^(?:${entry.pattern})(?![\\w-])`); } catch { return false; }
|
|
return re.test(f.lineText!.slice(f.col! - 1));
|
|
}
|
|
|
|
/**
|
|
* Split findings into the ones an entry covers (with the entry) and the ones
|
|
* kept; env.value and engine.* are always kept. `untested` lists kept findings
|
|
* that a pattern entry named by id and path but could not test because the
|
|
* scanner gave no position, so the table says why the entry was inert.
|
|
*/
|
|
export function applyAllowlist<F extends AllowlistFinding>(findings: readonly F[], entries: readonly AllowlistEntry[]): { kept: F[]; allowlisted: Allowlisted<F>[]; untested: Allowlisted<F>[] } {
|
|
const kept: F[] = [];
|
|
const allowlisted: Allowlisted<F>[] = [];
|
|
const untested: Allowlisted<F>[] = [];
|
|
for (const f of findings) {
|
|
const scoped = (e: AllowlistEntry) => names(e, f) && (!e.paths || matchesAny(e.paths, f.file));
|
|
const index = nonAllowlistable(f.id) ? -1 : entries.findIndex(e => scoped(e) && patternHits(e, f));
|
|
if (index >= 0) { allowlisted.push({ finding: f, index, entry: entries[index]! }); continue; }
|
|
kept.push(f);
|
|
if (nonAllowlistable(f.id) || positioned(f)) continue;
|
|
const named = entries.findIndex(e => e.pattern !== undefined && scoped(e));
|
|
if (named >= 0) untested.push({ finding: f, index: named, entry: entries[named]! });
|
|
}
|
|
return { kept, allowlisted, untested };
|
|
}
|
|
|
|
/** sha256 of the entries, for the check's `inputs.allowlist`. */
|
|
export const allowlistDigest = (entries: readonly AllowlistEntry[]) => sha256(JSON.stringify(entries));
|
|
|
|
/** One table line per finding a pattern entry named but could not test: the finding is kept. */
|
|
export function describeUntested(a: Allowlisted): string {
|
|
return `allowlist[${a.index}] ${a.entry.finding} not tested on ${a.finding.id} (${a.finding.file}): the scanner gave no position for its pattern; the finding is kept`;
|
|
}
|
|
|
|
/** One table line per allowlisted finding: what, where, and which entry (with its reason) covered it. */
|
|
export function describeAllowlisted(a: Allowlisted): string {
|
|
const where = a.finding.line ? ` (${a.finding.file}, added line ${a.finding.line})` : ` (${a.finding.file})`;
|
|
return `allowlisted ${a.finding.id}${where} by allowlist[${a.index}] ${a.entry.finding}: ${a.entry.reason}`;
|
|
}
|