argus-reviewer-e2e 0.1.2 → 0.2.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/README.md +30 -5
- package/action/action.yml +22 -0
- package/action/sticky-comment.mjs +287 -79
- package/dist/api.d.ts +1 -0
- package/dist/api.js +1 -0
- package/dist/cli.d.ts +67 -0
- package/dist/cli.js +463 -86
- package/dist/config.d.ts +105 -2
- package/dist/config.js +136 -9
- package/dist/debug.d.ts +1 -0
- package/dist/debug.js +9 -3
- package/dist/detect.d.ts +11 -1
- package/dist/detect.js +19 -3
- package/dist/evidence/ci.d.ts +47 -2
- package/dist/evidence/ci.js +98 -6
- package/dist/evidence/gate.d.ts +10 -0
- package/dist/evidence/gate.js +29 -0
- package/dist/evidence/link.d.ts +6 -1
- package/dist/evidence/link.js +7 -5
- package/dist/executor/sandbox.d.ts +105 -0
- package/dist/executor/sandbox.js +231 -0
- package/dist/probe/author.d.ts +50 -0
- package/dist/probe/author.js +149 -0
- package/dist/probe/harness.d.ts +30 -0
- package/dist/probe/harness.js +99 -0
- package/dist/probe/queue.d.ts +105 -0
- package/dist/probe/queue.js +446 -0
- package/dist/review/adjudicate.d.ts +63 -0
- package/dist/review/adjudicate.js +111 -0
- package/dist/review/secrets.d.ts +88 -0
- package/dist/review/secrets.js +220 -0
- package/dist/review/triage.d.ts +76 -0
- package/dist/review/triage.js +163 -0
- package/dist/trust.d.ts +50 -0
- package/dist/trust.js +103 -0
- package/dist/vision/cost.d.ts +14 -1
- package/dist/vision/cost.js +13 -0
- package/dist/vision/decisions.d.ts +95 -0
- package/dist/vision/decisions.js +232 -0
- package/package.json +3 -2
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { DecisionClient } from '../vision/decisions.js';
|
|
2
|
+
/**
|
|
3
|
+
* U8 finding adjudication — one batched Jev decide() scores each
|
|
4
|
+
* synthesized finding's true-positive probability before posting. Same
|
|
5
|
+
* posture as the secrets lane: Jev annotates/routes, never gates.
|
|
6
|
+
* `p` lands on the finding record and the sticky confidence column;
|
|
7
|
+
* suppression is scoped to `nit`/`q` severities whose false-positive
|
|
8
|
+
* confidence exceeds `findingThreshold` — `bug`/`risk` are never
|
|
9
|
+
* suppressed, and a Jev outage leaves every finding unadjudicated and
|
|
10
|
+
* unsuppressed (degrade open).
|
|
11
|
+
*
|
|
12
|
+
* `findingThreshold` is the required P(false positive): a nit/q is
|
|
13
|
+
* suppressed when `1 - p > threshold`, i.e. `p < 1 - threshold`.
|
|
14
|
+
* Default 1.0 → nothing can exceed 100% FP → annotate-only.
|
|
15
|
+
*/
|
|
16
|
+
export interface AdjudicableFinding {
|
|
17
|
+
file: string;
|
|
18
|
+
line?: number;
|
|
19
|
+
severity: string;
|
|
20
|
+
category?: string;
|
|
21
|
+
message: string;
|
|
22
|
+
}
|
|
23
|
+
export interface FindingAdjudicationRecord {
|
|
24
|
+
file: string;
|
|
25
|
+
line?: number;
|
|
26
|
+
severity: string;
|
|
27
|
+
category?: string;
|
|
28
|
+
/** Finding text — capped; suppressed findings keep their message in audit. */
|
|
29
|
+
message: string;
|
|
30
|
+
adjudicated: boolean;
|
|
31
|
+
/** Jev P(true positive) for this finding. */
|
|
32
|
+
p?: number;
|
|
33
|
+
/** nit/q below the FP bar — kept for audit, removed from findings. */
|
|
34
|
+
suppressed?: boolean;
|
|
35
|
+
}
|
|
36
|
+
/** The audit half of the result — what report.findingAdjudication stores. */
|
|
37
|
+
export interface FindingAdjudicationAudit {
|
|
38
|
+
records: FindingAdjudicationRecord[];
|
|
39
|
+
/** Findings past MAX_CANDIDATES — unadjudicated, never suppressed. */
|
|
40
|
+
overflow: number;
|
|
41
|
+
/** Whole-call failure — nothing adjudicated, nothing suppressed. */
|
|
42
|
+
unadjudicated?: boolean;
|
|
43
|
+
}
|
|
44
|
+
export interface FindingAdjudicationResult<T extends AdjudicableFinding> extends FindingAdjudicationAudit {
|
|
45
|
+
/** Surviving findings — adjudicated ones carry `p`. */
|
|
46
|
+
findings: (T & {
|
|
47
|
+
p?: number;
|
|
48
|
+
})[];
|
|
49
|
+
}
|
|
50
|
+
export declare function adjudicateFindings<T extends AdjudicableFinding>(opts: {
|
|
51
|
+
findings: T[];
|
|
52
|
+
/** PR file patches keyed by filename — Jev state context. */
|
|
53
|
+
patchByFile?: Map<string, string>;
|
|
54
|
+
threshold: number;
|
|
55
|
+
/**
|
|
56
|
+
* User-configured blocking severities — a severity listed here drives
|
|
57
|
+
* the verdict, so it must never be suppressed even if it is nit/q
|
|
58
|
+
* (otherwise Jev suppression could flip the commit-status gate).
|
|
59
|
+
*/
|
|
60
|
+
blockSeverities?: string[];
|
|
61
|
+
client: DecisionClient;
|
|
62
|
+
model?: string;
|
|
63
|
+
}): Promise<FindingAdjudicationResult<T>>;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { debug } from '../debug.js';
|
|
2
|
+
import { describeDecisionError, isNoulAnswer, MAX_CANDIDATES, } from '../vision/decisions.js';
|
|
3
|
+
/** Suppression is scoped to severities that never drive the verdict. */
|
|
4
|
+
const SUPPRESSIBLE = new Set(['nit', 'q']);
|
|
5
|
+
const MAX_PATCH_EXCERPT = 4000;
|
|
6
|
+
/** Aggregate patch-state bound — 50 unique files x 4KB is still ~200KB. */
|
|
7
|
+
const MAX_PATCH_STATE_CHARS = 24_000;
|
|
8
|
+
/** Audit-record message bound — full text already lives in findings. */
|
|
9
|
+
const MAX_RECORD_MESSAGE = 300;
|
|
10
|
+
export async function adjudicateFindings(opts) {
|
|
11
|
+
const capped = opts.findings.slice(0, MAX_CANDIDATES);
|
|
12
|
+
const overflow = opts.findings.length - capped.length;
|
|
13
|
+
const pByIdx = new Array(capped.length);
|
|
14
|
+
let adjudicationFailed = false;
|
|
15
|
+
if (capped.length > 0) {
|
|
16
|
+
try {
|
|
17
|
+
const questions = {};
|
|
18
|
+
capped.forEach((_f, i) => {
|
|
19
|
+
questions[`f_${i}`] = {
|
|
20
|
+
type: 'noul',
|
|
21
|
+
instructions: `state.findings[${i}]: is this code-review finding a real problem the ` +
|
|
22
|
+
'PR author should act on? Its file patch is under state.patches. ' +
|
|
23
|
+
'Answer no for speculative style nits, issues already handled by ' +
|
|
24
|
+
'guards visible in the patch, and findings that merely restate ' +
|
|
25
|
+
'what the code does.',
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
// Each finding references its patch by filename — sending patches
|
|
29
|
+
// once keyed by file avoids repeating a 4KB excerpt per finding
|
|
30
|
+
// on the same file; an aggregate bound caps the whole state.
|
|
31
|
+
const patches = {};
|
|
32
|
+
let patchBudget = MAX_PATCH_STATE_CHARS;
|
|
33
|
+
for (const f of capped) {
|
|
34
|
+
if (patchBudget <= 0)
|
|
35
|
+
break;
|
|
36
|
+
if (patches[f.file] !== undefined)
|
|
37
|
+
continue;
|
|
38
|
+
const p = opts.patchByFile?.get(f.file);
|
|
39
|
+
if (p !== undefined) {
|
|
40
|
+
const excerpt = p.slice(0, Math.min(MAX_PATCH_EXCERPT, patchBudget));
|
|
41
|
+
patches[f.file] = excerpt;
|
|
42
|
+
patchBudget -= excerpt.length;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const state = {
|
|
46
|
+
findings: capped.map((f) => ({
|
|
47
|
+
file: f.file,
|
|
48
|
+
line: f.line,
|
|
49
|
+
severity: f.severity,
|
|
50
|
+
message: f.message,
|
|
51
|
+
})),
|
|
52
|
+
patches,
|
|
53
|
+
};
|
|
54
|
+
const { answers } = await opts.client.decide({
|
|
55
|
+
...(opts.model !== undefined ? { model: opts.model } : {}),
|
|
56
|
+
state,
|
|
57
|
+
questions,
|
|
58
|
+
});
|
|
59
|
+
capped.forEach((_f, i) => {
|
|
60
|
+
const a = answers[`f_${i}`];
|
|
61
|
+
pByIdx[i] = a !== undefined && isNoulAnswer(a) ? a.noul : undefined;
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
catch (e) {
|
|
65
|
+
adjudicationFailed = true;
|
|
66
|
+
debug('adjudicate', `decision call failed — no suppression: ${describeDecisionError(e)}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
const blocking = new Set(opts.blockSeverities ?? []);
|
|
70
|
+
// Only Jev may attach p — a model-emitted p on an unadjudicated
|
|
71
|
+
// finding is spoofed confidence, so strip it.
|
|
72
|
+
const stripP = (f) => {
|
|
73
|
+
const out = { ...f };
|
|
74
|
+
delete out.p;
|
|
75
|
+
return out;
|
|
76
|
+
};
|
|
77
|
+
const findings = [];
|
|
78
|
+
const records = [];
|
|
79
|
+
capped.forEach((f, i) => {
|
|
80
|
+
const p = pByIdx[i];
|
|
81
|
+
const adjudicated = p !== undefined && !adjudicationFailed;
|
|
82
|
+
const suppress = adjudicated &&
|
|
83
|
+
SUPPRESSIBLE.has(f.severity) &&
|
|
84
|
+
!blocking.has(f.severity) &&
|
|
85
|
+
p !== undefined &&
|
|
86
|
+
p < 1 - opts.threshold;
|
|
87
|
+
records.push({
|
|
88
|
+
file: f.file,
|
|
89
|
+
...(f.line !== undefined ? { line: f.line } : {}),
|
|
90
|
+
severity: f.severity,
|
|
91
|
+
...(f.category !== undefined ? { category: f.category } : {}),
|
|
92
|
+
message: f.message.slice(0, MAX_RECORD_MESSAGE),
|
|
93
|
+
adjudicated,
|
|
94
|
+
...(p !== undefined ? { p } : {}),
|
|
95
|
+
...(suppress ? { suppressed: true } : {}),
|
|
96
|
+
});
|
|
97
|
+
if (!suppress) {
|
|
98
|
+
findings.push(p !== undefined ? { ...stripP(f), p } : stripP(f));
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
// Overflow findings keep their place — never adjudicated, never
|
|
102
|
+
// suppressed; count-only in the audit record like the secrets lane.
|
|
103
|
+
for (const f of opts.findings.slice(MAX_CANDIDATES))
|
|
104
|
+
findings.push(stripP(f));
|
|
105
|
+
return {
|
|
106
|
+
findings,
|
|
107
|
+
records,
|
|
108
|
+
overflow,
|
|
109
|
+
...(adjudicationFailed ? { unadjudicated: true } : {}),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { type ExecFn } from '../detect.js';
|
|
2
|
+
import { DecisionClient } from '../vision/decisions.js';
|
|
3
|
+
/**
|
|
4
|
+
* Deterministic secrets scan over the PR's local merge-base diff,
|
|
5
|
+
* optionally adjudicated by the Decisions API (Jev). The lane is
|
|
6
|
+
* additive-only: findings are unioned into the review AFTER model
|
|
7
|
+
* synthesis so a prompt-injected synthesis can never erase them, and a
|
|
8
|
+
* Jev outage degrades to regex-only findings rather than silence.
|
|
9
|
+
*
|
|
10
|
+
* Masking contract: raw literals transit to Jev inside `state` (KTD9 —
|
|
11
|
+
* adjudication needs the shape, and the full diff already crosses to
|
|
12
|
+
* OpenRouter in the review call) and appear NOWHERE else — not in
|
|
13
|
+
* findings, comments, the report, or logs.
|
|
14
|
+
*/
|
|
15
|
+
export interface SecretCandidate {
|
|
16
|
+
file: string;
|
|
17
|
+
/** Line number in the post-change file. */
|
|
18
|
+
line: number;
|
|
19
|
+
patternClass: string;
|
|
20
|
+
/** Diff line text with every occurrence of the literal replaced by `***`. */
|
|
21
|
+
contextExcerpt: string;
|
|
22
|
+
/** Raw literal — Jev `state` only, never emitted. */
|
|
23
|
+
literal: string;
|
|
24
|
+
/** Full raw added-line text — Jev `state` only, never emitted. */
|
|
25
|
+
rawText: string;
|
|
26
|
+
}
|
|
27
|
+
export interface SecretScanRecord {
|
|
28
|
+
file: string;
|
|
29
|
+
line: number;
|
|
30
|
+
patternClass: string;
|
|
31
|
+
/** True when Jev answered for this candidate. */
|
|
32
|
+
adjudicated: boolean;
|
|
33
|
+
pLive?: number;
|
|
34
|
+
/** Jev scored below threshold — recorded for audit, not a finding. */
|
|
35
|
+
suppressed?: boolean;
|
|
36
|
+
}
|
|
37
|
+
export interface SecretsScanResult {
|
|
38
|
+
/** Findings to union into the review — messages are fully masked. */
|
|
39
|
+
findings: {
|
|
40
|
+
file: string;
|
|
41
|
+
line?: number;
|
|
42
|
+
severity: string;
|
|
43
|
+
category?: string;
|
|
44
|
+
message: string;
|
|
45
|
+
}[];
|
|
46
|
+
/** Audit records for report.secretsScan — literals never included. */
|
|
47
|
+
records: SecretScanRecord[];
|
|
48
|
+
/** Candidates past MAX_CANDIDATES — reported count-only, never sent to Jev. */
|
|
49
|
+
overflow: number;
|
|
50
|
+
/** Why the lane produced nothing (e.g. base unfetchable). */
|
|
51
|
+
skipped?: string;
|
|
52
|
+
}
|
|
53
|
+
export { MAX_CANDIDATES } from '../vision/decisions.js';
|
|
54
|
+
export declare const DEFAULT_SECRETS_THRESHOLD = 0.3;
|
|
55
|
+
/**
|
|
56
|
+
* Parse `git diff` text into secret candidates from added (`+`) lines.
|
|
57
|
+
* Removed/context lines are not scanned — a rotated-out-but-live secret
|
|
58
|
+
* in a `-` line is a deliberate open question (plan OQ), and context
|
|
59
|
+
* lines would re-flag pre-existing secrets the PR did not introduce.
|
|
60
|
+
*/
|
|
61
|
+
export declare function scanDiffForSecrets(diffText: string): SecretCandidate[];
|
|
62
|
+
/**
|
|
63
|
+
* Materialize the merge-base diff locally — the PR-files API `patch`
|
|
64
|
+
* field omits large/binary files, so the API diff is not a complete
|
|
65
|
+
* scan surface.
|
|
66
|
+
*/
|
|
67
|
+
export declare function materializeMergeBaseDiff(opts: {
|
|
68
|
+
cwd: string;
|
|
69
|
+
baseSha: string;
|
|
70
|
+
token?: string;
|
|
71
|
+
exec?: ExecFn;
|
|
72
|
+
}): Promise<{
|
|
73
|
+
diff: string;
|
|
74
|
+
} | {
|
|
75
|
+
skipped: string;
|
|
76
|
+
}>;
|
|
77
|
+
/**
|
|
78
|
+
* Full lane: scan candidates (capped), Jev-adjudicate when a client and
|
|
79
|
+
* threshold are available, and emit masked findings + audit records.
|
|
80
|
+
* `client === undefined` (decisionModel unset) or any DecisionError →
|
|
81
|
+
* every candidate unadjudicated — regex-only mode, never silence.
|
|
82
|
+
*/
|
|
83
|
+
export declare function scanSecrets(opts: {
|
|
84
|
+
diff: string;
|
|
85
|
+
threshold?: number;
|
|
86
|
+
client?: DecisionClient;
|
|
87
|
+
model?: string;
|
|
88
|
+
}): Promise<SecretsScanResult>;
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { defaultExec } from '../detect.js';
|
|
2
|
+
import { debug } from '../debug.js';
|
|
3
|
+
import { describeDecisionError, isNoulAnswer, MAX_CANDIDATES, } from '../vision/decisions.js';
|
|
4
|
+
// Owned by vision/decisions.ts — re-exported here so existing import
|
|
5
|
+
// paths (tests, lanes) keep resolving.
|
|
6
|
+
export { MAX_CANDIDATES } from '../vision/decisions.js';
|
|
7
|
+
export const DEFAULT_SECRETS_THRESHOLD = 0.3;
|
|
8
|
+
const PATTERNS = [
|
|
9
|
+
{
|
|
10
|
+
cls: 'private-key',
|
|
11
|
+
re: /-----BEGIN (?:RSA |EC |OPENSSH |PGP |DSA )?PRIVATE KEY(?: BLOCK)?-----/,
|
|
12
|
+
},
|
|
13
|
+
{ cls: 'aws-access-key', re: /\bAKIA[0-9A-Z]{16}\b/ },
|
|
14
|
+
{ cls: 'stripe-live', re: /\b(?:sk|rk)_live_[0-9a-zA-Z]{16,}\b/ },
|
|
15
|
+
{ cls: 'stripe-webhook-secret', re: /\bwhsec_[0-9a-zA-Z]{16,}\b/ },
|
|
16
|
+
{ cls: 'github-pat', re: /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{22,})\b/ },
|
|
17
|
+
{ cls: 'slack-token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/ },
|
|
18
|
+
{
|
|
19
|
+
cls: 'jwt',
|
|
20
|
+
re: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{5,}\b/,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
cls: 'generic-assignment',
|
|
24
|
+
re: /(?:api[_-]?key|token|secret|passwd|password)\s*[:=]\s*['"]?([A-Za-z0-9/+_.=-]{12,})/i,
|
|
25
|
+
group: 1,
|
|
26
|
+
},
|
|
27
|
+
];
|
|
28
|
+
/**
|
|
29
|
+
* Parse `git diff` text into secret candidates from added (`+`) lines.
|
|
30
|
+
* Removed/context lines are not scanned — a rotated-out-but-live secret
|
|
31
|
+
* in a `-` line is a deliberate open question (plan OQ), and context
|
|
32
|
+
* lines would re-flag pre-existing secrets the PR did not introduce.
|
|
33
|
+
*/
|
|
34
|
+
export function scanDiffForSecrets(diffText) {
|
|
35
|
+
const out = [];
|
|
36
|
+
let file = '';
|
|
37
|
+
let newLine = 0;
|
|
38
|
+
// `+++ `/`--- ` are file headers only in the pre-hunk zone — inside a
|
|
39
|
+
// hunk they are added/removed content lines (`+` + `++ x`, `-` + `-- x`)
|
|
40
|
+
// and must not reset `file` or `inHunk`.
|
|
41
|
+
let inHunk = false;
|
|
42
|
+
for (const raw of diffText.split('\n')) {
|
|
43
|
+
if (raw.startsWith('diff --git')) {
|
|
44
|
+
inHunk = false;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (raw.startsWith('@@')) {
|
|
48
|
+
inHunk = true;
|
|
49
|
+
const m = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(raw);
|
|
50
|
+
newLine = m !== null ? parseInt(m[1], 10) : 0;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (!inHunk) {
|
|
54
|
+
if (raw.startsWith('+++ ')) {
|
|
55
|
+
const m = /^\+\+\+ b\/(.+)$/.exec(raw);
|
|
56
|
+
file = m?.[1] ?? '';
|
|
57
|
+
}
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
// Context lines consume a new-file line number; `-` lines don't.
|
|
61
|
+
if (raw.startsWith(' ')) {
|
|
62
|
+
newLine++;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (!raw.startsWith('+') || file === '')
|
|
66
|
+
continue;
|
|
67
|
+
const text = raw.slice(1);
|
|
68
|
+
for (const { cls, re, group } of PATTERNS) {
|
|
69
|
+
const m = re.exec(text);
|
|
70
|
+
if (m === null)
|
|
71
|
+
continue;
|
|
72
|
+
const literal = group !== undefined ? (m[group] ?? m[0]) : m[0];
|
|
73
|
+
out.push({
|
|
74
|
+
file,
|
|
75
|
+
line: newLine,
|
|
76
|
+
patternClass: cls,
|
|
77
|
+
contextExcerpt: text.replaceAll(literal, '***'),
|
|
78
|
+
literal,
|
|
79
|
+
rawText: text,
|
|
80
|
+
});
|
|
81
|
+
break; // first matching class wins — one candidate per line
|
|
82
|
+
}
|
|
83
|
+
newLine++;
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
function maskFindingMessage(c, adjudicated) {
|
|
88
|
+
const verdict = adjudicated
|
|
89
|
+
? 'live-looking credential'
|
|
90
|
+
: 'secret-shaped literal (unadjudicated — decision model unavailable)';
|
|
91
|
+
return (`L${c.line}: ${adjudicated ? '🔴' : '🟡'} ${adjudicated ? 'bug' : 'risk'}: ` +
|
|
92
|
+
`${verdict} (${c.patternClass}) added in this PR at \`${c.file}\`. ` +
|
|
93
|
+
`Rotate it and purge it from history.`);
|
|
94
|
+
}
|
|
95
|
+
/** `git cat-file -e` + shallow-fetch fallback — mirrors probe/queue.ts. */
|
|
96
|
+
async function ensureBaseObject(exec, cwd, baseSha, token) {
|
|
97
|
+
const have = await exec('git', ['-C', cwd, 'cat-file', '-e', `${baseSha}^{commit}`], 15_000);
|
|
98
|
+
if (have.code === 0)
|
|
99
|
+
return true;
|
|
100
|
+
if (token === undefined)
|
|
101
|
+
return false;
|
|
102
|
+
// Auth rides env config like actions/checkout's extraheader — keeps the
|
|
103
|
+
// token out of process argv where co-tenant jobs could scrape /proc.
|
|
104
|
+
const fetched = await exec('git', ['-C', cwd, 'fetch', '--depth', '1', 'origin', baseSha], 60_000, {
|
|
105
|
+
GIT_CONFIG_COUNT: '1',
|
|
106
|
+
GIT_CONFIG_KEY_0: 'http.https://github.com/.extraheader',
|
|
107
|
+
GIT_CONFIG_VALUE_0: `AUTHORIZATION: basic ${Buffer.from(`x-access-token:${token}`).toString('base64')}`,
|
|
108
|
+
});
|
|
109
|
+
return fetched.code === 0;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Materialize the merge-base diff locally — the PR-files API `patch`
|
|
113
|
+
* field omits large/binary files, so the API diff is not a complete
|
|
114
|
+
* scan surface.
|
|
115
|
+
*/
|
|
116
|
+
export async function materializeMergeBaseDiff(opts) {
|
|
117
|
+
const exec = opts.exec ?? defaultExec;
|
|
118
|
+
const have = await ensureBaseObject(exec, opts.cwd, opts.baseSha, opts.token);
|
|
119
|
+
if (!have) {
|
|
120
|
+
return { skipped: `base ${opts.baseSha.slice(0, 12)} not available locally and unfetchable` };
|
|
121
|
+
}
|
|
122
|
+
// core.quotePath=false — the default C-escapes non-ASCII/odd-byte paths
|
|
123
|
+
// ("b/\"f\\303\\251e.ts\""), mangling `file` in findings.
|
|
124
|
+
const diff = await exec('git', ['-c', 'core.quotePath=false', '-C', opts.cwd, 'diff', `${opts.baseSha}..HEAD`], 60_000);
|
|
125
|
+
if (diff.code !== 0) {
|
|
126
|
+
return { skipped: `git diff failed: ${diff.stderr.trim().slice(0, 200)}` };
|
|
127
|
+
}
|
|
128
|
+
return { diff: diff.stdout };
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Full lane: scan candidates (capped), Jev-adjudicate when a client and
|
|
132
|
+
* threshold are available, and emit masked findings + audit records.
|
|
133
|
+
* `client === undefined` (decisionModel unset) or any DecisionError →
|
|
134
|
+
* every candidate unadjudicated — regex-only mode, never silence.
|
|
135
|
+
*/
|
|
136
|
+
export async function scanSecrets(opts) {
|
|
137
|
+
const threshold = opts.threshold ?? DEFAULT_SECRETS_THRESHOLD;
|
|
138
|
+
const all = scanDiffForSecrets(opts.diff);
|
|
139
|
+
const candidates = all.slice(0, MAX_CANDIDATES);
|
|
140
|
+
const overflow = all.length - candidates.length;
|
|
141
|
+
const pLiveByIdx = new Array(candidates.length);
|
|
142
|
+
let adjudicationFailed = false;
|
|
143
|
+
if (opts.client !== undefined && candidates.length > 0) {
|
|
144
|
+
try {
|
|
145
|
+
const questions = {};
|
|
146
|
+
candidates.forEach((c, i) => {
|
|
147
|
+
questions[`cand_${i}`] = {
|
|
148
|
+
type: 'noul',
|
|
149
|
+
instructions: `state[${i}]: is 'literal' a real, usable credential committed to source? ` +
|
|
150
|
+
'Answer no for documentation examples, placeholders, test fixtures, ' +
|
|
151
|
+
'and revoked or sample values.',
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
const state = candidates.map((c) => ({
|
|
155
|
+
file: c.file,
|
|
156
|
+
line: c.line,
|
|
157
|
+
lineText: c.rawText,
|
|
158
|
+
literal: c.literal,
|
|
159
|
+
}));
|
|
160
|
+
const { answers } = await opts.client.decide({
|
|
161
|
+
...(opts.model !== undefined ? { model: opts.model } : {}),
|
|
162
|
+
state,
|
|
163
|
+
questions,
|
|
164
|
+
});
|
|
165
|
+
candidates.forEach((_c, i) => {
|
|
166
|
+
const a = answers[`cand_${i}`];
|
|
167
|
+
pLiveByIdx[i] = a !== undefined && isNoulAnswer(a) ? a.noul : undefined;
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
catch (e) {
|
|
171
|
+
adjudicationFailed = true;
|
|
172
|
+
debug('secrets', `adjudication failed — degrading to regex-only: ${describeDecisionError(e)}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const findings = [];
|
|
176
|
+
const records = [];
|
|
177
|
+
candidates.forEach((c, i) => {
|
|
178
|
+
const pLive = pLiveByIdx[i];
|
|
179
|
+
const adjudicated = pLive !== undefined && !adjudicationFailed;
|
|
180
|
+
if (adjudicated && pLive !== undefined && pLive < threshold) {
|
|
181
|
+
records.push({
|
|
182
|
+
file: c.file,
|
|
183
|
+
line: c.line,
|
|
184
|
+
patternClass: c.patternClass,
|
|
185
|
+
adjudicated: true,
|
|
186
|
+
pLive,
|
|
187
|
+
suppressed: true,
|
|
188
|
+
});
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
findings.push({
|
|
192
|
+
file: c.file,
|
|
193
|
+
line: c.line,
|
|
194
|
+
severity: adjudicated ? 'bug' : 'risk',
|
|
195
|
+
category: 'security',
|
|
196
|
+
message: maskFindingMessage(c, adjudicated),
|
|
197
|
+
});
|
|
198
|
+
records.push({
|
|
199
|
+
file: c.file,
|
|
200
|
+
line: c.line,
|
|
201
|
+
patternClass: c.patternClass,
|
|
202
|
+
adjudicated,
|
|
203
|
+
...(pLive !== undefined ? { pLive } : {}),
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
// Candidates past the cap were never adjudicated — a real secret could
|
|
207
|
+
// sit in the overflow. Surface that gap as a finding, not just a count.
|
|
208
|
+
if (overflow > 0) {
|
|
209
|
+
findings.push({
|
|
210
|
+
file: '-',
|
|
211
|
+
line: 0,
|
|
212
|
+
severity: 'risk',
|
|
213
|
+
category: 'security',
|
|
214
|
+
message: `L0: 🟡 risk: ${overflow} secret-shaped literal(s) exceeded the ` +
|
|
215
|
+
`${MAX_CANDIDATES}-candidate adjudication cap and were not evaluated — ` +
|
|
216
|
+
'review the diff for secrets manually.',
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
return { findings, records, overflow };
|
|
220
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { type DecisionClient } from '../vision/decisions.js';
|
|
2
|
+
declare const RISK_AREA_CRITERIA: {
|
|
3
|
+
readonly auth: "authentication, authorization, tokens, sessions, permissions";
|
|
4
|
+
readonly billing: "payments, invoices, usage metering, cost accounting";
|
|
5
|
+
readonly data: "persistence, migrations, integrity, serialization, caching";
|
|
6
|
+
readonly ops: "CI, deploy, infra, configuration, tooling, observability";
|
|
7
|
+
readonly none: "no meaningful risk area in this diff";
|
|
8
|
+
};
|
|
9
|
+
export declare const TRIAGE_AREAS: TriageArea[];
|
|
10
|
+
export type TriageArea = keyof typeof RISK_AREA_CRITERIA;
|
|
11
|
+
export interface TriageRecord {
|
|
12
|
+
mode: 'annotate' | 'route';
|
|
13
|
+
/** noul 0–1 — does this PR warrant careful review. */
|
|
14
|
+
needsDeepReview?: number;
|
|
15
|
+
/** score 1–5 blast-radius rubric. */
|
|
16
|
+
risk?: number;
|
|
17
|
+
topRiskArea?: TriageArea;
|
|
18
|
+
/** Choice-answer confidence when the API provides one. */
|
|
19
|
+
topRiskAreaConfidence?: number;
|
|
20
|
+
/** Chars of diff evidence Jev saw — route mode won't downgrade on 0. */
|
|
21
|
+
diffExcerptChars?: number;
|
|
22
|
+
/** decide() failed or answers failed validation — degrade-open marker. */
|
|
23
|
+
unadjudicated?: boolean;
|
|
24
|
+
/** Decision model that produced (or attempted) the record. */
|
|
25
|
+
model: string;
|
|
26
|
+
}
|
|
27
|
+
export interface TriageState {
|
|
28
|
+
title: string;
|
|
29
|
+
body: string;
|
|
30
|
+
files: string[];
|
|
31
|
+
totalFiles: number;
|
|
32
|
+
diffExcerpt: string;
|
|
33
|
+
}
|
|
34
|
+
export declare function buildTriageState(opts: {
|
|
35
|
+
title?: string | undefined;
|
|
36
|
+
body?: string | undefined;
|
|
37
|
+
files: {
|
|
38
|
+
filename: string;
|
|
39
|
+
patch?: string;
|
|
40
|
+
}[];
|
|
41
|
+
}): TriageState;
|
|
42
|
+
export declare function triagePr(opts: {
|
|
43
|
+
client: DecisionClient;
|
|
44
|
+
model?: string;
|
|
45
|
+
state: TriageState;
|
|
46
|
+
mode: 'annotate' | 'route';
|
|
47
|
+
}): Promise<TriageRecord>;
|
|
48
|
+
/**
|
|
49
|
+
* 'route' mode model selection — cheap tier only on a clear low-risk
|
|
50
|
+
* signal (risk ≤ 2 AND deep-review < 0.5) backed by actual diff
|
|
51
|
+
* evidence. Any ambiguity (missing fields, unadjudicated, unset
|
|
52
|
+
* lowRiskModel, or a title/body-only triage — fully attacker-steerable
|
|
53
|
+
* text) keeps the configured model: coverage stays constant and the
|
|
54
|
+
* expensive path is the default.
|
|
55
|
+
*/
|
|
56
|
+
export declare function routeModel(opts: {
|
|
57
|
+
record: TriageRecord | undefined;
|
|
58
|
+
configured: string;
|
|
59
|
+
lowRiskModel: string | undefined;
|
|
60
|
+
}): {
|
|
61
|
+
model: string;
|
|
62
|
+
reason: string;
|
|
63
|
+
};
|
|
64
|
+
/** U9 ordering signal consumed by the probe lane. */
|
|
65
|
+
export interface TriageAreaSignal {
|
|
66
|
+
area: TriageArea;
|
|
67
|
+
confidence: number;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* U9 — the probe lane's advisory ordering signal. Present only when
|
|
71
|
+
* triage adjudicated a real area with its confidence attached; 'none'
|
|
72
|
+
* is the null-area sentinel, not an ordering signal, and the
|
|
73
|
+
* confidence floor itself is queue policy (MIN_AREA_CONFIDENCE).
|
|
74
|
+
*/
|
|
75
|
+
export declare function triageAreaSignal(rec: TriageRecord | undefined): TriageAreaSignal | undefined;
|
|
76
|
+
export {};
|