Files
64fb6ecaf8 v1.92.8.0 fix(redact): env.kv reads Python, Dart and JSX source syntax as code (#3630) (#3742)
* fix(redact): env.kv reads Python and Dart type names and variable reads as code (#3630)

Extends the #3048 source-syntax exemption to .dart/.py/.pyi and JSX braces
joined by ?? || &&, with a Dart/Python-only triple-quote guard. pregate scans
changed Python/Dart files whole and keeps added-line findings, and the
allowlist names a pattern entry that missed its finding.

Co-authored-by: Adam Balazs <4034736+adam-balazs@users.noreply.github.com>
Co-authored-by: Hamish Barney <1049017+hbarney@users.noreply.github.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(redact): triple-quote spans skip comments and one-line strings; prepush and large-file fallback scan Python/Dart without the exemption (#3630)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(pregate): untracked Python/Dart files keep the source exemption; allowlist rows name file lines for whole-file scans (#3630)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(pregate): a whole-file Python/Dart scan falls back to added lines when the working copy differs from the diff; count added lines, not file lines (#3630)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(redact): one string-span pass for Python/Dart (block comments, raw strings, continuations); a literal fallback after a name still reports (#3630)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(redact): a name whose expression continues on the next line still reports; allowlist miss rows only for patterns written from the name; clear the span cache after each scan (#3630)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(redact): a trailing comment is not part of a name's expression (#3630)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(pregate): Python keyword form still reports; the pre-push hook scans Python/Dart without the exemption (#3630)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: Adam Balazs <4034736+adam-balazs@users.noreply.github.com>
Co-authored-by: Hamish Barney <1049017+hbarney@users.noreply.github.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
2026-11-01 01:50:36 +00:00

91 lines
5.5 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;
/** `line` numbers the whole file (a Dart or Python file scanned whole, #3630), not the added lines. */
fileLine?: boolean;
}
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));
}
function matchesLine(pattern: string, line: string): boolean {
try { return new RegExp(pattern).test(line); } catch { return false; }
}
/**
* 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;
* `missed` lists kept findings that a pattern entry named by id and path
* whose pattern matches the finding's line but not at its position (#3630:
* the entry was written from the name, not the value).
*/
export function applyAllowlist<F extends AllowlistFinding>(findings: readonly F[], entries: readonly AllowlistEntry[]): { kept: F[]; allowlisted: Allowlisted<F>[]; untested: Allowlisted<F>[]; missed: Allowlisted<F>[] } {
const kept: F[] = [];
const allowlisted: Allowlisted<F>[] = [];
const untested: Allowlisted<F>[] = [];
const missed: 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)) continue;
const named = entries.findIndex(e => e.pattern !== undefined && scoped(e) && (!positioned(f) || matchesLine(e.pattern, f.lineText!)));
if (named >= 0) (positioned(f) ? missed : untested).push({ finding: f, index: named, entry: entries[named]! });
}
return { kept, allowlisted, untested, missed };
}
/** 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 finding a pattern entry named but did not match: the pattern is tested from where the flagged value starts, never the name before it. */
export function describeMissed(a: Allowlisted): string {
return `allowlist[${a.index}] ${a.entry.finding} pattern did not match ${a.finding.id} (${a.finding.file}${a.finding.line ? `, ${a.finding.fileLine ? 'line' : 'added line'} ${a.finding.line}` : ''}, col ${a.finding.col}): a pattern is matched from where the flagged value starts, not from the name; 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}, ${a.finding.fileLine ? 'line' : 'added line'} ${a.finding.line})` : ` (${a.finding.file})`;
return `allowlisted ${a.finding.id}${where} by allowlist[${a.index}] ${a.entry.finding}: ${a.entry.reason}`;
}