@prism-d1/cli 1.2.0 → 1.4.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/dist/assets/github-workflows/prism-eval-gate-kiro.yml +2 -2
- package/dist/assets/infra/bin/app.ts +61 -37
- package/dist/assets/infra/lib/constructs/security-agent-construct.ts +35 -30
- package/dist/assets/infra/lib/metrics-pipeline-stack.ts +2 -110
- package/dist/assets/infra/lib/security-agent-resources.ts +136 -0
- package/dist/assets/infra/lib/security-agent-stack.ts +69 -0
- package/dist/assets/pentest-cdk/bin/app.ts +12 -0
- package/dist/assets/pentest-cdk/cdk.json +7 -0
- package/dist/assets/pentest-cdk/lib/pentest-target-stack.ts +206 -0
- package/dist/assets/pentest-cdk/package.json +18 -0
- package/dist/assets/pentest-cdk/tsconfig.json +12 -0
- package/dist/src/aws/advice.d.ts +33 -0
- package/dist/src/aws/advice.d.ts.map +1 -0
- package/dist/src/aws/advice.js +174 -0
- package/dist/src/aws/advice.js.map +1 -0
- package/dist/src/aws/check.d.ts +64 -0
- package/dist/src/aws/check.d.ts.map +1 -0
- package/dist/src/aws/check.js +269 -0
- package/dist/src/aws/check.js.map +1 -0
- package/dist/src/aws/connect.d.ts +65 -0
- package/dist/src/aws/connect.d.ts.map +1 -0
- package/dist/src/aws/connect.js +250 -0
- package/dist/src/aws/connect.js.map +1 -0
- package/dist/src/aws/connection.d.ts +67 -0
- package/dist/src/aws/connection.d.ts.map +1 -0
- package/dist/src/aws/connection.js +137 -0
- package/dist/src/aws/connection.js.map +1 -0
- package/dist/src/aws/recommend.d.ts +70 -0
- package/dist/src/aws/recommend.d.ts.map +1 -0
- package/dist/src/aws/recommend.js +244 -0
- package/dist/src/aws/recommend.js.map +1 -0
- package/dist/src/aws/run-check.d.ts +31 -0
- package/dist/src/aws/run-check.d.ts.map +1 -0
- package/dist/src/aws/run-check.js +91 -0
- package/dist/src/aws/run-check.js.map +1 -0
- package/dist/src/codewatch/results.d.ts +155 -4
- package/dist/src/codewatch/results.d.ts.map +1 -1
- package/dist/src/codewatch/results.js +12 -2
- package/dist/src/codewatch/results.js.map +1 -1
- package/dist/src/commands/_top/version.d.ts +62 -0
- package/dist/src/commands/_top/version.d.ts.map +1 -0
- package/dist/src/commands/_top/version.js +175 -0
- package/dist/src/commands/_top/version.js.map +1 -0
- package/dist/src/commands/aws/check.d.ts +25 -0
- package/dist/src/commands/aws/check.d.ts.map +1 -0
- package/dist/src/commands/aws/check.js +42 -0
- package/dist/src/commands/aws/check.js.map +1 -0
- package/dist/src/commands/aws/connect.d.ts +16 -0
- package/dist/src/commands/aws/connect.d.ts.map +1 -0
- package/dist/src/commands/aws/connect.js +39 -0
- package/dist/src/commands/aws/connect.js.map +1 -0
- package/dist/src/commands/aws/recommend.d.ts +12 -0
- package/dist/src/commands/aws/recommend.d.ts.map +1 -0
- package/dist/src/commands/aws/recommend.js +21 -0
- package/dist/src/commands/aws/recommend.js.map +1 -0
- package/dist/src/commands/bedrock-protection/scan-account.d.ts.map +1 -1
- package/dist/src/commands/bedrock-protection/scan-account.js +14 -35
- package/dist/src/commands/bedrock-protection/scan-account.js.map +1 -1
- package/dist/src/commands/bedrock-protection/scan-org.d.ts.map +1 -1
- package/dist/src/commands/bedrock-protection/scan-org.js +12 -5
- package/dist/src/commands/bedrock-protection/scan-org.js.map +1 -1
- package/dist/src/commands/bootstrapper/setup-otel-sync.d.ts +12 -6
- package/dist/src/commands/bootstrapper/setup-otel-sync.d.ts.map +1 -1
- package/dist/src/commands/bootstrapper/setup-otel-sync.js +18 -409
- package/dist/src/commands/bootstrapper/setup-otel-sync.js.map +1 -1
- package/dist/src/commands/enable/codeburn.d.ts +59 -0
- package/dist/src/commands/enable/codeburn.d.ts.map +1 -0
- package/dist/src/commands/enable/codeburn.js +531 -0
- package/dist/src/commands/enable/codeburn.js.map +1 -0
- package/dist/src/commands/enable/security.d.ts +41 -0
- package/dist/src/commands/enable/security.d.ts.map +1 -0
- package/dist/src/commands/enable/security.js +128 -0
- package/dist/src/commands/enable/security.js.map +1 -0
- package/dist/src/commands/export/otel.d.ts +31 -0
- package/dist/src/commands/export/otel.d.ts.map +1 -0
- package/dist/src/commands/export/otel.js +52 -0
- package/dist/src/commands/export/otel.js.map +1 -0
- package/dist/src/commands/security/code-review.d.ts +22 -0
- package/dist/src/commands/security/code-review.d.ts.map +1 -0
- package/dist/src/commands/security/code-review.js +44 -0
- package/dist/src/commands/security/code-review.js.map +1 -0
- package/dist/src/commands/security/findings.d.ts +15 -0
- package/dist/src/commands/security/findings.d.ts.map +1 -0
- package/dist/src/commands/security/findings.js +33 -0
- package/dist/src/commands/security/findings.js.map +1 -0
- package/dist/src/commands/security/pentest.d.ts +18 -0
- package/dist/src/commands/security/pentest.d.ts.map +1 -0
- package/dist/src/commands/security/pentest.js +33 -0
- package/dist/src/commands/security/pentest.js.map +1 -0
- package/dist/src/commands/security/threat-model.d.ts +16 -0
- package/dist/src/commands/security/threat-model.d.ts.map +1 -0
- package/dist/src/commands/security/threat-model.js +25 -0
- package/dist/src/commands/security/threat-model.js.map +1 -0
- package/dist/src/commands/security/threats.d.ts +12 -0
- package/dist/src/commands/security/threats.d.ts.map +1 -0
- package/dist/src/commands/security/threats.js +24 -0
- package/dist/src/commands/security/threats.js.map +1 -0
- package/dist/src/commands/securityagent/setup.d.ts +19 -5
- package/dist/src/commands/securityagent/setup.d.ts.map +1 -1
- package/dist/src/commands/securityagent/setup.js +25 -189
- package/dist/src/commands/securityagent/setup.js.map +1 -1
- package/dist/src/dashboard/aws.d.ts +39 -0
- package/dist/src/dashboard/aws.d.ts.map +1 -0
- package/dist/src/dashboard/aws.js +35 -0
- package/dist/src/dashboard/aws.js.map +1 -0
- package/dist/src/dashboard/codewatch.d.ts.map +1 -1
- package/dist/src/dashboard/codewatch.js +2 -1
- package/dist/src/dashboard/codewatch.js.map +1 -1
- package/dist/src/dashboard/page.d.ts.map +1 -1
- package/dist/src/dashboard/page.js +193 -5
- package/dist/src/dashboard/page.js.map +1 -1
- package/dist/src/dashboard/payload.d.ts +11 -5
- package/dist/src/dashboard/payload.d.ts.map +1 -1
- package/dist/src/dashboard/payload.js +7 -2
- package/dist/src/dashboard/payload.js.map +1 -1
- package/dist/src/dashboard/security.d.ts +130 -0
- package/dist/src/dashboard/security.d.ts.map +1 -0
- package/dist/src/dashboard/security.js +144 -0
- package/dist/src/dashboard/security.js.map +1 -0
- package/dist/src/dashboard/setup.d.ts +15 -0
- package/dist/src/dashboard/setup.d.ts.map +1 -1
- package/dist/src/dashboard/setup.js +20 -3
- package/dist/src/dashboard/setup.js.map +1 -1
- package/dist/src/dashboard/usage.js +1 -1
- package/dist/src/dashboard/usage.js.map +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +9 -2
- package/dist/src/index.js.map +1 -1
- package/dist/src/security/cfn-diff.d.ts +57 -0
- package/dist/src/security/cfn-diff.d.ts.map +1 -0
- package/dist/src/security/cfn-diff.js +112 -0
- package/dist/src/security/cfn-diff.js.map +1 -0
- package/dist/src/security/errors.d.ts +45 -0
- package/dist/src/security/errors.d.ts.map +1 -0
- package/dist/src/security/errors.js +116 -0
- package/dist/src/security/errors.js.map +1 -0
- package/dist/src/security/marker.d.ts +46 -0
- package/dist/src/security/marker.d.ts.map +1 -0
- package/dist/src/security/marker.js +120 -0
- package/dist/src/security/marker.js.map +1 -0
- package/dist/src/security/pentest-dockerfile.d.ts +8 -0
- package/dist/src/security/pentest-dockerfile.d.ts.map +1 -0
- package/dist/src/security/pentest-dockerfile.js +163 -0
- package/dist/src/security/pentest-dockerfile.js.map +1 -0
- package/dist/src/security/pentest.d.ts +233 -0
- package/dist/src/security/pentest.d.ts.map +1 -0
- package/dist/src/security/pentest.js +1057 -0
- package/dist/src/security/pentest.js.map +1 -0
- package/dist/src/security/repo.d.ts +25 -0
- package/dist/src/security/repo.d.ts.map +1 -0
- package/dist/src/security/repo.js +38 -0
- package/dist/src/security/repo.js.map +1 -0
- package/dist/src/security/review.d.ts +202 -0
- package/dist/src/security/review.d.ts.map +1 -0
- package/dist/src/security/review.js +706 -0
- package/dist/src/security/review.js.map +1 -0
- package/dist/src/security/setup.d.ts +209 -0
- package/dist/src/security/setup.d.ts.map +1 -0
- package/dist/src/security/setup.js +742 -0
- package/dist/src/security/setup.js.map +1 -0
- package/dist/src/security/threat-model.d.ts +133 -0
- package/dist/src/security/threat-model.d.ts.map +1 -0
- package/dist/src/security/threat-model.js +653 -0
- package/dist/src/security/threat-model.js.map +1 -0
- package/dist/src/utils/audit-account.d.ts +50 -0
- package/dist/src/utils/audit-account.d.ts.map +1 -0
- package/dist/src/utils/audit-account.js +69 -0
- package/dist/src/utils/audit-account.js.map +1 -0
- package/dist/src/utils/audit-iam.d.ts +1 -1
- package/dist/src/utils/audit-iam.d.ts.map +1 -1
- package/dist/src/utils/audit-iam.js +11 -7
- package/dist/src/utils/audit-iam.js.map +1 -1
- package/dist/src/utils/exec.d.ts +6 -0
- package/dist/src/utils/exec.d.ts.map +1 -1
- package/dist/src/utils/exec.js +12 -1
- package/dist/src/utils/exec.js.map +1 -1
- package/dist/src/utils/otel-sync-schedule.d.ts +19 -0
- package/dist/src/utils/otel-sync-schedule.d.ts.map +1 -1
- package/dist/src/utils/otel-sync-schedule.js +24 -2
- package/dist/src/utils/otel-sync-schedule.js.map +1 -1
- package/package.json +3 -3
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `prism-cli security code-review` and `prism-cli security findings`: run an
|
|
3
|
+
* AWS Security Agent code review job on demand, and read its findings.
|
|
4
|
+
*
|
|
5
|
+
* Same calls as the CI eval gate's Continuum step:
|
|
6
|
+
* start-code-review-job (--diff-source s3Uri for a diff scan; without it the
|
|
7
|
+
* code review's own source asset is reviewed), batch-get-code-review-jobs,
|
|
8
|
+
* list-findings (--code-review-job-id), plus batch-get-findings for each
|
|
9
|
+
* finding's description and code location, and
|
|
10
|
+
* list-code-review-jobs-for-code-review for "the latest job".
|
|
11
|
+
*
|
|
12
|
+
* Account, region, credentials, the identity block and error messages are the
|
|
13
|
+
* ones `enable security` uses. The only AWS writes are the S3 snapshot upload
|
|
14
|
+
* (skipped when identical) and starting the job; `security findings` makes
|
|
15
|
+
* none. Results go to .prism/results/ with kind security-review, so the
|
|
16
|
+
* dashboard can show them without credentials.
|
|
17
|
+
*/
|
|
18
|
+
import { realpathSync, statSync } from 'node:fs';
|
|
19
|
+
import { basename, join, resolve } from 'node:path';
|
|
20
|
+
import { makeResult, readResultEntries, writeResult } from '../codewatch/results.js';
|
|
21
|
+
import { explainAwsError, redact } from './errors.js';
|
|
22
|
+
import { MARKER_PATH, readLocalMarker } from './marker.js';
|
|
23
|
+
import { repoZipKey, SSM_PREFIX } from './repo.js';
|
|
24
|
+
import { Abort, call, getParam, md5, preflight, printIdentity, resolveRegion, resolveRepo, NO_REGION_MESSAGE, } from './setup.js';
|
|
25
|
+
export const PARAM_AGENT_SPACE = `${SSM_PREFIX}/agent-space-id`;
|
|
26
|
+
export const PARAM_SCAN_BUCKET = `${SSM_PREFIX}/scan-bucket`;
|
|
27
|
+
/** Terminal job states (the CI gate breaks on the same three). */
|
|
28
|
+
export const TERMINAL = new Set(['COMPLETED', 'FAILED', 'STOPPED']);
|
|
29
|
+
/** Poll delays: 10 s, doubling, capped at 60 s. */
|
|
30
|
+
export const POLL_FIRST_MS = 10_000;
|
|
31
|
+
export const POLL_MAX_MS = 60_000;
|
|
32
|
+
export const DEFAULT_TIMEOUT_MIN = 30;
|
|
33
|
+
/** A failed job poll is retried this many times, after 2 s, 4 s and 8 s. */
|
|
34
|
+
export const POLL_RETRIES = 3;
|
|
35
|
+
export const RETRY_FIRST_MS = 2_000;
|
|
36
|
+
/** Findings per list-findings page, and ids per batch-get-findings call (assumed limit). */
|
|
37
|
+
export const FINDINGS_PAGE = 100;
|
|
38
|
+
export const DETAILS_BATCH = 25;
|
|
39
|
+
/** Printed description length. */
|
|
40
|
+
const DESCRIPTION_CHARS = 200;
|
|
41
|
+
/** Security Agent risk levels, most severe first. */
|
|
42
|
+
export const RISK_ORDER = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFORMATIONAL', 'UNKNOWN'];
|
|
43
|
+
const RISK_RANK = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, INFORMATIONAL: 0, UNKNOWN: 0 };
|
|
44
|
+
export const FAIL_ON_LEVELS = ['critical', 'high', 'medium', 'low'];
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Setup lookup
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
export const NOT_SET_UP = 'run prism-cli enable security first';
|
|
49
|
+
/**
|
|
50
|
+
* Agent space and code review for this repo. The SSM parameters (written by
|
|
51
|
+
* `enable security` and the CI gate, in the account itself) are the source of
|
|
52
|
+
* truth: a file in the repo can be committed by anyone, so it must never
|
|
53
|
+
* choose where a developer's code is uploaded and reviewed.
|
|
54
|
+
*
|
|
55
|
+
* - The legacy `security` block in the committable `.prism/config.json` is
|
|
56
|
+
* never read here.
|
|
57
|
+
* - `.prism/security.local.json` is only a fallback when SSM has no value,
|
|
58
|
+
* only when git does not track it, and only for this account and region.
|
|
59
|
+
* - When SSM and the marker disagree, SSM wins and the difference is shown.
|
|
60
|
+
*/
|
|
61
|
+
export function resolveSetup(ctx, id, repo) {
|
|
62
|
+
const space = getParam(ctx, PARAM_AGENT_SPACE);
|
|
63
|
+
const review = getParam(ctx, repo.param);
|
|
64
|
+
let local = readLocalMarker(repo.root);
|
|
65
|
+
if (local && ctx.deps.git(['-C', repo.root, 'ls-files', '--error-unmatch', MARKER_PATH]).ok) {
|
|
66
|
+
ctx.deps.log(`Warning: ignoring ${MARKER_PATH} because git tracks it; a committed file cannot choose the code review.`);
|
|
67
|
+
local = null;
|
|
68
|
+
}
|
|
69
|
+
if (local && (local.account !== id.account || local.region !== ctx.opts.region))
|
|
70
|
+
local = null;
|
|
71
|
+
if (space && review) {
|
|
72
|
+
if (local && (local.agentSpaceId !== space || local.codeReviewId !== review)) {
|
|
73
|
+
ctx.deps.log(`Warning: ${MARKER_PATH} names agent space ${local.agentSpaceId} and code review ${local.codeReviewId}, but SSM says ${space} and ${review}; using SSM. Re-run prism-cli enable security to refresh the marker.`);
|
|
74
|
+
}
|
|
75
|
+
return { agentSpaceId: space, codeReviewId: review, source: `SSM ${repo.param}` };
|
|
76
|
+
}
|
|
77
|
+
if (local) {
|
|
78
|
+
ctx.deps.log(`Note: SSM has no ${space ? repo.param : PARAM_AGENT_SPACE}; using the IDs from the local marker ${MARKER_PATH}.`);
|
|
79
|
+
return { agentSpaceId: local.agentSpaceId, codeReviewId: local.codeReviewId, source: `local marker ${MARKER_PATH}` };
|
|
80
|
+
}
|
|
81
|
+
throw new Abort(`No Security Agent code review for ${repo.id} in account ${id.account}, region ${ctx.opts.region}: ${NOT_SET_UP}.`);
|
|
82
|
+
}
|
|
83
|
+
export function begin(input, deps, readOnly, say) {
|
|
84
|
+
const r = resolveRegion(input, deps);
|
|
85
|
+
if (!r) {
|
|
86
|
+
say(`Error: ${NO_REGION_MESSAGE}`);
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
const opts = { ...input, region: r.region, regionSource: r.source };
|
|
90
|
+
return { opts, deps: { ...deps, log: say }, cliVersion: deps.awsVersion(), readOnly };
|
|
91
|
+
}
|
|
92
|
+
const real = (p) => { try {
|
|
93
|
+
return realpathSync(p);
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return resolve(p);
|
|
97
|
+
} };
|
|
98
|
+
/**
|
|
99
|
+
* The repo these commands work on, strictly: `--path <dir>`, or the working
|
|
100
|
+
* directory, must be the top level of a git repository (compared by realpath,
|
|
101
|
+
* so a symlinked root is fine). A subdirectory or a non-repo is an error, never
|
|
102
|
+
* a walk up to some enclosing repo. Every later git call runs with -C <root>,
|
|
103
|
+
* and results are written under it.
|
|
104
|
+
*/
|
|
105
|
+
export function resolveRepoRoot(deps, path) {
|
|
106
|
+
const dir = path !== undefined ? resolve(deps.cwd(), path) : deps.cwd();
|
|
107
|
+
const top = deps.git(['-C', dir, 'rev-parse', '--show-toplevel']);
|
|
108
|
+
const root = top.ok ? top.stdout.trim() : '';
|
|
109
|
+
if (path !== undefined) {
|
|
110
|
+
if (!root)
|
|
111
|
+
throw new Abort(`--path ${dir} is not a git repository.`);
|
|
112
|
+
if (real(dir) !== real(root))
|
|
113
|
+
throw new Abort(`--path ${dir} is not the top level of its repository; the top level is ${root}.`);
|
|
114
|
+
return root;
|
|
115
|
+
}
|
|
116
|
+
if (!root)
|
|
117
|
+
throw new Abort('not a git repository; cd into one or pass --path <repo>');
|
|
118
|
+
if (real(dir) !== real(root))
|
|
119
|
+
throw new Abort(`run from the repository root (${root}) or pass --path <repo>`);
|
|
120
|
+
return root;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* The repo root, checked before anything else runs: a wrong directory prints
|
|
124
|
+
* one Error line and makes no AWS call (no notice, no region lookup, no STS).
|
|
125
|
+
*/
|
|
126
|
+
export function checkedRoot(deps, path, say) {
|
|
127
|
+
try {
|
|
128
|
+
return resolveRepoRoot(deps, path);
|
|
129
|
+
}
|
|
130
|
+
catch (e) {
|
|
131
|
+
if (e instanceof Abort) {
|
|
132
|
+
say(`Error: ${redact(e.message)}`);
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
throw e;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
export function needRepo(ctx, root) {
|
|
139
|
+
const repo = resolveRepo(ctx.opts, ctx.deps, root);
|
|
140
|
+
if ('error' in repo)
|
|
141
|
+
throw new Abort(repo.error);
|
|
142
|
+
return repo;
|
|
143
|
+
}
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
// Jobs and findings (reads)
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
function toJob(j) {
|
|
148
|
+
// shortcut: the AWS CLI returns untyped JSON; only these fields are read.
|
|
149
|
+
const steps = Array.isArray(j?.steps) ? j.steps : [];
|
|
150
|
+
const ctxs = Array.isArray(j?.executionContext) ? j.executionContext : [];
|
|
151
|
+
const running = steps.find((s) => s?.status === 'IN_PROGRESS');
|
|
152
|
+
const err = ctxs.filter((c) => c?.contextType === 'ERROR' || c?.contextType === 'CLIENT_ERROR').map((c) => String(c.context || '')).filter(Boolean);
|
|
153
|
+
return {
|
|
154
|
+
codeReviewJobId: String(j?.codeReviewJobId || ''),
|
|
155
|
+
status: String(j?.status || 'UNKNOWN'),
|
|
156
|
+
createdAt: j?.createdAt ? String(j.createdAt) : undefined,
|
|
157
|
+
updatedAt: j?.updatedAt ? String(j.updatedAt) : undefined,
|
|
158
|
+
step: running?.name ? String(running.name) : null,
|
|
159
|
+
reason: err.length ? err.join('; ') : null,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
const jobArgs = (setup, jobId) => ['securityagent', 'batch-get-code-review-jobs', '--agent-space-id', setup.agentSpaceId, '--code-review-job-ids', jobId];
|
|
163
|
+
function parseJob(json, setup, jobId) {
|
|
164
|
+
const j = (json?.codeReviewJobs || [])[0];
|
|
165
|
+
if (!j)
|
|
166
|
+
throw new Abort(`No code review job ${jobId} in agent space ${setup.agentSpaceId}.`);
|
|
167
|
+
return toJob(j);
|
|
168
|
+
}
|
|
169
|
+
export function getJob(ctx, setup, jobId) {
|
|
170
|
+
return parseJob(call(ctx, jobArgs(setup, jobId)).json, setup, jobId);
|
|
171
|
+
}
|
|
172
|
+
/** Every page of `--max-items` results, following NextToken (the CLI's) or nextToken (the API's). */
|
|
173
|
+
export function paged(ctx, args, key) {
|
|
174
|
+
const out = [];
|
|
175
|
+
let token;
|
|
176
|
+
for (let page = 0; page < 1000; page++) {
|
|
177
|
+
const r = call(ctx, [...args, '--max-items', String(FINDINGS_PAGE), ...(token ? ['--starting-token', token] : [])]);
|
|
178
|
+
out.push(...(r.json?.[key] || []));
|
|
179
|
+
token = r.json?.NextToken || r.json?.nextToken || undefined;
|
|
180
|
+
if (!token)
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
throw new Abort(`${args[1]} returned more than 1000 pages; stopping.`);
|
|
184
|
+
}
|
|
185
|
+
export function latestJob(ctx, setup) {
|
|
186
|
+
const jobs = paged(ctx, ['securityagent', 'list-code-review-jobs-for-code-review', '--agent-space-id', setup.agentSpaceId, '--code-review-id', setup.codeReviewId], 'codeReviewJobSummaries');
|
|
187
|
+
if (!jobs.length)
|
|
188
|
+
return null;
|
|
189
|
+
jobs.sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
|
|
190
|
+
return toJob(jobs[0]);
|
|
191
|
+
}
|
|
192
|
+
const CWE = /\bCWE-\d+\b/i;
|
|
193
|
+
/** The service reports paths inside its own sandbox: `/tmp/opt/source_code/code_sc-<id>/<repo path>`. */
|
|
194
|
+
const SANDBOX_PREFIX = /^.*?\/source_code\/code_sc-[^/]+\//;
|
|
195
|
+
/** A finding path as a repo-relative path: the sandbox prefix, the repo root and a leading ./ or / removed. */
|
|
196
|
+
export function repoRelative(raw, repoRoot) {
|
|
197
|
+
let p = raw.replace(/\\/g, '/');
|
|
198
|
+
p = p.replace(SANDBOX_PREFIX, '');
|
|
199
|
+
if (repoRoot) {
|
|
200
|
+
const root = repoRoot.replace(/\\/g, '/').replace(/\/+$/, '') + '/';
|
|
201
|
+
if (p.startsWith(root))
|
|
202
|
+
p = p.slice(root.length);
|
|
203
|
+
}
|
|
204
|
+
return p.replace(/^(\.\/)+/, '').replace(/^\/+/, '');
|
|
205
|
+
}
|
|
206
|
+
export function fetchFindings(ctx, setup, jobId, repoRoot) {
|
|
207
|
+
const summaries = paged(ctx, ['securityagent', 'list-findings', '--agent-space-id', setup.agentSpaceId, '--code-review-job-id', jobId], 'findingsSummaries');
|
|
208
|
+
const details = new Map();
|
|
209
|
+
for (let i = 0; i < summaries.length; i += DETAILS_BATCH) {
|
|
210
|
+
const ids = summaries.slice(i, i + DETAILS_BATCH).map((s) => String(s.findingId)).filter(Boolean);
|
|
211
|
+
if (!ids.length)
|
|
212
|
+
continue;
|
|
213
|
+
// Details are best effort: a caller without batch-get-findings still gets the summaries.
|
|
214
|
+
const r = ctx.deps.aws(['securityagent', 'batch-get-findings', '--agent-space-id', setup.agentSpaceId, '--finding-ids', ...ids], ctx.opts.region);
|
|
215
|
+
if (r.ok)
|
|
216
|
+
for (const f of r.json?.findings || [])
|
|
217
|
+
details.set(String(f.findingId), f);
|
|
218
|
+
}
|
|
219
|
+
return summaries.map((s) => {
|
|
220
|
+
const d = details.get(String(s.findingId)) || {};
|
|
221
|
+
const loc = Array.isArray(d.codeLocations) && d.codeLocations[0] ? d.codeLocations[0] : null;
|
|
222
|
+
const title = String(d.name || s.name || '(untitled finding)');
|
|
223
|
+
const riskType = String(d.riskType || s.riskType || '');
|
|
224
|
+
const description = String(d.description || '');
|
|
225
|
+
const cwe = (CWE.exec(riskType) || CWE.exec(title) || CWE.exec(description))?.[0]?.toUpperCase() ?? null;
|
|
226
|
+
return {
|
|
227
|
+
findingId: String(s.findingId || ''),
|
|
228
|
+
riskLevel: String(d.riskLevel || s.riskLevel || 'UNKNOWN').toUpperCase(),
|
|
229
|
+
title, riskType, cwe,
|
|
230
|
+
rawFile: loc?.filePath ? String(loc.filePath) : '',
|
|
231
|
+
file: loc?.filePath ? repoRelative(String(loc.filePath), repoRoot) : '',
|
|
232
|
+
line: typeof loc?.lineStart === 'number' ? loc.lineStart : null,
|
|
233
|
+
description,
|
|
234
|
+
};
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// Output
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
export function countByRisk(fs) {
|
|
241
|
+
const c = Object.fromEntries(RISK_ORDER.map((r) => [r, 0]));
|
|
242
|
+
for (const f of fs)
|
|
243
|
+
c[RISK_ORDER.includes(f.riskLevel) ? f.riskLevel : 'UNKNOWN']++;
|
|
244
|
+
return c;
|
|
245
|
+
}
|
|
246
|
+
const riskRank = (r) => RISK_RANK[r] ?? 0;
|
|
247
|
+
export function sortFindings(fs) {
|
|
248
|
+
return [...fs].sort((a, b) => riskRank(b.riskLevel) - riskRank(a.riskLevel) || a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0));
|
|
249
|
+
}
|
|
250
|
+
export function shortText(s) {
|
|
251
|
+
const one = redact(s).replace(/\s+/g, ' ').trim();
|
|
252
|
+
return one.length > DESCRIPTION_CHARS ? `${one.slice(0, DESCRIPTION_CHARS - 1)}...` : one;
|
|
253
|
+
}
|
|
254
|
+
export function totalsLine(c) {
|
|
255
|
+
const total = Object.values(c).reduce((a, b) => a + b, 0);
|
|
256
|
+
return `${total} finding${total === 1 ? '' : 's'}: ` + RISK_ORDER.map((r) => `${c[r]} ${r.toLowerCase()}`).join(', ');
|
|
257
|
+
}
|
|
258
|
+
/** Grouped by risk level, then file. */
|
|
259
|
+
export function printFindings(say, fs) {
|
|
260
|
+
const sorted = sortFindings(fs);
|
|
261
|
+
for (const level of RISK_ORDER) {
|
|
262
|
+
const group = sorted.filter((f) => f.riskLevel === level || (level === 'UNKNOWN' && !RISK_ORDER.includes(f.riskLevel)));
|
|
263
|
+
if (!group.length)
|
|
264
|
+
continue;
|
|
265
|
+
say(`${level} (${group.length})`);
|
|
266
|
+
for (const f of group) {
|
|
267
|
+
const where = f.file ? `${f.file}${f.line ? `:${f.line}` : ''}` : '(no location)';
|
|
268
|
+
say(` ${f.title} ${where}${f.cwe ? ` ${f.cwe}` : ''}`);
|
|
269
|
+
if (f.description)
|
|
270
|
+
say(` ${shortText(f.description)}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
say(totalsLine(countByRisk(fs)));
|
|
274
|
+
}
|
|
275
|
+
/** Security Agent risk -> the codewatch schema's three severities. */
|
|
276
|
+
function toSeverity(risk) {
|
|
277
|
+
return riskRank(risk) >= 3 ? 'high' : riskRank(risk) === 2 ? 'medium' : 'low';
|
|
278
|
+
}
|
|
279
|
+
/** The record code-review writes right after start-code-review-job, so a lost wait loses nothing. */
|
|
280
|
+
export function pendingResult(o) {
|
|
281
|
+
return makeResult({
|
|
282
|
+
kind: 'security-review', hook: 'manual', tool: 'security-agent', status: 'pending',
|
|
283
|
+
commit: o.tip ?? null, base: o.base ?? null, branch: o.branch ?? null, upstream: o.upstream ?? null,
|
|
284
|
+
started_at: o.startedAt, finished_at: o.startedAt,
|
|
285
|
+
security: {
|
|
286
|
+
job_id: o.job.codeReviewJobId, code_review_id: o.setup.codeReviewId, agent_space_id: o.setup.agentSpaceId,
|
|
287
|
+
region: o.region, account: o.account, scope: o.scope, job_status: o.job.status, counts: {}, context_commit: o.contextCommit ?? null,
|
|
288
|
+
service_created_at: o.job.createdAt ?? null, service_updated_at: null, service_duration_s: null, wall_s: null,
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
function scopeText(r) {
|
|
293
|
+
const tip = (r.commit || '').slice(0, 12);
|
|
294
|
+
const s = r.security?.scope;
|
|
295
|
+
return s === 'full' ? `the whole repo at ${tip}` : s === 'diff' ? `${r.upstream || 'base'}...${tip}` : 'scope unknown';
|
|
296
|
+
}
|
|
297
|
+
/** The fields of a pending record that its completion keeps. */
|
|
298
|
+
function fromPending(p) {
|
|
299
|
+
return { scope: p.security.scope, tip: p.commit, base: p.base, branch: p.branch, upstream: p.upstream, contextCommit: p.security.context_commit ?? null, startedAt: p.started_at };
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Pending records in this repo for this account, region and code review,
|
|
303
|
+
* newest first. Records from another account or code review are never
|
|
304
|
+
* completed with this one's findings.
|
|
305
|
+
*/
|
|
306
|
+
export function pendingReviews(root, account, region, codeReviewId) {
|
|
307
|
+
return readResultEntries(root)
|
|
308
|
+
.filter(({ result: r }) => r.kind === 'security-review' && r.status === 'pending' && r.security?.job_id
|
|
309
|
+
&& r.security.account === account && r.security.region === region && r.security.code_review_id === codeReviewId)
|
|
310
|
+
.sort((a, b) => b.result.started_at.localeCompare(a.result.started_at));
|
|
311
|
+
}
|
|
312
|
+
export function toResult(o) {
|
|
313
|
+
const counts = countByRisk(o.findings);
|
|
314
|
+
const ok = o.job.status === 'COMPLETED';
|
|
315
|
+
const findings = sortFindings(o.findings).map((f) => ({
|
|
316
|
+
severity: toSeverity(f.riskLevel), category: 'security', rule: f.cwe || f.riskType || f.title,
|
|
317
|
+
file: f.file, line: f.line, message: f.description ? `${f.title}: ${shortText(f.description)}` : f.title,
|
|
318
|
+
risk_level: f.riskLevel, title: f.title, cwe: f.cwe, finding_id: f.findingId, raw_file: f.rawFile,
|
|
319
|
+
}));
|
|
320
|
+
return makeResult({
|
|
321
|
+
kind: 'security-review', hook: 'manual', tool: 'security-agent',
|
|
322
|
+
status: !ok ? 'error' : findings.length ? 'findings' : 'ok',
|
|
323
|
+
commit: o.tip ?? null, base: o.base ?? null, branch: o.branch ?? null, upstream: o.upstream ?? null,
|
|
324
|
+
started_at: o.startedAt || o.job.createdAt || o.now.toISOString(),
|
|
325
|
+
finished_at: o.job.updatedAt || o.now.toISOString(),
|
|
326
|
+
findings,
|
|
327
|
+
summary: ok ? totalsLine(counts) : null,
|
|
328
|
+
reason: ok ? null : `job ${o.job.status}${o.job.reason ? `: ${redact(o.job.reason)}` : ''}`,
|
|
329
|
+
security: {
|
|
330
|
+
job_id: o.job.codeReviewJobId, code_review_id: o.setup.codeReviewId, agent_space_id: o.setup.agentSpaceId,
|
|
331
|
+
region: o.region, account: o.account, scope: o.scope, job_status: o.job.status, counts, context_commit: o.contextCommit ?? null,
|
|
332
|
+
service_created_at: o.job.createdAt ?? null, service_updated_at: o.job.updatedAt ?? null,
|
|
333
|
+
service_duration_s: serviceSeconds(o.job), wall_s: o.wallS ?? null,
|
|
334
|
+
},
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
/** updatedAt - createdAt of a finished job: the service's queue plus run time. null when not reported. */
|
|
338
|
+
export function serviceSeconds(job) {
|
|
339
|
+
if (!TERMINAL.has(job.status) || !job.createdAt || !job.updatedAt)
|
|
340
|
+
return null;
|
|
341
|
+
const a = Date.parse(job.createdAt), b = Date.parse(job.updatedAt);
|
|
342
|
+
return Number.isFinite(a) && Number.isFinite(b) && b >= a ? Math.round((b - a) / 1000) : null;
|
|
343
|
+
}
|
|
344
|
+
export function failOnHit(fs, failOn) {
|
|
345
|
+
if (!failOn)
|
|
346
|
+
return false;
|
|
347
|
+
const min = riskRank(failOn.toUpperCase());
|
|
348
|
+
return fs.some((f) => riskRank(f.riskLevel) >= min && riskRank(f.riskLevel) > 0);
|
|
349
|
+
}
|
|
350
|
+
export function parseLevel(flag, v) {
|
|
351
|
+
if (v === undefined)
|
|
352
|
+
return undefined;
|
|
353
|
+
if (!FAIL_ON_LEVELS.includes(v.toLowerCase()))
|
|
354
|
+
throw new Abort(`${flag} must be one of: ${FAIL_ON_LEVELS.join(', ')}`);
|
|
355
|
+
return v.toLowerCase();
|
|
356
|
+
}
|
|
357
|
+
export function parseTimeout(v, fallback = DEFAULT_TIMEOUT_MIN) {
|
|
358
|
+
if (v === undefined)
|
|
359
|
+
return fallback;
|
|
360
|
+
const n = Number(v);
|
|
361
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
362
|
+
throw new Abort(`--timeout must be a positive number of minutes, got "${v}"`);
|
|
363
|
+
return n;
|
|
364
|
+
}
|
|
365
|
+
const fetchLaterHint = (jobId) => `The job keeps running in AWS. Fetch its results later with: prism-cli security findings --job ${jobId}`;
|
|
366
|
+
const mmss = (ms) => `${String(Math.floor(ms / 60000)).padStart(2, '0')}:${String(Math.floor(ms / 1000) % 60).padStart(2, '0')}`;
|
|
367
|
+
/** Errors a retry cannot fix: a missing or malformed job. Credential, AccessDenied and old-CLI errors are excluded by kind. */
|
|
368
|
+
const NOT_TRANSIENT = /ResourceNotFound|ValidationException|NotFound/;
|
|
369
|
+
/**
|
|
370
|
+
* One poll, retried up to POLL_RETRIES times (2 s, 4 s, 8 s) when the AWS CLI
|
|
371
|
+
* fails in a way a retry can fix (throttling, a network error, any other
|
|
372
|
+
* non-zero exit that is not about credentials, permissions or the request).
|
|
373
|
+
* A final failure aborts with the fetch-later hint. null: Ctrl-C during a backoff.
|
|
374
|
+
*/
|
|
375
|
+
async function getJobRetrying(ctx, p, signal) {
|
|
376
|
+
const deps = ctx.deps;
|
|
377
|
+
const args = p.args;
|
|
378
|
+
for (let attempt = 0;; attempt++) {
|
|
379
|
+
const r = ctx.deps.aws(args, ctx.opts.region);
|
|
380
|
+
if (r.ok)
|
|
381
|
+
return p.parse(r.json);
|
|
382
|
+
const why = explainAwsError(args, r.raw, ctx.cliVersion);
|
|
383
|
+
if (why.kind !== 'other' || NOT_TRANSIENT.test(r.raw) || attempt >= POLL_RETRIES) {
|
|
384
|
+
const tries = attempt ? ` (after ${attempt} ${attempt === 1 ? 'retry' : 'retries'})` : '';
|
|
385
|
+
throw new Abort(`${why.message}${tries}\n${p.hint}`);
|
|
386
|
+
}
|
|
387
|
+
const wait = RETRY_FIRST_MS * 2 ** attempt;
|
|
388
|
+
if (deps.isTTY)
|
|
389
|
+
deps.ticker('');
|
|
390
|
+
ctx.deps.log(` Poll failed: ${why.message.split('\n')[0]}; retrying in ${wait / 1000}s (${attempt + 1}/${POLL_RETRIES})`);
|
|
391
|
+
await deps.sleep(wait, signal);
|
|
392
|
+
if (signal.aborted)
|
|
393
|
+
return null;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
/** Polls a code review job until a terminal state, the timeout, or Ctrl-C (which never cancels the job). */
|
|
397
|
+
export function pollJob(ctx, setup, first, timeoutMin) {
|
|
398
|
+
const jobId = first.codeReviewJobId;
|
|
399
|
+
return pollUntilDone(ctx, { args: jobArgs(setup, jobId), parse: (j) => parseJob(j, setup, jobId), hint: fetchLaterHint(jobId) }, first, timeoutMin);
|
|
400
|
+
}
|
|
401
|
+
/** Polls any job until a terminal state, the timeout, or Ctrl-C (which never cancels the job). */
|
|
402
|
+
export async function pollUntilDone(ctx, p, first, timeoutMin) {
|
|
403
|
+
const deps = ctx.deps;
|
|
404
|
+
const start = deps.now().getTime();
|
|
405
|
+
const limit = timeoutMin * 60_000;
|
|
406
|
+
const ac = new AbortController();
|
|
407
|
+
let interrupted = false;
|
|
408
|
+
const off = deps.onInterrupt(() => { interrupted = true; ac.abort(); });
|
|
409
|
+
let job = first;
|
|
410
|
+
let delay = POLL_FIRST_MS;
|
|
411
|
+
try {
|
|
412
|
+
while (!TERMINAL.has(job.status)) {
|
|
413
|
+
const elapsed = deps.now().getTime() - start;
|
|
414
|
+
if (elapsed >= limit)
|
|
415
|
+
return { kind: 'timeout', job };
|
|
416
|
+
await deps.sleep(Math.min(delay, limit - elapsed), ac.signal);
|
|
417
|
+
if (interrupted)
|
|
418
|
+
return { kind: 'interrupted', job };
|
|
419
|
+
delay = Math.min(delay * 2, POLL_MAX_MS);
|
|
420
|
+
const next = await getJobRetrying(ctx, p, ac.signal);
|
|
421
|
+
if (!next)
|
|
422
|
+
return { kind: 'interrupted', job };
|
|
423
|
+
job = next;
|
|
424
|
+
const line = `[${mmss(deps.now().getTime() - start)}] ${job.status}${job.step ? ` (${job.step})` : ''}`;
|
|
425
|
+
if (deps.isTTY)
|
|
426
|
+
deps.ticker(line);
|
|
427
|
+
else
|
|
428
|
+
ctx.deps.log(` ${line}`);
|
|
429
|
+
}
|
|
430
|
+
return { kind: 'done', job };
|
|
431
|
+
}
|
|
432
|
+
finally {
|
|
433
|
+
off();
|
|
434
|
+
if (deps.isTTY)
|
|
435
|
+
deps.ticker('');
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
function git(ctx, repo, args) {
|
|
439
|
+
const r = ctx.deps.git(['-C', repo.root, ...args]);
|
|
440
|
+
return r.ok ? r.stdout.trim() : null;
|
|
441
|
+
}
|
|
442
|
+
/** The pre-push base: the branch's upstream, else the remote's default branch. */
|
|
443
|
+
function resolveDiffBase(ctx, repo) {
|
|
444
|
+
const up = git(ctx, repo, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}']);
|
|
445
|
+
if (up) {
|
|
446
|
+
const sha = git(ctx, repo, ['rev-parse', '--verify', '--quiet', `${up}^{commit}`]);
|
|
447
|
+
if (sha)
|
|
448
|
+
return { base: sha, upstream: up };
|
|
449
|
+
}
|
|
450
|
+
const head = git(ctx, repo, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']);
|
|
451
|
+
if (head) {
|
|
452
|
+
const sha = git(ctx, repo, ['rev-parse', '--verify', '--quiet', head]);
|
|
453
|
+
if (sha)
|
|
454
|
+
return { base: sha, upstream: head.replace(/^refs\/remotes\//, '') };
|
|
455
|
+
}
|
|
456
|
+
throw new Abort('This branch has no upstream and the remote has no default branch to diff against. Push the branch, or review the whole repo with --full.');
|
|
457
|
+
}
|
|
458
|
+
function buildScope(ctx, repo, full) {
|
|
459
|
+
const tip = git(ctx, repo, ['rev-parse', 'HEAD']);
|
|
460
|
+
if (!tip)
|
|
461
|
+
throw new Abort('Could not read HEAD; is this a repository with at least one commit?');
|
|
462
|
+
const branch = git(ctx, repo, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
463
|
+
const dir = ctx.deps.makeTempDir();
|
|
464
|
+
const zip = join(dir, 'repo.zip');
|
|
465
|
+
const archive = () => {
|
|
466
|
+
// A zip of one commit is byte-identical run to run (file times are the commit time), so the MD5 skip works.
|
|
467
|
+
if (!ctx.deps.git(['-C', repo.root, 'archive', 'HEAD', '--format=zip', '-o', zip]).ok)
|
|
468
|
+
throw new Abort('git archive HEAD failed.');
|
|
469
|
+
return { file: zip, key: repoZipKey(repo.slug) };
|
|
470
|
+
};
|
|
471
|
+
if (full)
|
|
472
|
+
return { scope: 'full', tip, base: null, upstream: null, branch, context: archive(), diff: null, empty: false };
|
|
473
|
+
const { base, upstream } = resolveDiffBase(ctx, repo);
|
|
474
|
+
// The API requires a .patch object. --output writes straight to the file, so a large diff never passes through a buffer.
|
|
475
|
+
const file = join(dir, 'changes.patch');
|
|
476
|
+
if (!ctx.deps.git(['-C', repo.root, 'diff', '--no-color', '--no-ext-diff', `--output=${file}`, `${base}...${tip}`]).ok)
|
|
477
|
+
throw new Abort(`git diff ${upstream}...HEAD failed.`);
|
|
478
|
+
let size = 0;
|
|
479
|
+
try {
|
|
480
|
+
size = statSync(file).size;
|
|
481
|
+
}
|
|
482
|
+
catch {
|
|
483
|
+
throw new Abort('git diff wrote no patch file.');
|
|
484
|
+
}
|
|
485
|
+
const diff = { file, key: `diffs/${repo.slug}/local/${tip.slice(0, 12)}-${base.slice(0, 12)}.patch` };
|
|
486
|
+
if (size === 0)
|
|
487
|
+
return { scope: 'diff', tip, base, upstream, branch, context: { file: '', key: repoZipKey(repo.slug) }, diff, empty: true };
|
|
488
|
+
return { scope: 'diff', tip, base, upstream, branch, context: archive(), diff, empty: false };
|
|
489
|
+
}
|
|
490
|
+
/** Uploads a snapshot unless the object already has the same MD5. Returns whether it uploaded. */
|
|
491
|
+
export function upload(ctx, bucket, s) {
|
|
492
|
+
const sum = md5(s.file);
|
|
493
|
+
const head = call(ctx, ['s3api', 'head-object', '--bucket', bucket, '--key', s.key], ['404', 'NotFound', 'NoSuchKey']);
|
|
494
|
+
if (head.ok && String(head.json?.ETag || '').replace(/"/g, '') === sum)
|
|
495
|
+
return false;
|
|
496
|
+
call(ctx, ['s3api', 'put-object', '--bucket', bucket, '--key', s.key, '--body', s.file]);
|
|
497
|
+
return true;
|
|
498
|
+
}
|
|
499
|
+
// ---------------------------------------------------------------------------
|
|
500
|
+
// Commands
|
|
501
|
+
// ---------------------------------------------------------------------------
|
|
502
|
+
export const CODE_REVIEW_NOTICE = 'prism-cli security code-review uploads the repo at HEAD as the code review\'s source (repo/<slug>/latest.zip, which CI reviews also read) and, for a branch diff, a patch of your changes to the Security Agent scan bucket, and starts a code review job in the AWS account your credentials point to. Those are its only AWS writes; it may incur cost. The result is saved to .prism/results/.';
|
|
503
|
+
/** A FAILED / STOPPED job: saves the error result (in place over `id` when given) and prints why. Exit 1. */
|
|
504
|
+
function saveFailed(ctx, json, result, job, repo, id) {
|
|
505
|
+
const deps = ctx.deps;
|
|
506
|
+
const path = writeResult(repo.root, result, id);
|
|
507
|
+
if (json)
|
|
508
|
+
deps.out(JSON.stringify(result, null, 2) + '\n');
|
|
509
|
+
ctx.deps.log(`Error: job ${job.codeReviewJobId} ended ${job.status}${job.reason ? `: ${redact(job.reason)}` : ''}`);
|
|
510
|
+
ctx.deps.log(`Saved ${path}`);
|
|
511
|
+
return 1;
|
|
512
|
+
}
|
|
513
|
+
function finishFindings(ctx, json, result, findings, repo, minLevel, id) {
|
|
514
|
+
const deps = ctx.deps;
|
|
515
|
+
const shown = minLevel ? findings.filter((f) => riskRank(f.riskLevel) >= riskRank(minLevel.toUpperCase()) && riskRank(f.riskLevel) > 0) : findings;
|
|
516
|
+
const path = writeResult(repo.root, result, id);
|
|
517
|
+
if (json) {
|
|
518
|
+
deps.out(JSON.stringify(result, null, 2) + '\n');
|
|
519
|
+
deps.err(`Saved ${path}`);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
ctx.deps.log('');
|
|
523
|
+
if (shown.length)
|
|
524
|
+
printFindings(ctx.deps.log, shown);
|
|
525
|
+
else
|
|
526
|
+
ctx.deps.log(minLevel ? `No findings at ${minLevel} or above.` : 'No findings.');
|
|
527
|
+
if (minLevel && shown.length !== findings.length)
|
|
528
|
+
ctx.deps.log(`(${findings.length - shown.length} below ${minLevel} not shown; ${totalsLine(countByRisk(findings))})`);
|
|
529
|
+
ctx.deps.log(`Saved ${path}`);
|
|
530
|
+
}
|
|
531
|
+
/** `prism-cli security code-review`. Returns the exit code. */
|
|
532
|
+
export async function securityCodeReview(input, deps) {
|
|
533
|
+
const say = input.json ? deps.err : deps.log;
|
|
534
|
+
const root = checkedRoot(deps, input.path, say);
|
|
535
|
+
if (!root)
|
|
536
|
+
return 1;
|
|
537
|
+
say(CODE_REVIEW_NOTICE);
|
|
538
|
+
say('');
|
|
539
|
+
const ctx = begin(input, deps, false, say);
|
|
540
|
+
if (!ctx)
|
|
541
|
+
return 1;
|
|
542
|
+
try {
|
|
543
|
+
const failOn = parseLevel('--fail-on', input.failOn);
|
|
544
|
+
const timeout = parseTimeout(input.timeout);
|
|
545
|
+
const id = preflight(ctx);
|
|
546
|
+
printIdentity(ctx, id);
|
|
547
|
+
const repo = needRepo(ctx, root);
|
|
548
|
+
const setup = resolveSetup(ctx, id, repo);
|
|
549
|
+
say(`Repo: ${repo.id}`);
|
|
550
|
+
say(`Code review: ${setup.codeReviewId} (from ${setup.source})`);
|
|
551
|
+
const bucket = getParam(ctx, PARAM_SCAN_BUCKET);
|
|
552
|
+
if (!bucket)
|
|
553
|
+
throw new Abort(`SSM ${PARAM_SCAN_BUCKET} is not set in this account and region: ${NOT_SET_UP}.`);
|
|
554
|
+
const s = buildScope(ctx, repo, !!input.full);
|
|
555
|
+
say(`Scope: ${s.scope === 'full' ? `the whole repo at ${s.tip.slice(0, 12)}` : `${s.upstream}...${s.tip.slice(0, 12)}`}`);
|
|
556
|
+
if (s.empty) {
|
|
557
|
+
say(`No changes against ${s.upstream}; nothing to review.`);
|
|
558
|
+
return 0;
|
|
559
|
+
}
|
|
560
|
+
const ctxUri = `s3://${bucket}/${s.context.key}`;
|
|
561
|
+
say(upload(ctx, bucket, s.context)
|
|
562
|
+
? `Review context: updated ${ctxUri} to HEAD ${s.tip.slice(0, 12)}`
|
|
563
|
+
: `Review context: ${ctxUri} is already HEAD ${s.tip.slice(0, 12)}; upload skipped`);
|
|
564
|
+
if (s.diff)
|
|
565
|
+
say(upload(ctx, bucket, s.diff) ? `Uploaded s3://${bucket}/${s.diff.key}` : `s3://${bucket}/${s.diff.key} is already up to date; upload skipped`);
|
|
566
|
+
const startedAt = deps.now().getTime();
|
|
567
|
+
const start = call(ctx, [
|
|
568
|
+
'securityagent', 'start-code-review-job', '--agent-space-id', setup.agentSpaceId, '--code-review-id', setup.codeReviewId,
|
|
569
|
+
// --full reviews the code review's own source asset (just refreshed); a diff job reviews the patch with that asset as context.
|
|
570
|
+
...(s.diff ? ['--diff-source', JSON.stringify({ s3Uri: `s3://${bucket}/${s.diff.key}` })] : []),
|
|
571
|
+
]);
|
|
572
|
+
const started = toJob(start.json);
|
|
573
|
+
if (!started.codeReviewJobId)
|
|
574
|
+
throw new Abort('start-code-review-job returned no codeReviewJobId.');
|
|
575
|
+
const job = started.status === 'UNKNOWN' ? { ...started, status: 'IN_PROGRESS' } : started;
|
|
576
|
+
say(`Started job ${job.codeReviewJobId}`);
|
|
577
|
+
const kept = { setup, region: ctx.opts.region, account: id.account, scope: s.scope, tip: s.tip, base: s.base, branch: s.branch, upstream: s.upstream, contextCommit: s.tip, startedAt: new Date(startedAt).toISOString() };
|
|
578
|
+
// Written before any wait, and completed in place below, so a lost wait (timeout, Ctrl-C, a poll error, a closed terminal) loses nothing.
|
|
579
|
+
let pendingId;
|
|
580
|
+
try {
|
|
581
|
+
const p = writeResult(repo.root, pendingResult({ ...kept, job }));
|
|
582
|
+
pendingId = basename(p, '.json');
|
|
583
|
+
say(`Saved ${p} (pending)`);
|
|
584
|
+
}
|
|
585
|
+
catch (e) {
|
|
586
|
+
say(`Warning: could not save the pending record (${redact(e instanceof Error ? e.message : String(e))}).`);
|
|
587
|
+
}
|
|
588
|
+
if (input.wait === false) {
|
|
589
|
+
say(`Not waiting (--no-wait). ${fetchLaterHint(job.codeReviewJobId)}`);
|
|
590
|
+
return 0;
|
|
591
|
+
}
|
|
592
|
+
say(`Waiting for the job (up to ${timeout} min; Ctrl-C stops waiting, the job keeps running)...`);
|
|
593
|
+
const out = await pollJob(ctx, setup, job, timeout);
|
|
594
|
+
if (out.kind === 'interrupted') {
|
|
595
|
+
say('');
|
|
596
|
+
say(`Stopped waiting. ${fetchLaterHint(job.codeReviewJobId)}`);
|
|
597
|
+
return 130;
|
|
598
|
+
}
|
|
599
|
+
if (out.kind === 'timeout') {
|
|
600
|
+
say(`Timed out after ${timeout} min (job ${job.codeReviewJobId} is ${out.job.status}). ${fetchLaterHint(job.codeReviewJobId)}`);
|
|
601
|
+
return 2;
|
|
602
|
+
}
|
|
603
|
+
const wallS = Math.round((deps.now().getTime() - startedAt) / 1000);
|
|
604
|
+
const svc = serviceSeconds(out.job);
|
|
605
|
+
say(`Finished in ${wallS}s here${svc !== null ? `; the service reports ${svc}s from job creation to its last update` : ''}.`);
|
|
606
|
+
const base = { ...kept, job: out.job, now: deps.now(), wallS };
|
|
607
|
+
if (out.job.status !== 'COMPLETED')
|
|
608
|
+
return saveFailed(ctx, !!input.json, toResult({ ...base, findings: [] }), out.job, repo, pendingId);
|
|
609
|
+
const findings = fetchFindings(ctx, setup, out.job.codeReviewJobId, repo.root);
|
|
610
|
+
finishFindings(ctx, !!input.json, toResult({ ...base, findings }), findings, repo, undefined, pendingId);
|
|
611
|
+
if (failOnHit(findings, failOn)) {
|
|
612
|
+
say(`--fail-on ${failOn}: findings at or above ${failOn}.`);
|
|
613
|
+
return 1;
|
|
614
|
+
}
|
|
615
|
+
return 0;
|
|
616
|
+
}
|
|
617
|
+
catch (e) {
|
|
618
|
+
if (e instanceof Abort) {
|
|
619
|
+
say(`Error: ${redact(e.message)}`);
|
|
620
|
+
return 1;
|
|
621
|
+
}
|
|
622
|
+
throw e;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
/** `prism-cli security findings`. Read-only: no AWS writes. Returns the exit code. */
|
|
626
|
+
export async function securityFindings(input, deps) {
|
|
627
|
+
const say = input.json ? deps.err : deps.log;
|
|
628
|
+
const root = checkedRoot(deps, input.path, say);
|
|
629
|
+
if (!root)
|
|
630
|
+
return 1;
|
|
631
|
+
say('Read-only (security findings): nothing in AWS will be changed; results are cached to .prism/results/.');
|
|
632
|
+
say('');
|
|
633
|
+
const ctx = begin(input, deps, true, say);
|
|
634
|
+
if (!ctx)
|
|
635
|
+
return 1;
|
|
636
|
+
try {
|
|
637
|
+
const min = parseLevel('--severity', input.severity);
|
|
638
|
+
const timeout = parseTimeout(input.timeout);
|
|
639
|
+
const id = preflight(ctx);
|
|
640
|
+
printIdentity(ctx, id);
|
|
641
|
+
const repo = needRepo(ctx, root);
|
|
642
|
+
const setup = resolveSetup(ctx, id, repo);
|
|
643
|
+
const pendings = pendingReviews(repo.root, id.account, ctx.opts.region, setup.codeReviewId);
|
|
644
|
+
let pending = null;
|
|
645
|
+
let job;
|
|
646
|
+
if (input.job) {
|
|
647
|
+
if (!/^[A-Za-z0-9_-]{1,128}$/.test(input.job))
|
|
648
|
+
throw new Abort(`--job must be a job id, got "${input.job}"`);
|
|
649
|
+
pending = pendings.find((e) => e.result.security.job_id === input.job) ?? null;
|
|
650
|
+
job = getJob(ctx, setup, input.job);
|
|
651
|
+
}
|
|
652
|
+
else if (pendings.length) {
|
|
653
|
+
// A job code-review started and did not see finish: the newest one, completed in place with its scope and commits.
|
|
654
|
+
pending = pendings[0];
|
|
655
|
+
if (pendings.length > 1) {
|
|
656
|
+
say(`Also pending: ${pendings.slice(1).map((e) => `${e.result.security.job_id} (started ${e.result.started_at})`).join(', ')}. Fetch one with --job <id>.`);
|
|
657
|
+
}
|
|
658
|
+
job = getJob(ctx, setup, pending.result.security.job_id);
|
|
659
|
+
}
|
|
660
|
+
else {
|
|
661
|
+
job = latestJob(ctx, setup);
|
|
662
|
+
if (!job) {
|
|
663
|
+
say(`No code review jobs yet for ${repo.id}. Start one with: prism-cli security code-review`);
|
|
664
|
+
return 0;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
if (pending)
|
|
668
|
+
say(`Pending: recorded by code-review at ${pending.result.started_at} (${scopeText(pending.result)})`);
|
|
669
|
+
say(`Job: ${job.codeReviewJobId} (${job.status}${job.createdAt ? `, started ${job.createdAt}` : ''})`);
|
|
670
|
+
if (!TERMINAL.has(job.status)) {
|
|
671
|
+
if (!input.wait) {
|
|
672
|
+
say(`The job is still ${job.status}. Re-run with --wait to wait for it, or check back later.`);
|
|
673
|
+
return 2;
|
|
674
|
+
}
|
|
675
|
+
const out = await pollJob(ctx, setup, job, timeout);
|
|
676
|
+
if (out.kind === 'interrupted') {
|
|
677
|
+
say('');
|
|
678
|
+
say(`Stopped waiting. ${fetchLaterHint(job.codeReviewJobId)}`);
|
|
679
|
+
return 130;
|
|
680
|
+
}
|
|
681
|
+
if (out.kind === 'timeout') {
|
|
682
|
+
say(`Timed out after ${timeout} min (still ${out.job.status}). ${fetchLaterHint(job.codeReviewJobId)}`);
|
|
683
|
+
return 2;
|
|
684
|
+
}
|
|
685
|
+
job = out.job;
|
|
686
|
+
}
|
|
687
|
+
const base = { setup, job, region: ctx.opts.region, account: id.account, now: deps.now(), ...(pending ? fromPending(pending.result) : { scope: 'unknown' }) };
|
|
688
|
+
if (job.status !== 'COMPLETED') {
|
|
689
|
+
if (pending)
|
|
690
|
+
return saveFailed(ctx, !!input.json, toResult({ ...base, findings: [] }), job, repo, pending.id);
|
|
691
|
+
say(`Error: job ${job.codeReviewJobId} ended ${job.status}${job.reason ? `: ${redact(job.reason)}` : ''}`);
|
|
692
|
+
return 1;
|
|
693
|
+
}
|
|
694
|
+
const findings = fetchFindings(ctx, setup, job.codeReviewJobId, repo.root);
|
|
695
|
+
finishFindings(ctx, !!input.json, toResult({ ...base, findings }), findings, repo, min, pending?.id);
|
|
696
|
+
return 0;
|
|
697
|
+
}
|
|
698
|
+
catch (e) {
|
|
699
|
+
if (e instanceof Abort) {
|
|
700
|
+
say(`Error: ${redact(e.message)}`);
|
|
701
|
+
return 1;
|
|
702
|
+
}
|
|
703
|
+
throw e;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
//# sourceMappingURL=review.js.map
|