mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 15:04:37 +01:00
* fix(upgrade): accept a Git Bash checkout whose toplevel Git spells C:/… bin/gstack-upgrade compared `git rev-parse --show-toplevel` (C:/x from Git for Windows) with `pwd -P` (/c/x in Git Bash) as strings, so every Windows Git Bash install was refused as "not a gstack checkout", and the doctor's recovery line points at this command. Compare the two through gstack_path_same (bin/gstack-path-spelling.sh); a tree nested inside another repository is still refused. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(redact): a catalog field named <schema word>_key is not a credential (#3208) env.kv accepted every separated name ending in _key, so a catalog entry `"input_key": "expected_media_rate_bits_per_second_ingress"` failed pregate's never-downgradable secrets check as MEDIUM env.kv [secret]. The camelCase twins (cacheKey, sortKey, partitionKey, idempotencyKey) were already rejected for having no credential prefix; the snake/kebab/dotted forms of those data-model words now are too. ALL-CAPS env names (INPUT_KEY=) and credential-word names (signing_key, stripe_key, input_secret) still fire. Reported by @brentyates-swx. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(cso): build the git-hardening fixture repo once per file Each test built its fixture repo with five /usr/bin/git spawns inside its own 5 s budget. On macOS that binary is the Xcode shim, and on a cold runner those spawns cost far more than the code under test: in run 38800592772 the first test timed out at 5 s before readGitMetadata got past its first lstat, and afterEach then tore the fixture down under it. Build the repo once in beforeAll, under the same 30 s bound each fixture spawn already declares, and give every test a fresh copy. The assertions and the code under test are unchanged, and each test keeps its full 5 s for the code under test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(browse): rule 3 treats wizard steps as writes and reads back sets before saving (#3216) On a non-local target a wizard or dialog step (Next, Step N, a menu item that opens a dialog) now counts as mutating until its handler or network effect shows otherwise, and its Cancel proves nothing. Before any Save or Submit, every programmatic set is read back and the flow stops on a mismatch. The cookbook's silent-failures list adds the skip-path precondition probe. Redundant clauses in rules 2, 3, 7, 8, 9, 10 and READY are tightened so the shared setup grows by 3 bytes and every browsing skill stays under its parity cap. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(plan-design-review): keep the Completion Summary out of the regenerated report (#3219) The design review saved its Completion Summary inside the terminal GSTACK REVIEW REPORT section, which the next plan review deletes from its heading to the next "## " heading. The summary, deferred list and unresolved decisions now go in their own "## Design Review Completion Summary" section above the report, and the template says everything under the report heading is regenerated by the next review. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(redact): exempt a schema field only when its name and value both say so (#3208) Review (GPT-6.1 Sol outside voice): the name-only exemption also silenced real secrets, e.g. Azure's cosmosdb_primary_key and any longer name such as api_cache_key. Now the whole name must be one data-model word plus _key (primary/secondary dropped) and the value two or more lowercase identifier words; an entropic value, a longer name or a credential name still fires in .env, YAML, JSON and TOML. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(upgrade): the Git shim delegates with the unshimmed PATH A PATH-searching git wrapper found the shim again and recursed until the test timed out. The shim now restores the host PATH before running git. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(pregate): secrets scans each file with its type and never reads an emptied diff as clean (#3211) addedLines() flattened every file into one added.txt, so gstack-redact never saw a .ts path and the #3048 source-expression exemption never ran: TS properties such as `token: credentials.token,` failed the gate as MEDIUM env.kv. Two false negatives predate this wave: the `+++` header filter dropped added content lines that begin with "++", and `git diff` honoured diff.external/textconv, so an external driver that prints nothing made a branch carrying a real AWS key pass with "no added lines". Added lines are now parsed hunk-aware per file (the pre-push hook's rules), with --no-ext-diff --no-textconv, and each file is scanned as <tmp>/<i>/<basename> so its type survives; findings name the file. Reported by @brentyates-swx. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(plan-devex-review): keep the DX Scorecard out of the regenerated report (#3219) The DX Scorecard had the same placement gap as the design Completion Summary: with no rule, it could land inside GSTACK REVIEW REPORT, which the next plan review deletes. It now goes in its own "## DX Review Scorecard" section above the report. Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * v1.91.109.0 fix: wave 5 — pregate secrets false negatives (#3211), Windows gstack-upgrade, env.kv schema keys (#3208), browse rule 3 (#3216), review summaries (#3219) 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> Co-authored-by: Scott Gigante <scott@gethubflow.ai> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
144 lines
9.5 KiB
TypeScript
144 lines
9.5 KiB
TypeScript
import { afterAll, afterEach, beforeAll, describe, expect, spyOn, test } from 'bun:test';
|
|
import * as fs from 'node:fs';
|
|
import * as os from 'node:os';
|
|
import * as path from 'node:path';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { capture } from '../lib/cso/snapshot';
|
|
import { childEnvironment, executable, git as readGitMetadata, runProcess } from '../lib/cso/process';
|
|
|
|
const roots:string[]=[];
|
|
afterEach(()=>{for(const root of roots.splice(0))fs.rmSync(root,{recursive:true,force:true});});
|
|
|
|
const fixtureGit=(repo:string,home:string)=>(...args:string[])=>{const result=spawnSync('/usr/bin/git',['-C',repo,...args],{encoding:'utf8',env:{HOME:home,PATH:'/usr/bin:/bin'},timeout:30_000});if(result.status)throw new Error(result.stderr);return result.stdout;};
|
|
let template='';
|
|
// The first /usr/bin/git spawns on a cold macOS runner can eat a test's whole 5 s budget before the code under test runs; pay them once here.
|
|
beforeAll(()=>{
|
|
template=fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(),'cso-git-hardening-template-')));
|
|
const repo=path.join(template,'repo'),git=fixtureGit(repo,template);fs.mkdirSync(repo);
|
|
git('init','-q');git('config','user.email','fixture@example.test');git('config','user.name','Fixture');
|
|
fs.writeFileSync(path.join(repo,'tracked.ts'),'export const tracked = true\n');git('add','tracked.ts');git('commit','-qm','base');
|
|
},{timeout:30_000});
|
|
afterAll(()=>fs.rmSync(template,{recursive:true,force:true}));
|
|
|
|
function fixture(){
|
|
const root=fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(),'cso-git-hardening-'))),repo=path.join(root,'repo'),runDir=path.join(root,'state','run');
|
|
roots.push(root);fs.cpSync(path.join(template,'repo'),repo,{recursive:true});fs.mkdirSync(runDir,{recursive:true,mode:0o700});
|
|
return{root,repo,runDir,git:fixtureGit(repo,root)};
|
|
}
|
|
|
|
function replaceWithSymlinkAfterLstat(target:string,replacement:string,occurrence=1){
|
|
const original=fs.lstatSync;let hits=0,swapped=false;
|
|
const patched=spyOn(fs,'lstatSync').mockImplementation(((candidate:any,options?:any)=>{
|
|
const result=options===undefined?original(candidate):original(candidate,options);
|
|
if(path.resolve(String(candidate))===target&&++hits===occurrence){
|
|
fs.renameSync(target,`${target}.parked`);fs.symlinkSync(replacement,target);swapped=true;
|
|
}
|
|
return result;
|
|
}) as typeof fs.lstatSync);
|
|
return{patched,wasSwapped:()=>swapped};
|
|
}
|
|
|
|
describe('CSO Git metadata hardening',()=>{
|
|
test.skipIf(process.platform==='win32')('reports a bounded operation and exit status without exposing Git argv or paths',async()=>{
|
|
const {root,repo}=fixture();let failure:any;
|
|
try{await readGitMetadata(repo,['rev-parse','--verify','secret-ref-name'],root);}catch(error){failure=error;}
|
|
expect(failure).toMatchObject({code:'MISSING_INPUT'});
|
|
expect(failure.message).toContain('rev-parse exited');
|
|
expect(failure.message).toContain('(request rejected)');
|
|
expect(failure.message).not.toContain(root);
|
|
expect(failure.message).not.toContain('secret-ref-name');
|
|
});
|
|
|
|
test.skipIf(process.platform==='win32')('labels the fixed object-format phase without exposing repository data',async()=>{
|
|
const {root,repo}=fixture();fs.appendFileSync(path.join(repo,'.git','config'),'\n[broken configuration\n');let failure:any;
|
|
try{await readGitMetadata(repo,['rev-parse','--show-object-format'],root);}catch(error){failure=error;}
|
|
expect(failure).toMatchObject({code:'MISSING_INPUT'});
|
|
expect(failure.message).toContain('object-format exited');
|
|
expect(failure.message).toContain('(configuration rejected)');
|
|
expect(failure.message).not.toContain(root);
|
|
});
|
|
|
|
test.skipIf(process.platform==='win32')('refuses trusted Git calls that are not bound to one audited worktree',async()=>{
|
|
const {root}=fixture();
|
|
await expect(runProcess(executable('git'),['rev-parse','--is-inside-work-tree'],{cwd:root,env:childEnvironment(root),raw:true})).rejects.toMatchObject({code:'INVALID_ARGUMENT'});
|
|
});
|
|
|
|
test.skipIf(process.platform==='win32')('pins every metadata read to the audited worktree and ignores configured global excludes',async()=>{
|
|
const {root,repo,runDir,git}=fixture(),empty=path.join(root,'decoy-worktree'),excludes=path.join(root,'global-excludes');
|
|
fs.mkdirSync(empty);fs.writeFileSync(excludes,'untracked-security.ts\n');
|
|
fs.writeFileSync(path.join(repo,'untracked-security.ts'),'export const vulnerable = true\n');
|
|
const caseSensitive=!fs.existsSync(path.join(repo,'TRACKED.ts'));
|
|
if(caseSensitive)fs.writeFileSync(path.join(repo,'TRACKED.ts'),'export const caseVariant = true\n');
|
|
git('config','core.worktree',empty);git('config','core.excludesFile',excludes);git('config','core.ignoreCase','true');git('config','core.precomposeUnicode','true');
|
|
|
|
const manifest=await capture(repo,runDir,'HEAD');
|
|
|
|
expect(manifest.entries.map(entry=>entry.path)).toContain('untracked-security.ts');
|
|
if(caseSensitive)expect(manifest.entries.map(entry=>entry.path)).toContain('TRACKED.ts');
|
|
expect(fs.readFileSync(path.join(runDir,'snapshot','untracked-security.ts'),'utf8')).toContain('vulnerable');
|
|
expect(manifest.changedPaths).toContain('untracked-security.ts');
|
|
});
|
|
|
|
test.skipIf(process.platform==='win32')('ignores replacement refs when retaining tracked deletions and history',async()=>{
|
|
const {repo,runDir,git}=fixture(),head=git('rev-parse','HEAD').trim(),emptyTree=git('mktree').trim(),replacement=git('commit-tree',emptyTree,'-m','replacement-history').trim();
|
|
git('replace',head,replacement);git('rm','-q','-f','tracked.ts');
|
|
|
|
const manifest=await capture(repo,runDir);
|
|
const history=fs.readFileSync(path.join(runDir,'history.txt'),'utf8');
|
|
|
|
expect(manifest.headCommit).toBe(head);
|
|
expect(manifest.deletedPaths).toEqual([{path:'tracked.ts',pathId:expect.stringMatching(/^[a-f0-9]{32}$/)}]);
|
|
expect(history).toContain('Subject: base');
|
|
expect(history).not.toContain('replacement-history');
|
|
});
|
|
|
|
test.skipIf(process.platform==='win32')('rejects repository config includes before Git can consume them',async()=>{
|
|
const {root,repo,runDir}=fixture(),included=path.join(root,'included.conf');
|
|
fs.writeFileSync(included,'[core]\n\tworktree = /tmp/cso-decoy\n');
|
|
fs.appendFileSync(path.join(repo,'.git','config'),`\n[include]\n\tpath = ${included}\n`);
|
|
|
|
await expect(capture(repo,runDir)).rejects.toMatchObject({code:'UNSAFE_PATH',message:'Repository Git config includes are not allowed during a security snapshot'});
|
|
});
|
|
|
|
test.skipIf(process.platform==='win32')('binds an absent main-worktree config so it cannot appear after inspection',async()=>{
|
|
const {root,repo,git}=fixture(),worktreeConfig=path.join(repo,'.git','config.worktree'),lstat=fs.lstatSync;
|
|
git('config','extensions.worktreeConfig','true');let injected=false;
|
|
const patched=spyOn(fs,'lstatSync').mockImplementation(((candidate:any,options?:any)=>{
|
|
try{return options===undefined?lstat(candidate):lstat(candidate,options);}catch(error:any){
|
|
if(!injected&&path.resolve(String(candidate))===worktreeConfig&&error?.code==='ENOENT'){
|
|
fs.writeFileSync(worktreeConfig,'[cso]\n\tmarker = created-after-inspection\n');injected=true;
|
|
}
|
|
throw error;
|
|
}
|
|
}) as typeof fs.lstatSync);
|
|
try{await expect(runProcess(executable('git'),['--no-optional-locks','-C',repo,'rev-parse','--is-inside-work-tree'],{cwd:root,env:childEnvironment(root),raw:true})).rejects.toMatchObject({code:'SNAPSHOT_RACE'});}finally{patched.mockRestore();}
|
|
expect(injected).toBe(true);
|
|
});
|
|
|
|
test.skipIf(process.platform==='win32')('does not follow a repository config swapped after its bounded lstat',async()=>{
|
|
const {root,repo,runDir}=fixture(),config=path.join(repo,'.git','config'),oversized=path.join(root,'oversized-config');
|
|
fs.writeFileSync(oversized,'[core]\n'+'.'.repeat(1024*1024));
|
|
const race=replaceWithSymlinkAfterLstat(config,oversized);
|
|
try{await expect(capture(repo,runDir)).rejects.toMatchObject({code:'SNAPSHOT_RACE'});}finally{race.patched.mockRestore();}
|
|
expect(race.wasSwapped()).toBe(true);
|
|
});
|
|
|
|
test.skipIf(process.platform==='win32')('does not follow a worktree .git pointer swapped between lstat and open',async()=>{
|
|
const {root,repo,runDir}=fixture(),gitDir=path.join(root,'git-data'),marker=path.join(repo,'.git'),oversized=path.join(root,'oversized-git-pointer');
|
|
fs.renameSync(marker,gitDir);fs.writeFileSync(marker,'gitdir: ../git-data\n');fs.writeFileSync(oversized,'gitdir: '+'.'.repeat(16*1024));
|
|
const race=replaceWithSymlinkAfterLstat(marker,oversized);
|
|
try{await expect(capture(repo,runDir)).rejects.toMatchObject({code:'SNAPSHOT_RACE'});}finally{race.patched.mockRestore();}
|
|
expect(race.wasSwapped()).toBe(true);
|
|
});
|
|
|
|
test.skipIf(process.platform==='win32')('does not follow a linked-worktree commondir pointer swapped after lstat',async()=>{
|
|
const {root,repo,git}=fixture(),linked=path.join(root,'linked');
|
|
git('worktree','add','-q','-b','linked-security-test',linked);
|
|
const marker=fs.readFileSync(path.join(linked,'.git'),'utf8').trim().replace(/^gitdir:\s*/,''),gitDir=fs.realpathSync(path.resolve(linked,marker)),common=path.join(gitDir,'commondir'),oversized=path.join(root,'oversized-commondir');
|
|
fs.writeFileSync(oversized,'.'.repeat(16*1024));
|
|
const race=replaceWithSymlinkAfterLstat(common,oversized);
|
|
try{await expect(runProcess(executable('git'),['--no-optional-locks','-C',linked,'rev-parse','--is-inside-work-tree'],{cwd:root,env:childEnvironment(root),raw:true})).rejects.toMatchObject({code:'SNAPSHOT_RACE'});}finally{race.patched.mockRestore();}
|
|
expect(race.wasSwapped()).toBe(true);
|
|
});
|
|
});
|