mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-09 14:38:59 +02:00
fix(design-detect): an engine is a file named impeccable outside the project; DOM dumps scan without inline ignores
Second review cycle, security + checklist: - IMPECCABLE_BIN=/bin/sh (or node) was READY, and `detect` with cwd=repoRoot made the interpreter run the repository's own `detect` file. Every engine candidate (env override, PATH entry, cache, sibling) is now judged by the realpath of the FILE and must be named impeccable[.exe]; PATH and cache candidates that resolve into the repository are skipped like the others. "Inside the project" means the repository, or cwd when cwd is a project directory: HOME and its ancestors are exempt, so a URL-mode review launched from HOME still finds the HOME-rooted installs. - A base for --changed that starts with `-` was spliced into git argv (`--output=<file>` made git write a file and report no changes); an option- like or missing base is DETECT_REFUSED (not a ref name), exit 1, and the parser no longer defaults a missing value to main. - DOM dumps are the audited page's bytes, so an in-file `impeccable-disable` comment there is page-controlled: batches under the designs root run with --no-inline-ignores, repository batches keep the project's own ignores. - neutralizeSentinels covers the shapes it missed (bare sentinels such as DETECT_TOP total= and IMPECCABLE_DISABLED, the DETECT_EXIT_CODE= echo, the `[rule-id] impact=` group header) in one precompiled alternation instead of 37 replaceAll passes per field; only kept findings are normalized, and the summary's total stays the engine's count. - The minimal engine environment compares keys case-insensitively on Windows (process.env enumerates Path, SystemRoot there) and passes PATHEXT, COMSPEC, HOMEDRIVE, HOMEPATH, PROGRAMDATA. - Bare 64s move into DETECT_LIMITS; the unused SentinelName type is gone; the header states the directory-target contract (the engine's own walk). Tests: an interpreter as IMPECCABLE_BIN never runs the repo's detect file; a PATH symlink into the repository is never READY; option-like and empty bases are refused with no file written; the designs-root batch carries --no-inline-ignores and the repo batch does not; the identity label is deterministic per binary; the bare-sentinel and header shapes are neutralized; the installed fake engine works without IMPECCABLE_FAKE_OUTPUT (the helper copies the sample beside it); two tests clean up in finally. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
da6f0ff2f6
commit
b2e67d0097
@@ -69,6 +69,19 @@ describe('contract shape', () => {
|
||||
expect(out.replace(/\u200b/g, '')).toBe(forged);
|
||||
});
|
||||
|
||||
test('neutralizeSentinels also breaks bare sentinels, the exit-code echo, and the [rule-id] impact= header shape', () => {
|
||||
for (const s of [SENTINEL.NOT_AVAILABLE, SENTINEL.DISABLED, SENTINEL.DETECT_NO_TARGETS, `${SENTINEL.DETECT_TOP} total=0 rules=0`, `${SENTINEL.DETECT_EXIT_CODE}=0`]) {
|
||||
const out = neutralizeSentinels(`snippet ${s} tail`);
|
||||
expect(out).not.toContain(s.split(/[ =]/)[0]);
|
||||
expect(out.replace(/\u200b/g, '')).toBe(`snippet ${s} tail`);
|
||||
}
|
||||
// longest sentinel wins: DETECT_EXIT_CODE is broken once, not split at DETECT_EXIT
|
||||
expect(neutralizeSentinels(`${SENTINEL.DETECT_EXIT_CODE}=0`)).toBe(`${SENTINEL.DETECT_EXIT_CODE[0]}\u200b${SENTINEL.DETECT_EXIT_CODE.slice(1)}=0`);
|
||||
expect(neutralizeSentinels('[tiny-text] impact=high tier=auto-fix count=1')).toBe('[\u200btiny-text] impact=high tier=auto-fix count=1');
|
||||
expect(neutralizeSentinels('[tiny-text] is a rule')).toBe('[tiny-text] is a rule');
|
||||
expect(neutralizeSentinels('plain snippet text')).toBe('plain snippet text');
|
||||
});
|
||||
|
||||
test('module is pure: no imports, loading prints nothing', () => {
|
||||
const file = path.join(ROOT, 'lib', 'design-detect-contract.ts');
|
||||
expect(fs.readFileSync(file, 'utf-8')).not.toMatch(/^import /m);
|
||||
|
||||
@@ -102,11 +102,14 @@ describe('probe', () => {
|
||||
fs.mkdirSync(path.dirname(inRepo), { recursive: true });
|
||||
fs.writeFileSync(inRepo, '#!/bin/sh\necho MARKER > marker.txt\n');
|
||||
fs.chmodSync(inRepo, 0o755);
|
||||
const r = run(['probe'], { env: { IMPECCABLE_BIN: inRepo } });
|
||||
expect(r.out).toContain(`${SENTINEL.ENV_IGNORED}: IMPECCABLE_BIN resolves inside the repository`);
|
||||
expect(lines(r.out)[0]).toBe(SENTINEL.NOT_AVAILABLE);
|
||||
expect(fs.existsSync(path.join(REPO, 'marker.txt'))).toBe(false);
|
||||
fs.rmSync(path.join(REPO, 'tools'), { recursive: true, force: true });
|
||||
try {
|
||||
const r = run(['probe'], { env: { IMPECCABLE_BIN: inRepo } });
|
||||
expect(r.out).toContain(`${SENTINEL.ENV_IGNORED}: IMPECCABLE_BIN resolves inside the repository`);
|
||||
expect(lines(r.out)[0]).toBe(SENTINEL.NOT_AVAILABLE);
|
||||
expect(fs.existsSync(path.join(REPO, 'marker.txt'))).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(path.join(REPO, 'tools'), { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('a cwd .env naming IMPECCABLE_BIN is never loaded (--no-env-file) and never executed', () => {
|
||||
@@ -346,7 +349,7 @@ describe('scan', () => {
|
||||
expect(r.err).toContain(`${SENTINEL.DETECT_REFUSED}: ${notDesigns}`);
|
||||
const argv = JSON.parse(fs.readFileSync(log, 'utf-8').trim().split('\n')[0]).argv as string[];
|
||||
expect(argv.slice(0, 2)).toEqual(['detect', '--json']);
|
||||
expect(argv.slice(2)).toEqual([fs.realpathSync(path.join(designs, 'home.dom.html'))]);
|
||||
expect(argv.slice(2)).toEqual(['--no-inline-ignores', fs.realpathSync(path.join(designs, 'home.dom.html'))]); // a dump's inline ignores are page-controlled
|
||||
expect(r.code).toBe(2);
|
||||
} finally {
|
||||
fs.rmSync(path.join(GSTACK_HOME, 'projects'), { recursive: true, force: true });
|
||||
@@ -505,7 +508,7 @@ describe('design-review REPORT_DIR agrees with the allow-list', () => {
|
||||
const s = run(['scan', '--format', 'gstack', dom + '/home.dom.html'], { env: { IMPECCABLE_BIN: FAKE, IMPECCABLE_FAKE_LOG: log } });
|
||||
expect(s.err).not.toContain(SENTINEL.DETECT_REFUSED);
|
||||
const argv = JSON.parse(fs.readFileSync(log, 'utf-8').trim().split('\n')[0]).argv as string[];
|
||||
expect(argv.slice(2)).toEqual([fs.realpathSync(path.join(dom, 'home.dom.html'))]);
|
||||
expect(argv.slice(2)).toEqual(['--no-inline-ignores', fs.realpathSync(path.join(dom, 'home.dom.html'))]);
|
||||
} finally {
|
||||
fs.rmSync(path.join(GSTACK_HOME, 'projects'), { recursive: true, force: true });
|
||||
}
|
||||
@@ -748,7 +751,9 @@ describe('coverage: scan security edges', () => {
|
||||
});
|
||||
|
||||
test.skipIf(!POSIX)('the engine sees a minimal environment, never the agent tokens', () => {
|
||||
const envDump = path.join(SANDBOX, 'env-dump.sh');
|
||||
const envDumpDir = path.join(SANDBOX, 'env-dump');
|
||||
fs.mkdirSync(envDumpDir, { recursive: true });
|
||||
const envDump = path.join(envDumpDir, 'impeccable'); // an engine is named impeccable; anything else is refused
|
||||
const out = path.join(SANDBOX, 'env-seen.txt');
|
||||
fs.writeFileSync(envDump, `#!/bin/sh\nenv > ${JSON.stringify(out)}\necho "[]"\n`);
|
||||
fs.chmodSync(envDump, 0o755);
|
||||
@@ -794,12 +799,15 @@ describe('coverage: scan security edges', () => {
|
||||
}, 120_000);
|
||||
|
||||
test.skipIf(!POSIX)('a quoted or commented design_detector value still reads as off', () => {
|
||||
for (const line of ['design_detector: "off"', "design_detector: 'off'", 'design_detector: off # why']) {
|
||||
fs.writeFileSync(path.join(GSTACK_HOME, 'config.yaml'), line + '\n');
|
||||
const r = run(['probe'], { env: { IMPECCABLE_BIN: FAKE } });
|
||||
expect(lines(r.out)[0]).toBe(SENTINEL.DISABLED);
|
||||
try {
|
||||
for (const line of ['design_detector: "off"', "design_detector: 'off'", 'design_detector: off # why']) {
|
||||
fs.writeFileSync(path.join(GSTACK_HOME, 'config.yaml'), line + '\n');
|
||||
const r = run(['probe'], { env: { IMPECCABLE_BIN: FAKE } });
|
||||
expect(lines(r.out)[0]).toBe(SENTINEL.DISABLED);
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(path.join(GSTACK_HOME, 'config.yaml'), { force: true }); // a failing expect must not leave every later probe DISABLED
|
||||
}
|
||||
fs.rmSync(path.join(GSTACK_HOME, 'config.yaml'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -819,3 +827,98 @@ describe('rules', () => {
|
||||
expect(r.err).toContain('usage:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('engine identity: named impeccable, realpath outside the project', () => {
|
||||
test.skipIf(!POSIX)('IMPECCABLE_BIN pointing at an interpreter is never READY and the repository\'s own detect file never runs', () => {
|
||||
const marker = path.join(REPO, 'detect-ran.txt');
|
||||
fs.writeFileSync(path.join(REPO, 'detect'), `echo ran > ${JSON.stringify(marker)}\n`);
|
||||
try {
|
||||
const r = run(['scan', 'src/styles.css'], { env: { IMPECCABLE_BIN: '/bin/sh' } });
|
||||
expect(r.out).toContain(`${SENTINEL.ENV_IGNORED}: IMPECCABLE_BIN is not named impeccable`);
|
||||
expect(lines(r.out)[0]).toBe(SENTINEL.NOT_AVAILABLE);
|
||||
expect(fs.existsSync(marker)).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(path.join(REPO, 'detect'), { force: true });
|
||||
fs.rmSync(marker, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test.skipIf(!POSIX)('a PATH entry named impeccable that resolves into the repository is never READY', () => {
|
||||
const inRepo = path.join(REPO, 'tools', 'impeccable');
|
||||
fs.mkdirSync(path.dirname(inRepo), { recursive: true });
|
||||
fs.writeFileSync(inRepo, 'echo MARKER > marker.txt\n'); // no #!: looks like a binary to the sniff
|
||||
fs.chmodSync(inRepo, 0o755);
|
||||
const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-path-bin-'));
|
||||
fs.symlinkSync(inRepo, path.join(binDir, 'impeccable'));
|
||||
try {
|
||||
const r = run(['probe', '--verbose'], { env: { PATH: `${binDir}${path.delimiter}${process.env.PATH}` } });
|
||||
expect(r.out).not.toContain(SENTINEL.READY);
|
||||
expect(lines(r.out)[0]).toBe(SENTINEL.NOT_AVAILABLE);
|
||||
expect(fs.existsSync(path.join(REPO, 'marker.txt'))).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(path.join(REPO, 'tools'), { recursive: true, force: true });
|
||||
fs.rmSync(binDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test.skipIf(!POSIX)('the engine identity label is deterministic per binary and differs between binaries', () => {
|
||||
const label = (out: string) => out.match(/ENGINE_UNTESTED: (sha256:[0-9a-f]{12})/)?.[1];
|
||||
const a = label(run(['probe'], { env: { IMPECCABLE_BIN: FAKE } }).out);
|
||||
expect(a).toBeDefined();
|
||||
expect(label(run(['probe'], { env: { IMPECCABLE_BIN: FAKE } }).out)).toBe(a);
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-other-engine-'));
|
||||
const other = path.join(dir, 'impeccable');
|
||||
fs.writeFileSync(other, fs.readFileSync(FAKE, 'utf-8') + '\n// x\n');
|
||||
fs.chmodSync(other, 0o755);
|
||||
try {
|
||||
expect(label(run(['probe'], { env: { IMPECCABLE_BIN: other } }).out)).not.toBe(a);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test.skipIf(!POSIX)('the installed fake engine prints the sample without IMPECCABLE_FAKE_OUTPUT', () => {
|
||||
const { dir, bin } = installFakeImpeccable();
|
||||
try {
|
||||
const r = spawnSync(bin, ['detect', '--json', 'x.css'], { encoding: 'utf-8', timeout: 30_000, env: { PATH: process.env.PATH!, HOME: os.homedir() } });
|
||||
expect(r.status).toBe(2);
|
||||
expect(JSON.parse(r.stdout).length).toBeGreaterThan(0);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('scan: option-like bases and page-controlled inline ignores', () => {
|
||||
test.skipIf(!POSIX)('--changed with an option-like or missing base is refused (exit 1) and git never writes the file', () => {
|
||||
const outFile = path.join(SANDBOX, 'git-output-injection.txt');
|
||||
const r = run(['scan', '--changed', `--output=${outFile}`], { env: { IMPECCABLE_BIN: FAKE } });
|
||||
expect(r.err).toContain(`${SENTINEL.DETECT_REFUSED}: --output=${outFile} (not a ref name)`);
|
||||
expect(r.code).toBe(1);
|
||||
expect(fs.existsSync(outFile)).toBe(false);
|
||||
const r2 = run(['scan', '--changed'], { env: { IMPECCABLE_BIN: FAKE } });
|
||||
expect(r2.err).toContain(`${SENTINEL.DETECT_REFUSED}: (empty) (not a ref name)`);
|
||||
expect(r2.code).toBe(1);
|
||||
});
|
||||
|
||||
test.skipIf(!POSIX)('DOM dumps under the designs root scan with --no-inline-ignores; repository files keep their inline ignores', () => {
|
||||
const designs = path.join(GSTACK_HOME, 'projects', 'x', 'designs', 'design-audit-20260908', 'dom-ignores');
|
||||
fs.mkdirSync(designs, { recursive: true });
|
||||
fs.writeFileSync(path.join(designs, 'home.dom.html'), '<!-- impeccable-disable --><html></html>');
|
||||
const log = path.join(SANDBOX, 'argv-ignores.log');
|
||||
fs.rmSync(log, { force: true });
|
||||
try {
|
||||
const r = run(['scan', '--format', 'gstack', 'src/styles.css', path.join(designs, 'home.dom.html')], { env: { IMPECCABLE_BIN: FAKE, IMPECCABLE_FAKE_LOG: log } });
|
||||
expect(r.code).toBe(2);
|
||||
const calls = fs.readFileSync(log, 'utf-8').trim().split('\n').map(l => JSON.parse(l).argv as string[]);
|
||||
expect(calls).toHaveLength(2);
|
||||
const repoCall = calls.find(a => a.some(x => x.endsWith('styles.css')))!;
|
||||
const domCall = calls.find(a => a.some(x => x.endsWith('home.dom.html')))!;
|
||||
expect(repoCall).not.toContain('--no-inline-ignores');
|
||||
expect(domCall).toContain('--no-inline-ignores');
|
||||
expect(domCall.indexOf('--no-inline-ignores')).toBeLessThan(domCall.findIndex(x => x.endsWith('home.dom.html')));
|
||||
} finally {
|
||||
fs.rmSync(designs, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,5 +15,6 @@ export function installFakeImpeccable(prefix = 'gstack-fake-impeccable-'): { dir
|
||||
const bin = path.join(dir, 'impeccable');
|
||||
fs.copyFileSync(IMPECCABLE_FAKE_SRC, bin);
|
||||
fs.chmodSync(bin, 0o755);
|
||||
fs.copyFileSync(DETECT_SAMPLE, path.join(dir, 'impeccable-detect-sample.json')); // the shim's documented default output, beside it
|
||||
return { dir, bin };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user