@applesnort/crosscheck 0.3.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +83 -0
- package/README.md +138 -4
- package/bin/crosscheck.mjs +407 -25
- package/lenses/architect.md +1 -1
- package/lenses/check.md +1 -1
- package/lenses/security-check.md +1 -1
- package/lenses/taint.md +1 -1
- package/lib/cache.mjs +100 -0
- package/lib/comment.mjs +138 -0
- package/lib/config.mjs +30 -6
- package/lib/merge.mjs +7 -1
- package/lib/prompt.mjs +87 -1
- package/lib/run.mjs +106 -4
- package/lib/target.mjs +123 -0
- package/package.json +6 -2
package/bin/crosscheck.mjs
CHANGED
|
@@ -17,9 +17,11 @@
|
|
|
17
17
|
// {"lens": "ux", "output": null}] <- null means the lens died
|
|
18
18
|
//
|
|
19
19
|
// Usage:
|
|
20
|
-
// crosscheck run <path
|
|
20
|
+
// crosscheck run <path...|--diff|--staged|--since <ref>>
|
|
21
|
+
// --exec '<command>' [--lenses dir] [--only a,b]
|
|
21
22
|
// [--skip x,y] [--concurrency N] [--out run.json]
|
|
22
23
|
// [--sarif f] [--baseline b] [--mixed] [--dry-run]
|
|
24
|
+
// crosscheck init [--force] scaffold config, lenses, workflow
|
|
23
25
|
// crosscheck lenses [--lenses dir,dir] [--no-builtin]
|
|
24
26
|
// crosscheck report [--in run.json] [--baseline b.json]
|
|
25
27
|
// crosscheck sarif [--in run.json] [--baseline b.json] [--out x.sarif]
|
|
@@ -29,6 +31,13 @@
|
|
|
29
31
|
//
|
|
30
32
|
// Options: --overlap <file> independence data from `overlap` (report/sarif)
|
|
31
33
|
// --lenses <dir> lens directory (routing + SARIF rule metadata)
|
|
34
|
+
// --max-dispatches N cap lens runs; dropped lenses are named, never
|
|
35
|
+
// silently omitted. crosscheck cannot see tokens or
|
|
36
|
+
// money (--exec is any command), so dispatches are
|
|
37
|
+
// the only unit it can honestly cap.
|
|
38
|
+
// --comment-file <f> write a pull-request summary comment
|
|
39
|
+
// --no-cache do not read or write .crosscheck/cache
|
|
40
|
+
// --cache-dir <dir> relocate the cache
|
|
32
41
|
// --config <file> config file (default: nearest .crosscheckrc.json,
|
|
33
42
|
// searching upward and stopping at a repo root)
|
|
34
43
|
//
|
|
@@ -44,29 +53,40 @@
|
|
|
44
53
|
// crosscheck run lib/ --exec 'my-wrapper --json' --concurrency 2
|
|
45
54
|
// Use --dry-run to print the roster and prompts without spawning anything.
|
|
46
55
|
|
|
47
|
-
import { spawn } from 'node:child_process';
|
|
56
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
48
57
|
import {
|
|
49
|
-
existsSync, readFileSync, readdirSync, statSync, writeFileSync
|
|
58
|
+
existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync
|
|
50
59
|
} from 'node:fs';
|
|
51
|
-
import { join, relative, resolve } from 'node:path';
|
|
60
|
+
import { dirname, join, relative, resolve } from 'node:path';
|
|
52
61
|
import { formatScore, score } from '../lib/calibrate.mjs';
|
|
53
62
|
import { findConfig, mergeConfig, validateConfig } from '../lib/config.mjs';
|
|
54
63
|
import { parseFrontmatter, resolveLensSet } from '../lib/lenses.mjs';
|
|
55
64
|
import {
|
|
56
|
-
countsBySeverity, lensOverlap, mergeFindings, panelVerdict
|
|
65
|
+
applyVerdicts, countsBySeverity, lensOverlap, mergeFindings, panelVerdict
|
|
57
66
|
} from '../lib/merge.mjs';
|
|
58
67
|
import { filterAgainstBaseline, staleBaselineEntries, toBaseline }
|
|
59
68
|
from '../lib/baseline.mjs';
|
|
60
69
|
import { parseReports } from '../lib/parse.mjs';
|
|
61
|
-
import {
|
|
70
|
+
import {
|
|
71
|
+
planRun, promptsFor, resolveExec, runPanel, verifyFindings
|
|
72
|
+
} from '../lib/run.mjs';
|
|
73
|
+
import { cacheKey, createCache } from '../lib/cache.mjs';
|
|
74
|
+
import { buildComment } from '../lib/comment.mjs';
|
|
62
75
|
import { toSarifJson } from '../lib/sarif.mjs';
|
|
76
|
+
import { diffCommand, targetFromDiff, withContext } from '../lib/target.mjs';
|
|
63
77
|
|
|
64
78
|
function fail(message) {
|
|
65
79
|
process.stderr.write(`crosscheck: ${message}\n`);
|
|
66
80
|
process.exit(2);
|
|
67
81
|
}
|
|
68
82
|
|
|
69
|
-
|
|
83
|
+
// --diff is boolean rather than taking an optional ref: `run --diff src/` would
|
|
84
|
+
// otherwise be ambiguous about whether src/ is a ref or a path. Use --since <ref>
|
|
85
|
+
// to compare against something.
|
|
86
|
+
const BOOLEAN_FLAGS = new Set([
|
|
87
|
+
'dry-run', 'mixed', 'no-builtin', 'staged', 'verify', 'no-verify', 'diff',
|
|
88
|
+
'no-cache', 'force'
|
|
89
|
+
]);
|
|
70
90
|
|
|
71
91
|
function parseArgs(argv) {
|
|
72
92
|
const [command, ...rest] = argv;
|
|
@@ -163,26 +183,49 @@ function lensSources(option, { includeBuiltin = true } = {}) {
|
|
|
163
183
|
if (dirs.length === 0) {
|
|
164
184
|
fail('no lens directories to load (--no-builtin with no --lenses?)');
|
|
165
185
|
}
|
|
166
|
-
|
|
186
|
+
// The same directory can arrive twice — ./lenses is auto-detected, and running
|
|
187
|
+
// from that project also names it via --lenses. Loading it twice makes every
|
|
188
|
+
// lens in it shadow itself, which reads as a configuration mistake that is not
|
|
189
|
+
// one. Keep the last occurrence, so an explicit --lenses still wins on order.
|
|
190
|
+
const seen = new Map();
|
|
191
|
+
for (const dir of dirs) {
|
|
192
|
+
seen.set(dir, true);
|
|
193
|
+
}
|
|
194
|
+
const uniqueDirs = [...seen.keys()];
|
|
195
|
+
const sources = uniqueDirs.map(dir => ({ origin: dir, lenses: loadLenses(dir) }));
|
|
196
|
+
if (sources.every(source => source.lenses.length === 0)) {
|
|
197
|
+
fail(`no usable lens definitions found in: ${uniqueDirs.join(', ')}`);
|
|
198
|
+
}
|
|
199
|
+
return sources;
|
|
167
200
|
}
|
|
168
201
|
|
|
202
|
+
// Documentation files that live alongside lenses and are not lens attempts.
|
|
203
|
+
const NOT_A_LENS = /^(README|CONTRIBUTING|NOTES)\.md$/i;
|
|
204
|
+
|
|
205
|
+
// Returns whatever lenses the directory holds, possibly none. A source with no
|
|
206
|
+
// lenses is normal once sources layer — a project's own directory may hold only a
|
|
207
|
+
// README while the packaged lenses do the work. Failing here would make an empty
|
|
208
|
+
// local directory fatal; the combined set is what has to be non-empty, and
|
|
209
|
+
// lensSources checks that.
|
|
169
210
|
function loadLenses(dir) {
|
|
170
211
|
const files = readdirSync(dir).filter(f => f.endsWith('.md'));
|
|
171
212
|
const lenses = [];
|
|
172
213
|
for (const file of files) {
|
|
214
|
+
if (NOT_A_LENS.test(file)) {
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
173
217
|
const path = join(dir, file);
|
|
174
218
|
const text = readFileSync(path, 'utf8');
|
|
175
219
|
const meta = parseFrontmatter(text);
|
|
176
220
|
if (!meta?.name) {
|
|
221
|
+
// Named, because a lens silently ignored for a malformed header is a
|
|
222
|
+
// coverage hole that looks like a working roster.
|
|
177
223
|
process.stderr.write(
|
|
178
|
-
`crosscheck:
|
|
224
|
+
`crosscheck: ignoring ${file} — no frontmatter with a name\n`);
|
|
179
225
|
continue;
|
|
180
226
|
}
|
|
181
227
|
lenses.push({ ...meta, definition: text, definitionPath: path });
|
|
182
228
|
}
|
|
183
|
-
if (lenses.length === 0) {
|
|
184
|
-
fail(`no usable lens definitions in ${dir}`);
|
|
185
|
-
}
|
|
186
229
|
return lenses;
|
|
187
230
|
}
|
|
188
231
|
|
|
@@ -222,6 +265,63 @@ function collectFiles(targets) {
|
|
|
222
265
|
|
|
223
266
|
// Spawn the user's command with the prompt on stdin. crosscheck stays agnostic
|
|
224
267
|
// about which model or framework produced the text.
|
|
268
|
+
// Run git and return stdout. A failure here is fatal: a diff-scoped review that
|
|
269
|
+
// silently falls back to reviewing everything would cost far more than intended.
|
|
270
|
+
function git(args) {
|
|
271
|
+
const result = spawnSync('git', args, { encoding: 'utf8' });
|
|
272
|
+
if (result.error) {
|
|
273
|
+
fail(`could not run git: ${result.error.message}`);
|
|
274
|
+
}
|
|
275
|
+
if (result.status !== 0) {
|
|
276
|
+
fail(`git ${args.join(' ')} failed: ${String(result.stderr).trim()}`);
|
|
277
|
+
}
|
|
278
|
+
return result.stdout;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// A preflight command lets a project impose its own gate — data classification,
|
|
282
|
+
// a clean worktree, a branch policy — without crosscheck knowing what the rule
|
|
283
|
+
// is. Non-zero aborts before any model is called.
|
|
284
|
+
function runPreflight(commandLine) {
|
|
285
|
+
process.stderr.write(`crosscheck: preflight ${commandLine}\n`);
|
|
286
|
+
const result = spawnSync(commandLine, { shell: true, encoding: 'utf8' });
|
|
287
|
+
if (result.error) {
|
|
288
|
+
fail(`preflight could not run: ${result.error.message}`);
|
|
289
|
+
}
|
|
290
|
+
if (result.status !== 0) {
|
|
291
|
+
process.stderr.write(String(result.stdout ?? ''));
|
|
292
|
+
process.stderr.write(String(result.stderr ?? ''));
|
|
293
|
+
fail(`preflight failed (exit ${result.status}); nothing was dispatched`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// A disk-backed cache under .crosscheck/cache. Disabled entirely by --no-cache,
|
|
298
|
+
// in which case nothing is read or written.
|
|
299
|
+
function buildCache(options) {
|
|
300
|
+
if (options['no-cache']) {
|
|
301
|
+
return createCache();
|
|
302
|
+
}
|
|
303
|
+
const dir = resolve(options['cache-dir'] ?? '.crosscheck/cache');
|
|
304
|
+
return createCache({
|
|
305
|
+
read: key => {
|
|
306
|
+
const path = join(dir, `${key}.json`);
|
|
307
|
+
if (!existsSync(path)) {
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
try {
|
|
311
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
312
|
+
} catch {
|
|
313
|
+
// A corrupt entry is a miss, not a crash: the run should proceed and
|
|
314
|
+
// simply pay for that lens again.
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
},
|
|
318
|
+
write: (key, entry) => {
|
|
319
|
+
mkdirSync(dir, { recursive: true });
|
|
320
|
+
writeFileSync(join(dir, `${key}.json`), JSON.stringify(entry, null, 2));
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
225
325
|
function execCommand(commandLine) {
|
|
226
326
|
return ({ prompt }) => new Promise((resolvePromise, rejectPromise) => {
|
|
227
327
|
const child = spawn(commandLine, {
|
|
@@ -255,7 +355,7 @@ function buildMerged(options) {
|
|
|
255
355
|
};
|
|
256
356
|
}
|
|
257
357
|
|
|
258
|
-
function report({ merged, suppressed, stale }) {
|
|
358
|
+
function report({ merged, suppressed, stale, refuted = [] }) {
|
|
259
359
|
const counts = countsBySeverity(merged.findings);
|
|
260
360
|
const out = [];
|
|
261
361
|
out.push('# Crosscheck report');
|
|
@@ -270,6 +370,9 @@ function report({ merged, suppressed, stale }) {
|
|
|
270
370
|
out.push('', `Baseline entries no longer reported: ${stale.length} — ` +
|
|
271
371
|
'either fixed, or a lens stopped running.');
|
|
272
372
|
}
|
|
373
|
+
// Always stated, including zero: a finding that vanished without a count is
|
|
374
|
+
// indistinguishable from one that was never found.
|
|
375
|
+
out.push('', `Refuted in verification: ${refuted.length}.`);
|
|
273
376
|
if (merged.unparsed.length) {
|
|
274
377
|
out.push('', `Unparsed lens lines: ${merged.unparsed.length} ` +
|
|
275
378
|
`(${[...new Set(merged.unparsed.map(u => u.lens))].join(', ')}).`);
|
|
@@ -285,7 +388,12 @@ function report({ merged, suppressed, stale }) {
|
|
|
285
388
|
const who = f.consensus
|
|
286
389
|
? `CONSENSUS ${f.consensusScore}: ${f.lenses.join(', ')}`
|
|
287
390
|
: f.lenses.join(', ');
|
|
288
|
-
|
|
391
|
+
// More reports than lenses means nearby similar findings were collapsed;
|
|
392
|
+
// say so and give the lines, or the entry understates what was reported.
|
|
393
|
+
const collapsed = f.occurrences > f.lenses.length
|
|
394
|
+
? ` (${f.occurrences} reports across lines ${f.lines.join(', ')})`
|
|
395
|
+
: '';
|
|
396
|
+
out.push(`- [${who}] ${f.file}:${f.line}${collapsed} — ${f.issue}` +
|
|
289
397
|
(f.fix ? ` — ${f.fix}` : ''));
|
|
290
398
|
}
|
|
291
399
|
}
|
|
@@ -336,18 +444,195 @@ function loadConfig(explicitPath) {
|
|
|
336
444
|
return { config, path };
|
|
337
445
|
}
|
|
338
446
|
|
|
447
|
+
const INIT_CONFIG = `{
|
|
448
|
+
"// exec": "any command that takes a lens prompt on stdin and returns findings",
|
|
449
|
+
"exec": "claude -p",
|
|
450
|
+
"concurrency": 2,
|
|
451
|
+
"// context": "lines of surrounding code given to a lens around each change",
|
|
452
|
+
"context": 20
|
|
453
|
+
}
|
|
454
|
+
`;
|
|
455
|
+
|
|
456
|
+
const INIT_LENS_README = `# Project lenses
|
|
457
|
+
|
|
458
|
+
Markdown files here are added to the packaged lenses. A file whose \`name\`
|
|
459
|
+
matches a packaged lens overrides it, and the override is printed on every run.
|
|
460
|
+
|
|
461
|
+
Run \`crosscheck lenses\` to see what resolved and where each lens came from.
|
|
462
|
+
|
|
463
|
+
A lens needs five frontmatter keys and a body:
|
|
464
|
+
|
|
465
|
+
---
|
|
466
|
+
name: house-rules
|
|
467
|
+
summary: conventions this team actually enforces
|
|
468
|
+
when: [**/*.{js,mjs}]
|
|
469
|
+
owns: violations of our written conventions
|
|
470
|
+
not-owns: correctness, security, architecture, usability
|
|
471
|
+
---
|
|
472
|
+
|
|
473
|
+
# Lens: house-rules
|
|
474
|
+
|
|
475
|
+
...what to look for...
|
|
476
|
+
|
|
477
|
+
Findings only, one per line: \`file:line — SEVERITY — issue — fix\`.
|
|
478
|
+
SEVERITY is BLOCK, FIX, or CONSIDER. Reply exactly \`NO FINDINGS\` if none.
|
|
479
|
+
|
|
480
|
+
Nothing in this directory is published or uploaded by crosscheck. It is read off
|
|
481
|
+
disk at dispatch and goes nowhere else, so a lens here can encode conventions,
|
|
482
|
+
domain detail, or house rules that would make no sense upstream.
|
|
483
|
+
`;
|
|
484
|
+
|
|
485
|
+
const INIT_WORKFLOW = `name: crosscheck
|
|
486
|
+
|
|
487
|
+
on:
|
|
488
|
+
pull_request:
|
|
489
|
+
|
|
490
|
+
permissions:
|
|
491
|
+
contents: read
|
|
492
|
+
# Required to upload SARIF to code scanning.
|
|
493
|
+
security-events: write
|
|
494
|
+
# Required to post the summary comment.
|
|
495
|
+
pull-requests: write
|
|
496
|
+
|
|
497
|
+
concurrency:
|
|
498
|
+
group: crosscheck-\${{ github.ref }}
|
|
499
|
+
cancel-in-progress: true
|
|
500
|
+
|
|
501
|
+
jobs:
|
|
502
|
+
review:
|
|
503
|
+
runs-on: ubuntu-latest
|
|
504
|
+
steps:
|
|
505
|
+
- uses: actions/checkout@v4
|
|
506
|
+
with:
|
|
507
|
+
# crosscheck reviews a diff, so it needs the base commit too.
|
|
508
|
+
fetch-depth: 0
|
|
509
|
+
|
|
510
|
+
- uses: actions/setup-node@v4
|
|
511
|
+
with:
|
|
512
|
+
node-version: '22.x'
|
|
513
|
+
|
|
514
|
+
# EDIT THIS STEP. crosscheck never talks to a model, so whatever your
|
|
515
|
+
# \`exec\` command names has to exist on the runner. Nothing here is
|
|
516
|
+
# installed for you, and a missing command fails every lens with ENOENT.
|
|
517
|
+
#
|
|
518
|
+
# Claude Code: npm i -g @anthropic-ai/claude-code (exec: claude -p)
|
|
519
|
+
# llm: pipx install llm (exec: llm -m ...)
|
|
520
|
+
# your own: whatever installs it
|
|
521
|
+
- name: Install the model CLI
|
|
522
|
+
run: npm i -g @anthropic-ai/claude-code
|
|
523
|
+
|
|
524
|
+
- name: Review the change
|
|
525
|
+
env:
|
|
526
|
+
# Whatever your exec command needs to authenticate. crosscheck reads no
|
|
527
|
+
# credentials of its own.
|
|
528
|
+
ANTHROPIC_API_KEY: \${{ secrets.ANTHROPIC_API_KEY }}
|
|
529
|
+
run: |
|
|
530
|
+
npx @applesnort/crosscheck run \\
|
|
531
|
+
--since origin/\${{ github.base_ref }} \\
|
|
532
|
+
--sarif crosscheck.sarif \\
|
|
533
|
+
--comment-file comment.md
|
|
534
|
+
|
|
535
|
+
- name: Upload SARIF
|
|
536
|
+
if: always() && hashFiles('crosscheck.sarif') != ''
|
|
537
|
+
uses: github/codeql-action/upload-sarif@v3
|
|
538
|
+
with:
|
|
539
|
+
sarif_file: crosscheck.sarif
|
|
540
|
+
|
|
541
|
+
- name: Post or update the summary comment
|
|
542
|
+
if: always() && hashFiles('comment.md') != ''
|
|
543
|
+
env:
|
|
544
|
+
GH_TOKEN: \${{ secrets.GITHUB_TOKEN }}
|
|
545
|
+
run: |
|
|
546
|
+
# Edit our previous comment rather than stacking a new one.
|
|
547
|
+
id=$(gh api "repos/\${{ github.repository }}/issues/\${{ github.event.number }}/comments" \\
|
|
548
|
+
--jq '[.[] | select(.body | contains("<!-- crosscheck:report -->"))] | last | .id // empty')
|
|
549
|
+
if [ -n "$id" ]; then
|
|
550
|
+
gh api --method PATCH "repos/\${{ github.repository }}/issues/comments/$id" \\
|
|
551
|
+
-F body=@comment.md
|
|
552
|
+
else
|
|
553
|
+
gh api --method POST "repos/\${{ github.repository }}/issues/\${{ github.event.number }}/comments" \\
|
|
554
|
+
-F body=@comment.md
|
|
555
|
+
fi
|
|
556
|
+
`;
|
|
557
|
+
|
|
558
|
+
// Scaffold, without overwriting anything. A tool that silently replaces a config
|
|
559
|
+
// someone tuned is worse than one that refuses.
|
|
560
|
+
function initCommand(options) {
|
|
561
|
+
const force = Boolean(options.force);
|
|
562
|
+
const targets = [
|
|
563
|
+
{ path: '.crosscheckrc.json', content: INIT_CONFIG },
|
|
564
|
+
{ path: '.crosscheck/lenses/README.md', content: INIT_LENS_README },
|
|
565
|
+
{ path: '.github/workflows/crosscheck.yml', content: INIT_WORKFLOW }
|
|
566
|
+
];
|
|
567
|
+
const written = [];
|
|
568
|
+
const kept = [];
|
|
569
|
+
for (const { path, content } of targets) {
|
|
570
|
+
const full = resolve(path);
|
|
571
|
+
if (existsSync(full) && !force) {
|
|
572
|
+
kept.push(path);
|
|
573
|
+
continue;
|
|
574
|
+
}
|
|
575
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
576
|
+
writeFileSync(full, content);
|
|
577
|
+
written.push(path);
|
|
578
|
+
}
|
|
579
|
+
const out = [];
|
|
580
|
+
if (written.length) {
|
|
581
|
+
out.push('Created:', ...written.map(p => ` ${p}`));
|
|
582
|
+
}
|
|
583
|
+
if (kept.length) {
|
|
584
|
+
out.push('Left alone (already present; --force overwrites):',
|
|
585
|
+
...kept.map(p => ` ${p}`));
|
|
586
|
+
}
|
|
587
|
+
out.push('',
|
|
588
|
+
'Next: set "exec" in .crosscheckrc.json to the command that runs your model,',
|
|
589
|
+
'then try a dry run:',
|
|
590
|
+
'',
|
|
591
|
+
' crosscheck run --diff --dry-run',
|
|
592
|
+
'');
|
|
593
|
+
process.stdout.write(out.join('\n'));
|
|
594
|
+
}
|
|
595
|
+
|
|
339
596
|
async function runCommand(cliOptions, positional) {
|
|
340
597
|
const { config, path: configPath } = loadConfig(cliOptions.config);
|
|
341
598
|
const options = mergeConfig(config, cliOptions);
|
|
342
599
|
if (configPath) {
|
|
343
600
|
process.stderr.write(`crosscheck: config ${configPath}\n`);
|
|
344
601
|
}
|
|
345
|
-
if (
|
|
346
|
-
|
|
602
|
+
if (options.preflight) {
|
|
603
|
+
runPreflight(options.preflight);
|
|
604
|
+
}
|
|
605
|
+
const diffMode = options.staged || options.since != null ||
|
|
606
|
+
options.diff != null;
|
|
607
|
+
if (positional.length === 0 && !diffMode) {
|
|
608
|
+
fail('run needs a path to audit, or --diff / --staged / --since <ref>');
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// Resolve the target first: a bad path or an empty diff is the more
|
|
612
|
+
// fundamental error, and reporting a missing flag instead sends the user after
|
|
613
|
+
// the wrong problem.
|
|
614
|
+
let files;
|
|
615
|
+
let rangesByFile = null;
|
|
616
|
+
if (diffMode) {
|
|
617
|
+
const cmd = diffCommand({
|
|
618
|
+
diff: options.diff, staged: options.staged, since: options.since
|
|
619
|
+
});
|
|
620
|
+
process.stderr.write(`crosscheck: git ${cmd.join(' ')}\n`);
|
|
621
|
+
const target = targetFromDiff(git(cmd));
|
|
622
|
+
if (target.files.length === 0) {
|
|
623
|
+
process.stderr.write(
|
|
624
|
+
'crosscheck: the diff contains no reviewable changes — nothing to do\n');
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
files = target.files;
|
|
628
|
+
// Widen to give a lens the surrounding code. A defect introduced by a change
|
|
629
|
+
// is often only visible against the lines the change did not touch.
|
|
630
|
+
const context = Number(options.context ?? 20);
|
|
631
|
+
rangesByFile = Object.fromEntries(Object.entries(target.rangesByFile)
|
|
632
|
+
.map(([file, ranges]) => [file, withContext(ranges, context)]));
|
|
633
|
+
} else {
|
|
634
|
+
files = collectFiles(positional);
|
|
347
635
|
}
|
|
348
|
-
// Resolve the target first: a bad path is the more fundamental error, and
|
|
349
|
-
// reporting a missing flag instead sends the user after the wrong problem.
|
|
350
|
-
const files = collectFiles(positional);
|
|
351
636
|
if (!options.exec && !options['dry-run']) {
|
|
352
637
|
fail("run needs --exec '<command>' (or --dry-run to see the prompts)");
|
|
353
638
|
}
|
|
@@ -380,7 +665,10 @@ async function runCommand(cliOptions, positional) {
|
|
|
380
665
|
fail('no lens matched the target; nothing to run');
|
|
381
666
|
}
|
|
382
667
|
|
|
383
|
-
const promptOptions = {
|
|
668
|
+
const promptOptions = {
|
|
669
|
+
mixedCorpus: Boolean(options.mixed),
|
|
670
|
+
rangesByFile
|
|
671
|
+
};
|
|
384
672
|
|
|
385
673
|
if (options['dry-run']) {
|
|
386
674
|
for (const job of promptsFor(roster, promptOptions)) {
|
|
@@ -390,17 +678,57 @@ async function runCommand(cliOptions, positional) {
|
|
|
390
678
|
return;
|
|
391
679
|
}
|
|
392
680
|
|
|
393
|
-
|
|
681
|
+
// Each lens may run under its own command, so dispatch resolves per lens
|
|
682
|
+
// rather than sharing one executor.
|
|
683
|
+
const byLens = new Map(roster.map(l => [l.name, l]));
|
|
684
|
+
const dispatch = async ({ prompt, lens, files }) => {
|
|
685
|
+
const commandLine = resolveExec(byLens.get(lens), options.exec);
|
|
686
|
+
if (!commandLine) {
|
|
687
|
+
throw new Error(
|
|
688
|
+
`no exec for lens "${lens}" — set exec, or an exec map entry for it`);
|
|
689
|
+
}
|
|
690
|
+
return execCommand(commandLine)({ prompt, lens, files });
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
const cache = buildCache(options);
|
|
694
|
+
const { reports, failures, dropped, cacheStats } = await runPanel({
|
|
394
695
|
roster,
|
|
395
696
|
skipped,
|
|
396
|
-
exec:
|
|
697
|
+
exec: dispatch,
|
|
397
698
|
concurrency: Number(options.concurrency ?? 4),
|
|
398
699
|
promptOptions,
|
|
700
|
+
cache: cache.enabled ? cache : null,
|
|
701
|
+
cacheKeyFor: cache.enabled
|
|
702
|
+
? job => cacheKey({
|
|
703
|
+
lens: job.lens,
|
|
704
|
+
definition: byLens.get(job.lens)?.definition,
|
|
705
|
+
files: job.files.map(path => ({
|
|
706
|
+
path,
|
|
707
|
+
content: existsSync(path) ? readFileSync(path, 'utf8') : ''
|
|
708
|
+
})),
|
|
709
|
+
promptOptions
|
|
710
|
+
})
|
|
711
|
+
: null,
|
|
712
|
+
maxDispatches: options['max-dispatches'] == null
|
|
713
|
+
? null : Number(options['max-dispatches']),
|
|
399
714
|
onLensStart: lens => process.stderr.write(` → ${lens}\n`),
|
|
400
715
|
onLensDone: (lens, r) => process.stderr.write(
|
|
401
|
-
r.ok
|
|
716
|
+
r.ok
|
|
717
|
+
? ` ✓ ${lens} (${r.findings} finding(s))${r.cached ? ' [cached]' : ''}\n`
|
|
718
|
+
: ` ✗ ${lens} did not complete\n`)
|
|
402
719
|
});
|
|
403
720
|
|
|
721
|
+
// Both of these are coverage holes, and this tool states its holes.
|
|
722
|
+
if (dropped.length) {
|
|
723
|
+
process.stderr.write(
|
|
724
|
+
`crosscheck: BUDGET REACHED — ${dropped.length} lens(es) not run: ` +
|
|
725
|
+
`${dropped.map(d => d.lens).join(', ')}\n`);
|
|
726
|
+
}
|
|
727
|
+
if (cacheStats?.hits) {
|
|
728
|
+
process.stderr.write(
|
|
729
|
+
`crosscheck: ${cacheStats.hits} lens(es) served from cache\n`);
|
|
730
|
+
}
|
|
731
|
+
|
|
404
732
|
for (const f of failures) {
|
|
405
733
|
process.stderr.write(`crosscheck: ${f.lens} failed — ${f.reason}\n`);
|
|
406
734
|
}
|
|
@@ -416,6 +744,39 @@ async function runCommand(cliOptions, positional) {
|
|
|
416
744
|
|
|
417
745
|
const overlap = loadJson(options.overlap) ?? undefined;
|
|
418
746
|
let merged = mergeFindings(reports, { overlap });
|
|
747
|
+
|
|
748
|
+
// Verification is on by default for BLOCK findings: false positives cost more
|
|
749
|
+
// than misses, because a panel that cries wolf stops being read at all.
|
|
750
|
+
let refuted = [];
|
|
751
|
+
const verifyWanted = options['no-verify'] ? false : true;
|
|
752
|
+
if (verifyWanted) {
|
|
753
|
+
const candidates = merged.findings.filter(f =>
|
|
754
|
+
options.verify ? true : f.severity === 'BLOCK');
|
|
755
|
+
if (candidates.length > 0) {
|
|
756
|
+
process.stderr.write(
|
|
757
|
+
`crosscheck: verifying ${candidates.length} finding(s)\n`);
|
|
758
|
+
const { verdicts, failures: verifyFailures } = await verifyFindings({
|
|
759
|
+
findings: candidates,
|
|
760
|
+
exec: execCommand(options.exec),
|
|
761
|
+
concurrency: Number(options.concurrency ?? 4),
|
|
762
|
+
onVerdict: (f, v) => process.stderr.write(
|
|
763
|
+
` ${v.refuted ? '✗ refuted' : '✓ confirmed'} ${f.file}:${f.line}\n`)
|
|
764
|
+
});
|
|
765
|
+
for (const f of verifyFailures) {
|
|
766
|
+
// A verifier that did not run is not agreement; the finding stands.
|
|
767
|
+
process.stderr.write(
|
|
768
|
+
`crosscheck: verifier failed for ${f.finding} — ${f.reason}; ` +
|
|
769
|
+
'the finding is kept\n');
|
|
770
|
+
}
|
|
771
|
+
const applied = applyVerdicts(merged.findings, verdicts);
|
|
772
|
+
refuted = applied.refuted;
|
|
773
|
+
merged = { ...merged, findings: applied.findings };
|
|
774
|
+
if (refuted.length) {
|
|
775
|
+
process.stderr.write(
|
|
776
|
+
`crosscheck: ${refuted.length} finding(s) refuted and removed\n`);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
419
780
|
let suppressed = [];
|
|
420
781
|
let stale = [];
|
|
421
782
|
const baseline = loadJson(options.baseline);
|
|
@@ -426,11 +787,27 @@ async function runCommand(cliOptions, positional) {
|
|
|
426
787
|
merged = { ...merged, findings: filtered.findings };
|
|
427
788
|
}
|
|
428
789
|
|
|
429
|
-
process.stdout.write(report({ merged, suppressed, stale }));
|
|
790
|
+
process.stdout.write(report({ merged, suppressed, stale, refuted }));
|
|
791
|
+
|
|
792
|
+
if (options['comment-file']) {
|
|
793
|
+
writeFileSync(options['comment-file'], buildComment({
|
|
794
|
+
merged,
|
|
795
|
+
refuted,
|
|
796
|
+
suppressed,
|
|
797
|
+
dropped,
|
|
798
|
+
skipped,
|
|
799
|
+
target: diffMode
|
|
800
|
+
? `${files.length} changed file(s)`
|
|
801
|
+
: `${files.length} file(s)`,
|
|
802
|
+
sarifPath: options.sarif ?? null
|
|
803
|
+
}));
|
|
804
|
+
process.stderr.write(`crosscheck: wrote ${options['comment-file']}\n`);
|
|
805
|
+
}
|
|
430
806
|
|
|
431
807
|
if (options.sarif) {
|
|
432
808
|
writeFileSync(options.sarif, toSarifJson(merged, {
|
|
433
|
-
lensMeta: Object.fromEntries(lenses.map(l => [l.name, l]))
|
|
809
|
+
lensMeta: Object.fromEntries(lenses.map(l => [l.name, l])),
|
|
810
|
+
refuted
|
|
434
811
|
}));
|
|
435
812
|
process.stderr.write(`crosscheck: wrote ${options.sarif}\n`);
|
|
436
813
|
}
|
|
@@ -457,6 +834,11 @@ async function main() {
|
|
|
457
834
|
return;
|
|
458
835
|
}
|
|
459
836
|
|
|
837
|
+
if (command === 'init') {
|
|
838
|
+
initCommand(options);
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
|
|
460
842
|
if (command === 'lenses') {
|
|
461
843
|
const sources = lensSources(options.lenses,
|
|
462
844
|
{ includeBuiltin: !options['no-builtin'] });
|
package/lenses/architect.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: architect
|
|
3
3
|
summary: structure, data shape, coupling, and reversibility of decisions
|
|
4
|
-
when: [**/*.{js,mjs,cjs,ts,tsx,py,go,rb,java,cs,rs,sql,prisma,graphql}, "**/migrations/**", "**/schema*"]
|
|
4
|
+
when: [**/*.{js,mjs,cjs,ts,tsx,py,go,rb,java,cs,rs,sql,prisma,graphql,vue,svelte}, "**/migrations/**", "**/schema*"]
|
|
5
5
|
owns: couplings and lock-in that make later change expensive
|
|
6
6
|
not-owns: line-level correctness, style, security categories, usability
|
|
7
7
|
cites: []
|
package/lenses/check.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: check
|
|
3
3
|
summary: correctness — boundaries, absent values, error paths, concurrency
|
|
4
|
-
when: [**/*.{js,mjs,cjs,jsx,ts,tsx,py,go,rb,java,cs,rs,php,kt,swift}]
|
|
4
|
+
when: [**/*.{js,mjs,cjs,jsx,ts,tsx,py,go,rb,java,cs,rs,php,kt,swift,vue,svelte}]
|
|
5
5
|
owns: defects that produce wrong behavior at runtime
|
|
6
6
|
not-owns: style, naming, architecture, security categories, usability
|
|
7
7
|
cites: []
|
package/lenses/security-check.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: security-check
|
|
3
3
|
summary: application security — trust boundaries, injection, secrets, exposure
|
|
4
|
-
when: [**/*.{js,mjs,cjs,ts,tsx,py,go,rb,java,cs,rs,php,kt,swift,sql}]
|
|
4
|
+
when: [**/*.{js,mjs,cjs,ts,tsx,py,go,rb,java,cs,rs,php,kt,swift,sql,vue,svelte}]
|
|
5
5
|
owns: exploitable weaknesses reachable by an untrusted or under-privileged caller
|
|
6
6
|
not-owns: general correctness, architecture preference, usability, styling
|
|
7
7
|
cites: ["OWASP Top 10 (2021)", "OWASP ASVS", "CWE"]
|
package/lenses/taint.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: taint
|
|
3
3
|
summary: data flow from untrusted origin to dangerous operation, and what sanitises it
|
|
4
|
-
when: [**/*.{js,mjs,cjs,ts,tsx,py,go,rb,java,cs,rs,php,kt,swift}]
|
|
4
|
+
when: [**/*.{js,mjs,cjs,ts,tsx,py,go,rb,java,cs,rs,php,kt,swift,vue,svelte}]
|
|
5
5
|
owns: untrusted values reaching an operation that interprets them, unsanitised
|
|
6
6
|
not-owns: security policy, authentication design, crypto choice, correctness, architecture, usability
|
|
7
7
|
cites: []
|