Skip to content

Commit e6dbbd6

Browse files
feat(doctor-core): add --push mode with privacy-stripped findings
Adds a new push module that strips Diagnostic[] down to the 6 fields the SaaS receives (file, line, col, ruleId, severity, category) and POSTs the payload with Content-Type: application/json and x-api-key headers. Privacy boundary is enforced in stripFindings itself (defense in depth): the SaaS schema also rejects extras, but the CLI never sends them. The category is looked up from the existing RULE_REGISTRY, not derived from the ruleId prefix. Unknown ruleIds get category='unknown'. CI fields (commitSha, branch, prNumber) are read from GITHUB_SHA, GITHUB_REF_NAME, and GITHUB_PR_NUMBER env vars and only included when set. pushFindings never throws: HTTP 5xx, network errors, and abort timeouts all return { ok: false, error } so the audit's exit code is unaffected by SaaS downtime. A 5s AbortController timeout guards against hanging servers. 20 unit tests in tests/push.test.ts cover strip, fetch URL+headers+body shape, missing registry entries, 1MB codeSnippet non-leakage, 5xx, network error, non-Error throwables, and timeout behavior. Signed-off-by: Vinayak Kulkarni <19776877+vinayakkulkarni@users.noreply.github.com>
1 parent ad2a15f commit e6dbbd6

3 files changed

Lines changed: 593 additions & 0 deletions

File tree

packages/doctor-core/src/index.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,16 @@ export {
103103
} from './rule-registry.js';
104104
export { scoreDiagnostics } from './score.js';
105105
export type { ScoreBreakdownEntry, ScoreConfig, ScoreResult } from './score.js';
106+
export {
107+
buildPushPayload,
108+
pushFindings,
109+
stripFindings,
110+
type PushOptions,
111+
type PushPayload,
112+
type PushPayloadInput,
113+
type PushResult,
114+
type PushedFinding,
115+
} from './push.js';
106116
export type {
107117
Capability,
108118
Framework,

packages/doctor-core/src/push.ts

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { RULE_REGISTRY } from './rule-registry.js';
2+
import type { Diagnostic, Severity } from './types.js';
3+
4+
/**
5+
* The 6 fields the SaaS receives for each finding.
6+
* Everything else (message, codeSnippet, recommendation, etc.) is privacy-sensitive
7+
* and MUST be stripped before transmission.
8+
*/
9+
export interface PushedFinding {
10+
file: string;
11+
line: number;
12+
col: number;
13+
ruleId: string;
14+
severity: Severity;
15+
category: string;
16+
}
17+
18+
export interface PushPayloadInput {
19+
project: string;
20+
score: number;
21+
errorCount: number;
22+
warnCount: number;
23+
infoCount: number;
24+
findings: PushedFinding[];
25+
}
26+
27+
export interface PushPayload extends PushPayloadInput {
28+
commitSha?: string;
29+
branch?: string;
30+
prNumber?: number;
31+
}
32+
33+
export interface PushOptions {
34+
project: string;
35+
score: number;
36+
errorCount: number;
37+
warnCount: number;
38+
infoCount: number;
39+
diagnostics: Diagnostic[];
40+
url: string;
41+
apiKey: string;
42+
fetchImpl?: typeof fetch;
43+
/** Overrides for the request timeout (ms). Defaults to 5000. Test-only. */
44+
timeoutMs?: number;
45+
}
46+
47+
export type PushResult =
48+
| { ok: true; status?: number; error?: undefined }
49+
| { ok: false; status?: number; error: string };
50+
51+
const PUSH_TIMEOUT_MS = 5000;
52+
53+
const categoryByRuleId: ReadonlyMap<string, string> = new Map(
54+
RULE_REGISTRY.map((r) => [r.id, r.category]),
55+
);
56+
57+
function lookupCategory(ruleId: string): string {
58+
return categoryByRuleId.get(ruleId) ?? 'unknown';
59+
}
60+
61+
/**
62+
* Strip a list of Diagnostics down to the 6 allowed push fields.
63+
* Privacy boundary: this function is the single point of enforcement.
64+
* If you add a new privacy-sensitive field to Diagnostic, add it to the deny list here.
65+
*/
66+
export function stripFindings(diagnostics: Diagnostic[]): PushedFinding[] {
67+
const out: PushedFinding[] = new Array(diagnostics.length);
68+
for (let i = 0; i < diagnostics.length; i++) {
69+
const d = diagnostics[i]!;
70+
out[i] = {
71+
file: d.file,
72+
line: d.line,
73+
col: d.column,
74+
ruleId: d.ruleId,
75+
severity: d.severity,
76+
category: lookupCategory(d.ruleId),
77+
};
78+
}
79+
return out;
80+
}
81+
82+
function readOptionalEnv(name: string): string | undefined {
83+
const v = process.env[name];
84+
return v === undefined || v === '' ? undefined : v;
85+
}
86+
87+
function readPrNumber(): number | undefined {
88+
const raw = readOptionalEnv('GITHUB_PR_NUMBER');
89+
if (raw === undefined) return undefined;
90+
const n = Number(raw);
91+
return Number.isFinite(n) ? n : undefined;
92+
}
93+
94+
/**
95+
* Build the top-level POST body. CI fields (commitSha, branch, prNumber) are
96+
* included ONLY when the matching GITHUB_* env var is set.
97+
*/
98+
export function buildPushPayload(input: PushPayloadInput): PushPayload {
99+
const payload: PushPayload = {
100+
project: input.project,
101+
score: input.score,
102+
errorCount: input.errorCount,
103+
warnCount: input.warnCount,
104+
infoCount: input.infoCount,
105+
findings: input.findings,
106+
};
107+
const commitSha = readOptionalEnv('GITHUB_SHA');
108+
const branch = readOptionalEnv('GITHUB_REF_NAME');
109+
const prNumber = readPrNumber();
110+
if (commitSha !== undefined) payload.commitSha = commitSha;
111+
if (branch !== undefined) payload.branch = branch;
112+
if (prNumber !== undefined) payload.prNumber = prNumber;
113+
return payload;
114+
}
115+
116+
/**
117+
* Strip + POST findings to a SaaS endpoint. Never throws — returns
118+
* `{ ok: false, error }` on any HTTP / network failure so the audit's
119+
* exit code is not affected by SaaS downtime.
120+
*/
121+
export async function pushFindings(opts: PushOptions): Promise<PushResult> {
122+
const findings = stripFindings(opts.diagnostics);
123+
const payload = buildPushPayload({
124+
project: opts.project,
125+
score: opts.score,
126+
errorCount: opts.errorCount,
127+
warnCount: opts.warnCount,
128+
infoCount: opts.infoCount,
129+
findings,
130+
});
131+
132+
const fetchImpl: typeof fetch = opts.fetchImpl ?? fetch;
133+
const controller = new AbortController();
134+
const timeoutMs = opts.timeoutMs ?? PUSH_TIMEOUT_MS;
135+
const timer = setTimeout(() => controller.abort(), timeoutMs);
136+
timer.unref?.();
137+
138+
try {
139+
const res = await fetchImpl(opts.url, {
140+
method: 'POST',
141+
headers: {
142+
'Content-Type': 'application/json',
143+
'x-api-key': opts.apiKey,
144+
},
145+
body: JSON.stringify(payload),
146+
signal: controller.signal,
147+
});
148+
clearTimeout(timer);
149+
if (res.ok) {
150+
return { ok: true, status: res.status };
151+
}
152+
return {
153+
ok: false,
154+
status: res.status,
155+
error: `HTTP ${res.status} ${res.statusText}`.trim(),
156+
};
157+
} catch (err) {
158+
clearTimeout(timer);
159+
const message = err instanceof Error ? err.message : String(err);
160+
return { ok: false, error: message };
161+
}
162+
}

0 commit comments

Comments
 (0)