@prodcycle/prodcycle 0.6.11 → 0.6.12
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/dist/api-client.d.ts +5 -5
- package/dist/api-client.js +18 -8
- package/dist/index.js +11 -3
- package/dist/utils/vcs.d.ts +20 -0
- package/dist/utils/vcs.js +139 -0
- package/package.json +1 -1
package/dist/api-client.d.ts
CHANGED
|
@@ -88,7 +88,7 @@ export declare class ComplianceApiClient {
|
|
|
88
88
|
* '/v1/compliance/scans'`, silently falls back to the chunked-session
|
|
89
89
|
* flow so large-repo CI jobs don't have to know the difference.
|
|
90
90
|
*/
|
|
91
|
-
validate(files: Record<string, string>, frameworks: string[], options?: ScanOptions): Promise<ScanResult>;
|
|
91
|
+
validate(files: Record<string, string>, frameworks: string[], options?: ScanOptions, vcsLocalOnly?: string[]): Promise<ScanResult>;
|
|
92
92
|
/**
|
|
93
93
|
* Hook endpoint — small per-write call from coding agents. No
|
|
94
94
|
* suggestedEndpoint fallback because /hook keeps the historical 50 MB
|
|
@@ -102,7 +102,7 @@ export declare class ComplianceApiClient {
|
|
|
102
102
|
* 30 minutes by default — abandoned sessions self-clean via the
|
|
103
103
|
* stale-session reaper.
|
|
104
104
|
*/
|
|
105
|
-
openSession(frameworks: string[], options?: ScanOptions): Promise<{
|
|
105
|
+
openSession(frameworks: string[], options?: ScanOptions, vcsLocalOnly?: string[]): Promise<{
|
|
106
106
|
scanId: string;
|
|
107
107
|
chunkSizeBytes: number;
|
|
108
108
|
maxFilesPerChunk: number;
|
|
@@ -131,7 +131,7 @@ export declare class ComplianceApiClient {
|
|
|
131
131
|
* Caller can pre-set `chunkMaxBytes` / `chunkMaxFiles` on `options.config`
|
|
132
132
|
* to override the conservative defaults.
|
|
133
133
|
*/
|
|
134
|
-
validateChunked(files: Record<string, string>, frameworks: string[], options?: ScanOptions): Promise<ScanResult>;
|
|
134
|
+
validateChunked(files: Record<string, string>, frameworks: string[], options?: ScanOptions, vcsLocalOnly?: string[]): Promise<ScanResult>;
|
|
135
135
|
/**
|
|
136
136
|
* Some server versions of `POST /scans/:id/complete` return only the summary,
|
|
137
137
|
* leaving `findings` empty even when `summary.total > 0`. The findings are
|
|
@@ -153,7 +153,7 @@ export declare class ComplianceApiClient {
|
|
|
153
153
|
* `getScan(scanId)` until status is COMPLETED or FAILED. Useful for CI
|
|
154
154
|
* runners that don't want to hold a connection for a 60 s scan.
|
|
155
155
|
*/
|
|
156
|
-
validateAsync(files: Record<string, string>, frameworks: string[], options?: ScanOptions): Promise<{
|
|
156
|
+
validateAsync(files: Record<string, string>, frameworks: string[], options?: ScanOptions, vcsLocalOnly?: string[]): Promise<{
|
|
157
157
|
scanId: string;
|
|
158
158
|
}>;
|
|
159
159
|
/**
|
|
@@ -164,7 +164,7 @@ export declare class ComplianceApiClient {
|
|
|
164
164
|
* High-level helper: kicks off an async-validate, polls until terminal,
|
|
165
165
|
* returns the same shape as `validate()`.
|
|
166
166
|
*/
|
|
167
|
-
validateAndPoll(files: Record<string, string>, frameworks: string[], options?: ScanOptions): Promise<ScanResult>;
|
|
167
|
+
validateAndPoll(files: Record<string, string>, frameworks: string[], options?: ScanOptions, vcsLocalOnly?: string[]): Promise<ScanResult>;
|
|
168
168
|
private buildOptions;
|
|
169
169
|
/**
|
|
170
170
|
* Single HTTP request with:
|
package/dist/api-client.js
CHANGED
|
@@ -121,12 +121,17 @@ class ComplianceApiClient {
|
|
|
121
121
|
* '/v1/compliance/scans'`, silently falls back to the chunked-session
|
|
122
122
|
* flow so large-repo CI jobs don't have to know the difference.
|
|
123
123
|
*/
|
|
124
|
-
async validate(files, frameworks, options = {}) {
|
|
124
|
+
async validate(files, frameworks, options = {}, vcsLocalOnly = []) {
|
|
125
125
|
try {
|
|
126
126
|
return await this.request('POST', '/v1/compliance/validate', {
|
|
127
127
|
files,
|
|
128
128
|
frameworks,
|
|
129
129
|
options: this.buildOptions(options),
|
|
130
|
+
// Top-level hint (sibling of `files`/`frameworks`, NOT an option) so
|
|
131
|
+
// the scanner can downgrade secrets that exist only in the local
|
|
132
|
+
// working tree. Omitted entirely when empty so payloads are unchanged
|
|
133
|
+
// for repos without local-only files.
|
|
134
|
+
...(vcsLocalOnly.length > 0 && { vcsLocalOnly }),
|
|
130
135
|
});
|
|
131
136
|
}
|
|
132
137
|
catch (err) {
|
|
@@ -136,7 +141,7 @@ class ComplianceApiClient {
|
|
|
136
141
|
// Server says: this payload won't fit, use chunked sessions instead.
|
|
137
142
|
// Fall back transparently — the caller asked for `validate`, the
|
|
138
143
|
// semantics (single scanId with final findings) are preserved.
|
|
139
|
-
return this.validateChunked(files, frameworks, options);
|
|
144
|
+
return this.validateChunked(files, frameworks, options, vcsLocalOnly);
|
|
140
145
|
}
|
|
141
146
|
throw err;
|
|
142
147
|
}
|
|
@@ -161,10 +166,13 @@ class ComplianceApiClient {
|
|
|
161
166
|
* 30 minutes by default — abandoned sessions self-clean via the
|
|
162
167
|
* stale-session reaper.
|
|
163
168
|
*/
|
|
164
|
-
async openSession(frameworks, options = {}) {
|
|
169
|
+
async openSession(frameworks, options = {}, vcsLocalOnly = []) {
|
|
165
170
|
return this.request('POST', '/v1/compliance/scans', {
|
|
166
171
|
frameworks,
|
|
167
172
|
options: this.buildOptions(options),
|
|
173
|
+
// Sent once at session-open (NOT on appendChunk) — the scanner applies
|
|
174
|
+
// it to the whole chunked scan. Omitted when empty (see `validate`).
|
|
175
|
+
...(vcsLocalOnly.length > 0 && { vcsLocalOnly }),
|
|
168
176
|
});
|
|
169
177
|
}
|
|
170
178
|
/**
|
|
@@ -192,10 +200,10 @@ class ComplianceApiClient {
|
|
|
192
200
|
* Caller can pre-set `chunkMaxBytes` / `chunkMaxFiles` on `options.config`
|
|
193
201
|
* to override the conservative defaults.
|
|
194
202
|
*/
|
|
195
|
-
async validateChunked(files, frameworks, options = {}) {
|
|
203
|
+
async validateChunked(files, frameworks, options = {}, vcsLocalOnly = []) {
|
|
196
204
|
const chunkMaxBytes = options.config?.chunkMaxBytes ?? DEFAULT_CHUNK_MAX_BYTES;
|
|
197
205
|
const chunkMaxFiles = options.config?.chunkMaxFiles ?? DEFAULT_CHUNK_MAX_FILES;
|
|
198
|
-
const session = await this.openSession(frameworks, options);
|
|
206
|
+
const session = await this.openSession(frameworks, options, vcsLocalOnly);
|
|
199
207
|
const chunks = chunkFiles(files, chunkMaxBytes, chunkMaxFiles);
|
|
200
208
|
for (const chunk of chunks) {
|
|
201
209
|
await this.appendChunk(session.scanId, chunk);
|
|
@@ -281,11 +289,13 @@ class ComplianceApiClient {
|
|
|
281
289
|
* `getScan(scanId)` until status is COMPLETED or FAILED. Useful for CI
|
|
282
290
|
* runners that don't want to hold a connection for a 60 s scan.
|
|
283
291
|
*/
|
|
284
|
-
async validateAsync(files, frameworks, options = {}) {
|
|
292
|
+
async validateAsync(files, frameworks, options = {}, vcsLocalOnly = []) {
|
|
285
293
|
return this.request('POST', '/v1/compliance/validate?async=true', {
|
|
286
294
|
files,
|
|
287
295
|
frameworks,
|
|
288
296
|
options: this.buildOptions(options),
|
|
297
|
+
// Top-level local-only hint; omitted when empty (see `validate`).
|
|
298
|
+
...(vcsLocalOnly.length > 0 && { vcsLocalOnly }),
|
|
289
299
|
});
|
|
290
300
|
}
|
|
291
301
|
/**
|
|
@@ -298,8 +308,8 @@ class ComplianceApiClient {
|
|
|
298
308
|
* High-level helper: kicks off an async-validate, polls until terminal,
|
|
299
309
|
* returns the same shape as `validate()`.
|
|
300
310
|
*/
|
|
301
|
-
async validateAndPoll(files, frameworks, options = {}) {
|
|
302
|
-
const { scanId } = await this.validateAsync(files, frameworks, options);
|
|
311
|
+
async validateAndPoll(files, frameworks, options = {}, vcsLocalOnly = []) {
|
|
312
|
+
const { scanId } = await this.validateAsync(files, frameworks, options, vcsLocalOnly);
|
|
303
313
|
const deadline = Date.now() + ASYNC_POLL_TIMEOUT_MS;
|
|
304
314
|
// Always poll at least once, and always do one final poll *after*
|
|
305
315
|
// the last sleep before declaring a timeout — otherwise a scan that
|
package/dist/index.js
CHANGED
|
@@ -19,6 +19,7 @@ exports.scan = scan;
|
|
|
19
19
|
exports.gate = gate;
|
|
20
20
|
const api_client_1 = require("./api-client");
|
|
21
21
|
const fs_1 = require("./utils/fs");
|
|
22
|
+
const vcs_1 = require("./utils/vcs");
|
|
22
23
|
__exportStar(require("./api-client"), exports);
|
|
23
24
|
__exportStar(require("./formatters/table"), exports);
|
|
24
25
|
__exportStar(require("./formatters/prompt"), exports);
|
|
@@ -56,17 +57,24 @@ async function scan(params) {
|
|
|
56
57
|
summary: undefined,
|
|
57
58
|
};
|
|
58
59
|
}
|
|
60
|
+
// Compute the local-only files (git-ignored AND untracked) from the real
|
|
61
|
+
// `.git` in this checkout. The hosted API collects files in a sandbox
|
|
62
|
+
// without a real `.git`, so it can't derive this itself — we send it as a
|
|
63
|
+
// top-level `vcsLocalOnly` hint so the scanner downgrades secrets that live
|
|
64
|
+
// only in the local working tree (e.g. a developer's `.env`). Fail-safe:
|
|
65
|
+
// returns [] on any git error / non-repo, in which case nothing changes.
|
|
66
|
+
const vcsLocalOnly = await (0, vcs_1.computeVcsLocalOnly)(repoPath, Object.keys(files));
|
|
59
67
|
const client = new api_client_1.ComplianceApiClient(options.apiUrl, options.apiKey);
|
|
60
68
|
const mode = options.config?.mode ?? 'sync';
|
|
61
69
|
let response;
|
|
62
70
|
if (mode === 'async') {
|
|
63
|
-
response = await client.validateAndPoll(files, frameworks, options);
|
|
71
|
+
response = await client.validateAndPoll(files, frameworks, options, vcsLocalOnly);
|
|
64
72
|
}
|
|
65
73
|
else if (mode === 'chunked') {
|
|
66
|
-
response = await client.validateChunked(files, frameworks, options);
|
|
74
|
+
response = await client.validateChunked(files, frameworks, options, vcsLocalOnly);
|
|
67
75
|
}
|
|
68
76
|
else {
|
|
69
|
-
response = await client.validate(files, frameworks, options);
|
|
77
|
+
response = await client.validate(files, frameworks, options, vcsLocalOnly);
|
|
70
78
|
}
|
|
71
79
|
// Pull `scannerError` through if the API set it. Picking the field
|
|
72
80
|
// explicitly (rather than `...response`) so the CLI's public surface
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Repo-relative paths (subset of candidatePaths) that are local-only: git
|
|
3
|
+
* IGNORES them, does NOT track them, AND they were never committed in any
|
|
4
|
+
* reachable history. Async (non-blocking) variant of the engine's
|
|
5
|
+
* `computeVcsLocalOnly` in @prodcycle/compliance-code-scanner; the LOGIC is
|
|
6
|
+
* identical, so keep the two in sync. The hosted API (which collects in a
|
|
7
|
+
* sandbox without a real `.git`) relies on this hint to downgrade secrets in
|
|
8
|
+
* local-only files.
|
|
9
|
+
*
|
|
10
|
+
* Recall-safe by construction:
|
|
11
|
+
* - tracked files (even force-added past `.gitignore`) are filtered out before
|
|
12
|
+
* the ignore check, so a currently-committed secret is never local-only;
|
|
13
|
+
* - a file that was committed then `rm --cached`'d + gitignored is untracked
|
|
14
|
+
* AND ignored, yet still recoverable from `git log --all` — the history
|
|
15
|
+
* check excludes it so its secret stays critical (the real-leak case).
|
|
16
|
+
* Fail-safe: returns [] on any git error / non-repo / shallow clone — a
|
|
17
|
+
* truncated history can't prove a path was never committed, so we downgrade
|
|
18
|
+
* nothing rather than risk masking a leak.
|
|
19
|
+
*/
|
|
20
|
+
export declare function computeVcsLocalOnly(repoRoot: string, candidatePaths: string[]): Promise<string[]>;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.computeVcsLocalOnly = computeVcsLocalOnly;
|
|
7
|
+
const node_child_process_1 = require("node:child_process");
|
|
8
|
+
const node_fs_1 = require("node:fs");
|
|
9
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
10
|
+
const GIT_TIMEOUT_MS = 10_000;
|
|
11
|
+
const toPosix = (p) => p.split(node_path_1.default.sep).join('/');
|
|
12
|
+
/**
|
|
13
|
+
* Run `git -C <repoRoot> <args>` WITHOUT blocking the event loop — the CLI runs
|
|
14
|
+
* this mid-scan alongside async HTTP, and `git ls-files` on a large repo can
|
|
15
|
+
* take seconds. Optional `input` is written to stdin (for `check-ignore
|
|
16
|
+
* --stdin`). Resolves with stdout + exit code; rejects only when git cannot be
|
|
17
|
+
* spawned (e.g. not installed).
|
|
18
|
+
*/
|
|
19
|
+
function runGit(repoRoot, args, input) {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const child = (0, node_child_process_1.spawn)('git', ['-C', repoRoot, ...args], {
|
|
22
|
+
stdio: ['pipe', 'pipe', 'ignore'],
|
|
23
|
+
timeout: GIT_TIMEOUT_MS,
|
|
24
|
+
});
|
|
25
|
+
let stdout = '';
|
|
26
|
+
child.stdout.setEncoding('utf-8');
|
|
27
|
+
child.stdout.on('data', (chunk) => {
|
|
28
|
+
stdout += chunk;
|
|
29
|
+
});
|
|
30
|
+
child.once('error', reject); // git missing / spawn failure
|
|
31
|
+
child.once('close', (code) => resolve({ stdout, code: code ?? 1 }));
|
|
32
|
+
if (input !== undefined)
|
|
33
|
+
child.stdin.write(input);
|
|
34
|
+
child.stdin.end();
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Repo-relative paths (subset of candidatePaths) that are local-only: git
|
|
39
|
+
* IGNORES them, does NOT track them, AND they were never committed in any
|
|
40
|
+
* reachable history. Async (non-blocking) variant of the engine's
|
|
41
|
+
* `computeVcsLocalOnly` in @prodcycle/compliance-code-scanner; the LOGIC is
|
|
42
|
+
* identical, so keep the two in sync. The hosted API (which collects in a
|
|
43
|
+
* sandbox without a real `.git`) relies on this hint to downgrade secrets in
|
|
44
|
+
* local-only files.
|
|
45
|
+
*
|
|
46
|
+
* Recall-safe by construction:
|
|
47
|
+
* - tracked files (even force-added past `.gitignore`) are filtered out before
|
|
48
|
+
* the ignore check, so a currently-committed secret is never local-only;
|
|
49
|
+
* - a file that was committed then `rm --cached`'d + gitignored is untracked
|
|
50
|
+
* AND ignored, yet still recoverable from `git log --all` — the history
|
|
51
|
+
* check excludes it so its secret stays critical (the real-leak case).
|
|
52
|
+
* Fail-safe: returns [] on any git error / non-repo / shallow clone — a
|
|
53
|
+
* truncated history can't prove a path was never committed, so we downgrade
|
|
54
|
+
* nothing rather than risk masking a leak.
|
|
55
|
+
*/
|
|
56
|
+
async function computeVcsLocalOnly(repoRoot, candidatePaths) {
|
|
57
|
+
if (candidatePaths.length === 0)
|
|
58
|
+
return [];
|
|
59
|
+
const root = node_path_1.default.resolve(repoRoot);
|
|
60
|
+
let tracked;
|
|
61
|
+
try {
|
|
62
|
+
const { stdout, code } = await runGit(root, ['ls-files', '-z']);
|
|
63
|
+
if (code !== 0)
|
|
64
|
+
return []; // not a git work tree / git error → fail safe
|
|
65
|
+
tracked = new Set(stdout.split('\0').filter(Boolean));
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
return []; // git not available → fail safe
|
|
69
|
+
}
|
|
70
|
+
const candidates = [...new Set(candidatePaths.map(toPosix))].filter((p) => !tracked.has(p));
|
|
71
|
+
if (candidates.length === 0)
|
|
72
|
+
return [];
|
|
73
|
+
let ignoredUntracked;
|
|
74
|
+
try {
|
|
75
|
+
// check-ignore exits 0 when ≥1 path is ignored, 1 when none (stdout empty),
|
|
76
|
+
// 128 on error. Treat anything other than 0/1 as a failure → fail safe.
|
|
77
|
+
const { stdout, code } = await runGit(root, ['check-ignore', '--stdin', '-z'], candidates.join('\0'));
|
|
78
|
+
if (code !== 0 && code !== 1)
|
|
79
|
+
return [];
|
|
80
|
+
const ignored = new Set(stdout.split('\0').filter(Boolean));
|
|
81
|
+
ignoredUntracked = candidates.filter((p) => ignored.has(p));
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return [];
|
|
85
|
+
}
|
|
86
|
+
if (ignoredUntracked.length === 0)
|
|
87
|
+
return [];
|
|
88
|
+
// Truncated history can't disprove a past commit → keep everything critical.
|
|
89
|
+
// Detect shallowness via the marker file located with `--git-path shallow`
|
|
90
|
+
// (git ≥ 2.5) rather than `--is-shallow-repository` (git ≥ 2.15), so the
|
|
91
|
+
// downgrade feature isn't silently disabled on older git toolchains.
|
|
92
|
+
try {
|
|
93
|
+
const { stdout, code } = await runGit(root, ['rev-parse', '--git-path', 'shallow']);
|
|
94
|
+
if (code !== 0)
|
|
95
|
+
return []; // old/unsupported git → fail safe
|
|
96
|
+
const marker = stdout.trim();
|
|
97
|
+
if (!marker)
|
|
98
|
+
return [];
|
|
99
|
+
const abs = node_path_1.default.isAbsolute(marker) ? marker : node_path_1.default.join(root, marker);
|
|
100
|
+
if ((0, node_fs_1.existsSync)(abs))
|
|
101
|
+
return []; // shallow clone → downgrade nothing
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
// Exclude any candidate that appears in committed history (every branch, tag,
|
|
107
|
+
// remote-tracking ref). `--all` walks all refs; `--diff-filter=A` emits only
|
|
108
|
+
// ADDED entries so a wide commit contributes just the candidate's
|
|
109
|
+
// introduction (proving it was once in history) while keeping output small.
|
|
110
|
+
// `-m` decomposes merge commits into per-parent diffs — without it, a merge's
|
|
111
|
+
// COMBINED diff omits a file added on only one side of a true 2-parent merge
|
|
112
|
+
// (a recall hole); `-m` surfaces it as "A" against the mainline parent.
|
|
113
|
+
// `--no-renames` keeps names literal so a renamed-away path still matches its
|
|
114
|
+
// old name. The `-- <paths>` pathspec restricts the walk to our candidates.
|
|
115
|
+
try {
|
|
116
|
+
const { stdout, code } = await runGit(root, [
|
|
117
|
+
'log',
|
|
118
|
+
'--all',
|
|
119
|
+
'-m',
|
|
120
|
+
'--no-renames',
|
|
121
|
+
'--diff-filter=A',
|
|
122
|
+
'--format=',
|
|
123
|
+
'--name-only',
|
|
124
|
+
'-z',
|
|
125
|
+
'--',
|
|
126
|
+
...ignoredUntracked,
|
|
127
|
+
]);
|
|
128
|
+
if (code !== 0)
|
|
129
|
+
return []; // fail safe: assume in history
|
|
130
|
+
const candSet = new Set(ignoredUntracked);
|
|
131
|
+
// -z separates with NUL; an empty --format still emits stray newlines. Split
|
|
132
|
+
// on both and keep only candidate tokens (membership is all we need).
|
|
133
|
+
const everCommitted = new Set(stdout.split(/[\0\n]/).filter((t) => candSet.has(t)));
|
|
134
|
+
return ignoredUntracked.filter((p) => !everCommitted.has(p));
|
|
135
|
+
}
|
|
136
|
+
catch {
|
|
137
|
+
return [];
|
|
138
|
+
}
|
|
139
|
+
}
|
package/package.json
CHANGED