@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.
@@ -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:
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@prodcycle/prodcycle",
3
- "version": "0.6.11",
3
+ "version": "0.6.12",
4
4
  "description": "Multi-framework policy-as-code compliance scanner for infrastructure and application code.",
5
5
  "homepage": "https://docs.prodcycle.com",
6
6
  "repository": {