mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 14:34:42 +01:00
* 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>
91 lines
5.5 KiB
TypeScript
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}`;
|
|
}
|