@prism-d1/cli 1.2.0 → 1.3.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.
Files changed (127) hide show
  1. package/dist/assets/github-workflows/prism-eval-gate-kiro.yml +2 -2
  2. package/dist/assets/infra/bin/app.ts +61 -37
  3. package/dist/assets/infra/lib/constructs/security-agent-construct.ts +35 -30
  4. package/dist/assets/infra/lib/metrics-pipeline-stack.ts +2 -110
  5. package/dist/assets/infra/lib/security-agent-resources.ts +136 -0
  6. package/dist/assets/infra/lib/security-agent-stack.ts +69 -0
  7. package/dist/assets/pentest-cdk/bin/app.ts +12 -0
  8. package/dist/assets/pentest-cdk/cdk.json +7 -0
  9. package/dist/assets/pentest-cdk/lib/pentest-target-stack.ts +206 -0
  10. package/dist/assets/pentest-cdk/package.json +18 -0
  11. package/dist/assets/pentest-cdk/tsconfig.json +12 -0
  12. package/dist/src/codewatch/results.d.ts +124 -4
  13. package/dist/src/codewatch/results.d.ts.map +1 -1
  14. package/dist/src/codewatch/results.js +8 -2
  15. package/dist/src/codewatch/results.js.map +1 -1
  16. package/dist/src/commands/_top/version.d.ts +62 -0
  17. package/dist/src/commands/_top/version.d.ts.map +1 -0
  18. package/dist/src/commands/_top/version.js +175 -0
  19. package/dist/src/commands/_top/version.js.map +1 -0
  20. package/dist/src/commands/bootstrapper/setup-otel-sync.d.ts +12 -6
  21. package/dist/src/commands/bootstrapper/setup-otel-sync.d.ts.map +1 -1
  22. package/dist/src/commands/bootstrapper/setup-otel-sync.js +18 -409
  23. package/dist/src/commands/bootstrapper/setup-otel-sync.js.map +1 -1
  24. package/dist/src/commands/enable/codeburn.d.ts +59 -0
  25. package/dist/src/commands/enable/codeburn.d.ts.map +1 -0
  26. package/dist/src/commands/enable/codeburn.js +531 -0
  27. package/dist/src/commands/enable/codeburn.js.map +1 -0
  28. package/dist/src/commands/enable/security.d.ts +41 -0
  29. package/dist/src/commands/enable/security.d.ts.map +1 -0
  30. package/dist/src/commands/enable/security.js +130 -0
  31. package/dist/src/commands/enable/security.js.map +1 -0
  32. package/dist/src/commands/export/otel.d.ts +31 -0
  33. package/dist/src/commands/export/otel.d.ts.map +1 -0
  34. package/dist/src/commands/export/otel.js +52 -0
  35. package/dist/src/commands/export/otel.js.map +1 -0
  36. package/dist/src/commands/security/code-review.d.ts +22 -0
  37. package/dist/src/commands/security/code-review.d.ts.map +1 -0
  38. package/dist/src/commands/security/code-review.js +43 -0
  39. package/dist/src/commands/security/code-review.js.map +1 -0
  40. package/dist/src/commands/security/findings.d.ts +15 -0
  41. package/dist/src/commands/security/findings.d.ts.map +1 -0
  42. package/dist/src/commands/security/findings.js +31 -0
  43. package/dist/src/commands/security/findings.js.map +1 -0
  44. package/dist/src/commands/security/pentest.d.ts +18 -0
  45. package/dist/src/commands/security/pentest.d.ts.map +1 -0
  46. package/dist/src/commands/security/pentest.js +32 -0
  47. package/dist/src/commands/security/pentest.js.map +1 -0
  48. package/dist/src/commands/security/threat-model.d.ts +16 -0
  49. package/dist/src/commands/security/threat-model.d.ts.map +1 -0
  50. package/dist/src/commands/security/threat-model.js +24 -0
  51. package/dist/src/commands/security/threat-model.js.map +1 -0
  52. package/dist/src/commands/security/threats.d.ts +12 -0
  53. package/dist/src/commands/security/threats.d.ts.map +1 -0
  54. package/dist/src/commands/security/threats.js +23 -0
  55. package/dist/src/commands/security/threats.js.map +1 -0
  56. package/dist/src/commands/securityagent/setup.d.ts +19 -5
  57. package/dist/src/commands/securityagent/setup.d.ts.map +1 -1
  58. package/dist/src/commands/securityagent/setup.js +25 -189
  59. package/dist/src/commands/securityagent/setup.js.map +1 -1
  60. package/dist/src/dashboard/codewatch.d.ts.map +1 -1
  61. package/dist/src/dashboard/codewatch.js +2 -1
  62. package/dist/src/dashboard/codewatch.js.map +1 -1
  63. package/dist/src/dashboard/page.d.ts.map +1 -1
  64. package/dist/src/dashboard/page.js +159 -4
  65. package/dist/src/dashboard/page.js.map +1 -1
  66. package/dist/src/dashboard/payload.d.ts +8 -5
  67. package/dist/src/dashboard/payload.d.ts.map +1 -1
  68. package/dist/src/dashboard/payload.js +5 -2
  69. package/dist/src/dashboard/payload.js.map +1 -1
  70. package/dist/src/dashboard/security.d.ts +130 -0
  71. package/dist/src/dashboard/security.d.ts.map +1 -0
  72. package/dist/src/dashboard/security.js +144 -0
  73. package/dist/src/dashboard/security.js.map +1 -0
  74. package/dist/src/dashboard/setup.d.ts +15 -0
  75. package/dist/src/dashboard/setup.d.ts.map +1 -1
  76. package/dist/src/dashboard/setup.js +20 -3
  77. package/dist/src/dashboard/setup.js.map +1 -1
  78. package/dist/src/dashboard/usage.js +1 -1
  79. package/dist/src/dashboard/usage.js.map +1 -1
  80. package/dist/src/index.d.ts.map +1 -1
  81. package/dist/src/index.js +9 -2
  82. package/dist/src/index.js.map +1 -1
  83. package/dist/src/security/cfn-diff.d.ts +57 -0
  84. package/dist/src/security/cfn-diff.d.ts.map +1 -0
  85. package/dist/src/security/cfn-diff.js +112 -0
  86. package/dist/src/security/cfn-diff.js.map +1 -0
  87. package/dist/src/security/errors.d.ts +45 -0
  88. package/dist/src/security/errors.d.ts.map +1 -0
  89. package/dist/src/security/errors.js +116 -0
  90. package/dist/src/security/errors.js.map +1 -0
  91. package/dist/src/security/marker.d.ts +46 -0
  92. package/dist/src/security/marker.d.ts.map +1 -0
  93. package/dist/src/security/marker.js +120 -0
  94. package/dist/src/security/marker.js.map +1 -0
  95. package/dist/src/security/pentest-dockerfile.d.ts +8 -0
  96. package/dist/src/security/pentest-dockerfile.d.ts.map +1 -0
  97. package/dist/src/security/pentest-dockerfile.js +163 -0
  98. package/dist/src/security/pentest-dockerfile.js.map +1 -0
  99. package/dist/src/security/pentest.d.ts +233 -0
  100. package/dist/src/security/pentest.d.ts.map +1 -0
  101. package/dist/src/security/pentest.js +1057 -0
  102. package/dist/src/security/pentest.js.map +1 -0
  103. package/dist/src/security/repo.d.ts +25 -0
  104. package/dist/src/security/repo.d.ts.map +1 -0
  105. package/dist/src/security/repo.js +38 -0
  106. package/dist/src/security/repo.js.map +1 -0
  107. package/dist/src/security/review.d.ts +202 -0
  108. package/dist/src/security/review.d.ts.map +1 -0
  109. package/dist/src/security/review.js +706 -0
  110. package/dist/src/security/review.js.map +1 -0
  111. package/dist/src/security/setup.d.ts +193 -0
  112. package/dist/src/security/setup.d.ts.map +1 -0
  113. package/dist/src/security/setup.js +728 -0
  114. package/dist/src/security/setup.js.map +1 -0
  115. package/dist/src/security/threat-model.d.ts +133 -0
  116. package/dist/src/security/threat-model.d.ts.map +1 -0
  117. package/dist/src/security/threat-model.js +653 -0
  118. package/dist/src/security/threat-model.js.map +1 -0
  119. package/dist/src/utils/exec.d.ts +6 -0
  120. package/dist/src/utils/exec.d.ts.map +1 -1
  121. package/dist/src/utils/exec.js +12 -1
  122. package/dist/src/utils/exec.js.map +1 -1
  123. package/dist/src/utils/otel-sync-schedule.d.ts +19 -0
  124. package/dist/src/utils/otel-sync-schedule.d.ts.map +1 -1
  125. package/dist/src/utils/otel-sync-schedule.js +24 -2
  126. package/dist/src/utils/otel-sync-schedule.js.map +1 -1
  127. package/package.json +3 -3
@@ -0,0 +1,728 @@
1
+ /**
2
+ * `prism-cli enable security`: sets up AWS Security Agent for PRISM, safely.
3
+ *
4
+ * Compared with the old `securityagent setup`:
5
+ * - deploys only the stack that holds the Security Agent resources, never --all;
6
+ * - shows a resource diff (IAM, network and public endpoints called out) and
7
+ * asks before any write, naming the account and region; --yes approves for
8
+ * non-interactive use, and with no terminal and no --yes it refuses;
9
+ * - --dry-run runs every read and prints the exact calls it would make;
10
+ * - is idempotent: existing agent space, application, code review, S3 object
11
+ * and SSM parameter are reused, and drift is shown before it is changed;
12
+ * - --status is a read-only report;
13
+ * - writes the per-repo code review where the CI eval gate looks for it
14
+ * (/prism/continuum/code-review-id/<repo-slug>), which the old command did not.
15
+ *
16
+ * Every AWS call goes through deps.aws, so tests run it against a fake.
17
+ */
18
+ import { createHash } from 'node:crypto';
19
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
20
+ import { join, resolve } from 'node:path';
21
+ import { diffTemplates, formatDiff } from './cfn-diff.js';
22
+ import { cliVersionOk, explainAwsError, MIN_AWS_CLI_TEXT, redact } from './errors.js';
23
+ import { ensureMarkerIgnored, MARKER_PATH, readSecurityMarkerWithSource, writeSecurityMarker } from './marker.js';
24
+ import { codeReviewParam, codeReviewTitle, repoIdFromRemote, repoSlug, repoZipKey, SSM_PREFIX } from './repo.js';
25
+ /** Hosts Security Agent when deployed with enableSecurityAgent=true (the original layout). */
26
+ export const PIPELINE_STACK = 'PrismD1MetricsPipeline';
27
+ /** The standalone stack (infra context securityAgentStandalone=true): Security Agent only. */
28
+ export const STANDALONE_STACK = 'PrismD1SecurityAgent';
29
+ export const AGENT_SPACE_NAME = 'prism-d1-security';
30
+ export const SERVICE_ROLE_NAME = `prism-d1-security-agent-${AGENT_SPACE_NAME}`;
31
+ export const PARAM_AGENT_SPACE = `${SSM_PREFIX}/agent-space-id`;
32
+ export const PARAM_SCAN_BUCKET = `${SSM_PREFIX}/scan-bucket`;
33
+ export const PARAM_SERVICE_ROLE = `${SSM_PREFIX}/service-role-arn`;
34
+ /** Written by the old `securityagent setup`; nothing reads it. Reported, never written. */
35
+ export const LEGACY_PARAM_CODE_REVIEW = `${SSM_PREFIX}/code-review-id`;
36
+ export class Abort extends Error {
37
+ }
38
+ // ---------------------------------------------------------------------------
39
+ // Exact AWS calls. Dry-run prints these; apply runs them. One builder each so
40
+ // the two can never disagree.
41
+ // ---------------------------------------------------------------------------
42
+ export const calls = {
43
+ createApplication: () => ['securityagent', 'create-application'],
44
+ updateApplication: (id, roleArn) => ['securityagent', 'update-application', '--application-id', id, '--role-arn', roleArn],
45
+ putObject: (bucket, key, file) => ['s3api', 'put-object', '--bucket', bucket, '--key', key, '--body', file],
46
+ createCodeReview: (spaceId, title, roleArn, bucket, key) => [
47
+ 'securityagent', 'create-code-review', '--agent-space-id', spaceId, '--title', title, '--service-role', roleArn,
48
+ '--assets', JSON.stringify({ sourceCode: [{ s3Location: `s3://${bucket}/${key}` }] }),
49
+ ],
50
+ putParameter: (name, value, overwrite) => [
51
+ 'ssm', 'put-parameter', '--name', name, '--value', value, '--type', 'String', ...(overwrite ? ['--overwrite'] : []),
52
+ ],
53
+ };
54
+ const quote = (a) => (/^[A-Za-z0-9_./:=@,<>+-]+$/.test(a) ? a : `'${a.replace(/'/g, `'\\''`)}'`);
55
+ function display(args, opts) {
56
+ return ['aws', ...args, '--region', opts.region, ...(opts.profile ? ['--profile', opts.profile] : [])].map(quote).join(' ');
57
+ }
58
+ /** True for any AWS call that changes something. Used by tests and as a guard in dry-run/status. */
59
+ export function isWriteCall(args) {
60
+ const verb = args[1] || '';
61
+ return /^(create|update|put|delete|batch-delete|start|tag|untag|add|remove|attach|detach)-/.test(verb) || (args[0] === 's3' && /^(cp|mv|rm|sync)$/.test(verb));
62
+ }
63
+ /** Runs a call; on failure either returns the result (expected codes) or aborts with an explained message. */
64
+ export function call(ctx, args, expected = []) {
65
+ if (ctx.readOnly && isWriteCall(args))
66
+ throw new Error(`internal: write call in read-only mode: ${args.slice(0, 2).join(' ')}`);
67
+ const r = ctx.deps.aws(args, ctx.opts.region);
68
+ if (r.ok)
69
+ return r;
70
+ if (expected.includes(r.errorCode) || expected.some((e) => r.raw.includes(e)))
71
+ return r;
72
+ throw new Abort(explainAwsError(args, r.raw, ctx.cliVersion).message);
73
+ }
74
+ export function getParam(ctx, name) {
75
+ const r = call(ctx, ['ssm', 'get-parameter', '--name', name], ['ParameterNotFound']);
76
+ return r.ok ? (r.json?.Parameter?.Value ?? null) : null;
77
+ }
78
+ /**
79
+ * Does the IAM role behind `roleArn` exist? `iam get-role`, read-only. null when
80
+ * it cannot be told (no iam:GetRole), so a missing permission never reads as
81
+ * "missing role".
82
+ */
83
+ export function roleExists(ctx, roleArn) {
84
+ const name = roleArn.split('/').pop() || roleArn;
85
+ const r = ctx.deps.aws(['iam', 'get-role', '--role-name', name], ctx.opts.region);
86
+ if (r.ok)
87
+ return true;
88
+ if (r.errorCode === 'NoSuchEntity' || /NoSuchEntity/.test(r.raw))
89
+ return false;
90
+ return null;
91
+ }
92
+ const roleName = (arn) => arn.split('/').pop() || arn;
93
+ /**
94
+ * The agent space's awsResources.s3Buckets (ARNs or names, per the API), from
95
+ * batch-get-agent-spaces. null when the space was not returned.
96
+ */
97
+ export function spaceBuckets(ctx, spaceId) {
98
+ const r = call(ctx, ['securityagent', 'batch-get-agent-spaces', '--agent-space-ids', spaceId]);
99
+ const space = (r.json?.agentSpaces || []).find((s) => s?.agentSpaceId === spaceId);
100
+ if (!space)
101
+ return null;
102
+ const list = space.awsResources?.s3Buckets;
103
+ return Array.isArray(list) ? list.map((b) => String(b)) : [];
104
+ }
105
+ /** Whether `bucket` (a name) is in the list, given by name or by ARN (any partition). */
106
+ export function listsBucket(buckets, bucket) {
107
+ return buckets.some((b) => b === bucket || /^arn:[a-z-]+:s3:::/.test(b) && b.replace(/^arn:[a-z-]+:s3:::/, '').replace(/\/.*$/, '') === bucket);
108
+ }
109
+ export const bucketNotListed = (bucket) => `the Security Agent space doesn't list the scan bucket ${bucket}; run prism-cli enable security to update it.`;
110
+ function identity(ctx) {
111
+ const r = call(ctx, ['sts', 'get-caller-identity']);
112
+ const account = String(r.json?.Account || '');
113
+ if (!account)
114
+ throw new Abort('Failed to get account ID. Check your AWS credentials.');
115
+ // The alias is a nicety for the confirmation; a caller without iam:ListAccountAliases still proceeds.
116
+ const a = ctx.deps.aws(['iam', 'list-account-aliases'], ctx.opts.region);
117
+ const alias = a.ok && Array.isArray(a.json?.AccountAliases) && a.json.AccountAliases[0] ? String(a.json.AccountAliases[0]) : null;
118
+ return { account, arn: String(r.json?.Arn || ''), alias };
119
+ }
120
+ const accountLabel = (id) => `${id.account}${id.alias ? ` (${id.alias})` : ' (no account alias)'}`;
121
+ /**
122
+ * The region, resolved in the AWS CLI's order: --region, AWS_REGION,
123
+ * AWS_DEFAULT_REGION, then the profile's configured region (the --profile
124
+ * profile, else AWS_PROFILE, else default). Null when none is set.
125
+ */
126
+ export function resolveRegion(opts, deps) {
127
+ if (opts.region)
128
+ return { region: opts.region, source: '--region' };
129
+ for (const k of ['AWS_REGION', 'AWS_DEFAULT_REGION']) {
130
+ const v = deps.env[k]?.trim();
131
+ if (v)
132
+ return { region: v, source: k };
133
+ }
134
+ const v = deps.configRegion(opts.profile)?.trim();
135
+ const which = opts.profile ? `profile ${opts.profile}` : deps.env.AWS_PROFILE ? `profile ${deps.env.AWS_PROFILE} (AWS_PROFILE)` : 'the default profile';
136
+ return v ? { region: v, source: `region configured for ${which}` } : null;
137
+ }
138
+ export const NO_REGION_MESSAGE = 'No AWS region is set. Pass --region <region>, or set AWS_REGION, or configure one with "aws configure set region <region>".';
139
+ /** Where the credentials come from, as far as can be told without reading them. */
140
+ export function credentialSource(opts, env) {
141
+ if (opts.profile)
142
+ return `profile ${opts.profile} (--profile)`;
143
+ if (env.AWS_PROFILE)
144
+ return `profile ${env.AWS_PROFILE} (AWS_PROFILE)`;
145
+ if (env.AWS_ACCESS_KEY_ID)
146
+ return 'environment variables (AWS_ACCESS_KEY_ID)';
147
+ if (env.AWS_WEB_IDENTITY_TOKEN_FILE)
148
+ return 'web identity token (AWS_WEB_IDENTITY_TOKEN_FILE)';
149
+ if (env.AWS_CONTAINER_CREDENTIALS_FULL_URI || env.AWS_CONTAINER_CREDENTIALS_RELATIVE_URI)
150
+ return 'container credentials';
151
+ return 'the default credential chain (default profile, SSO, or an instance role)';
152
+ }
153
+ export const DEPLOY_NOTICE = [
154
+ 'prism-cli enable security deploys AWS resources into the AWS account your credentials point to:',
155
+ 'an AWS Security Agent space, an IAM service role and policies, an encrypted S3 scan bucket and SSM',
156
+ 'parameters. These may incur cost. It shows the account, a diff and the plan, and asks before any change.',
157
+ ];
158
+ export function printIdentity(ctx, id) {
159
+ const { deps, opts } = ctx;
160
+ deps.log(`Account: ${accountLabel(id)}`);
161
+ deps.log(`Caller: ${id.arn || 'unknown'}`);
162
+ deps.log(`Region: ${opts.region} (${opts.regionSource})`);
163
+ deps.log(`Credentials: ${credentialSource(opts, deps.env)}`);
164
+ }
165
+ /**
166
+ * The repo whose code review this run manages, or a reason it cannot.
167
+ * `root`, when given, is an already-checked repository top level: every git
168
+ * call runs with `-C <root>` (the security commands). Without it the repo is
169
+ * found from the working directory, walking up (enable security, unchanged).
170
+ */
171
+ export function resolveRepo(opts, deps, root) {
172
+ const at = root ? ['-C', root] : [];
173
+ const top = root ? { ok: true, stdout: root, stderr: '' } : deps.git(['rev-parse', '--show-toplevel']);
174
+ if (!top.ok)
175
+ return { error: 'not inside a git repository, so the per-repo code review is skipped (run from the repo to set it up)' };
176
+ let id;
177
+ if (opts.repo) {
178
+ if (!/^[A-Za-z0-9.-]+(\/[A-Za-z0-9._-]+)+$/.test(opts.repo))
179
+ return { error: `--repo must look like github.com/owner/name, got "${opts.repo}"` };
180
+ id = opts.repo;
181
+ }
182
+ else {
183
+ const remote = deps.git([...at, 'remote', 'get-url', 'origin']);
184
+ id = remote.ok ? repoIdFromRemote(remote.stdout) : null;
185
+ if (!id)
186
+ return { error: 'this repo has no usable "origin" remote; pass --repo github.com/<owner>/<name> (the name the CI eval gate uses)' };
187
+ }
188
+ const slug = repoSlug(id);
189
+ return { root: top.stdout, id, slug, title: codeReviewTitle(slug), param: codeReviewParam(slug), zipKey: repoZipKey(slug) };
190
+ }
191
+ /** CDK context that selects each layout; the other key is removed so the two can never combine. */
192
+ const MODE_CONTEXT = { pipeline: 'enableSecurityAgent', standalone: 'securityAgentStandalone' };
193
+ function parseContext(pairs = [], mode) {
194
+ const ctx = {};
195
+ for (const p of pairs) {
196
+ const m = /^([A-Za-z0-9_:@./-]+)=(.*)$/.exec(p);
197
+ if (!m)
198
+ throw new Abort(`--context must be key=value, got "${p}"`);
199
+ ctx[m[1]] = m[2];
200
+ }
201
+ delete ctx[MODE_CONTEXT.pipeline];
202
+ delete ctx[MODE_CONTEXT.standalone];
203
+ ctx[MODE_CONTEXT[mode]] = 'true';
204
+ return ctx;
205
+ }
206
+ function readStack(ctx, name) {
207
+ const r = call(ctx, ['cloudformation', 'describe-stacks', '--stack-name', name], ['does not exist']);
208
+ if (!r.ok)
209
+ return { name, deployed: false, status: null, template: null, hasSecurityAgent: false };
210
+ const status = String(r.json?.Stacks?.[0]?.StackStatus || 'UNKNOWN');
211
+ const t = call(ctx, ['cloudformation', 'get-template', '--stack-name', name, '--template-stage', 'Original']);
212
+ let template = null;
213
+ const body = t.json?.TemplateBody;
214
+ if (body && typeof body === 'object')
215
+ template = body;
216
+ else if (typeof body === 'string') {
217
+ try {
218
+ template = JSON.parse(body);
219
+ }
220
+ catch {
221
+ throw new Abort(`the deployed ${name} template is not JSON; cannot compare it`);
222
+ }
223
+ }
224
+ const hasSecurityAgent = Object.values(template?.Resources ?? {}).some((x) => x.Type === 'AWS::SecurityAgent::AgentSpace');
225
+ return { name, deployed: true, status, template, hasSecurityAgent };
226
+ }
227
+ /**
228
+ * Which stack holds Security Agent in this account and region. Never both:
229
+ * an account that already hosts it in the pipeline keeps that layout (no
230
+ * second agent space, nothing moved); otherwise the standalone stack is used.
231
+ * An agent space named like ours that neither stack owns stops the run.
232
+ */
233
+ export function chooseTarget(ctx, spaces) {
234
+ const pipeline = readStack(ctx, PIPELINE_STACK);
235
+ const standalone = readStack(ctx, STANDALONE_STACK);
236
+ if (pipeline.hasSecurityAgent && standalone.hasSecurityAgent) {
237
+ throw new Abort(`both ${PIPELINE_STACK} and ${STANDALONE_STACK} hold Security Agent resources in this account and region, which should be impossible (they share names and exports). Fix this in CloudFormation before running enable security.`);
238
+ }
239
+ if (pipeline.hasSecurityAgent) {
240
+ return { mode: 'pipeline', stack: pipeline, pipeline, why: `this account already hosts Security Agent in ${PIPELINE_STACK}, so that stack is kept` };
241
+ }
242
+ if (standalone.deployed) {
243
+ return { mode: 'standalone', stack: standalone, pipeline, why: `this account uses the standalone ${STANDALONE_STACK} stack` };
244
+ }
245
+ if (spaces.some((s) => s.name === AGENT_SPACE_NAME)) {
246
+ throw new Abort(`an agent space named ${AGENT_SPACE_NAME} exists, but neither ${PIPELINE_STACK} nor ${STANDALONE_STACK} manages it. Deploying would create a second one; remove it or bring it under CloudFormation first.`);
247
+ }
248
+ return {
249
+ mode: 'standalone', stack: standalone, pipeline,
250
+ why: `no Security Agent resources found, so the standalone ${STANDALONE_STACK} stack is used${pipeline.deployed ? ` (${PIPELINE_STACK} is deployed without them and is not touched)` : ''}`,
251
+ };
252
+ }
253
+ function planStack(ctx, id, target) {
254
+ const { deps, opts } = ctx;
255
+ const st = target.stack;
256
+ const name = st.name;
257
+ if (st.status && /_IN_PROGRESS$/.test(st.status))
258
+ throw new Abort(`stack ${name} is busy (${st.status}); wait for it to finish and re-run`);
259
+ const dir = deps.makeTempDir();
260
+ const assemblyDir = join(dir, 'cdk.out');
261
+ const context = parseContext(opts.context, target.mode);
262
+ const cdkEnv = { account: id.account, region: opts.region };
263
+ deps.log(`Synthesizing the CDK app with ${MODE_CONTEXT[target.mode]}=true (local, no AWS writes)...`);
264
+ const s = deps.cdk.synth(assemblyDir, context, cdkEnv);
265
+ if (!s.ok)
266
+ throw new Abort(`cdk synth failed:\n${redact(s.stderr).split('\n').slice(-15).join('\n')}`);
267
+ const tplPath = join(assemblyDir, `${name}.template.json`);
268
+ if (!existsSync(tplPath))
269
+ throw new Abort(`cdk synth produced no ${name} template`);
270
+ const synthesized = JSON.parse(readFileSync(tplPath, 'utf8'));
271
+ // Every resource of the standalone stack is a Security Agent resource.
272
+ const diff = diffTemplates(st.template, synthesized, name, target.mode === 'standalone' ? () => true : undefined);
273
+ let diffFile = null;
274
+ const full = deps.cdk.diff(assemblyDir, name, cdkEnv);
275
+ if (full.output) {
276
+ diffFile = join(dir, 'cdk-diff.txt');
277
+ writeFileSync(diffFile, redact(full.output));
278
+ }
279
+ let needsDeploy, reason;
280
+ if (target.mode === 'standalone') {
281
+ if (!st.deployed) {
282
+ needsDeploy = true;
283
+ reason = `${name} is not deployed in this account and region; it holds only the Security Agent resources`;
284
+ }
285
+ else if (diff.changes.length) {
286
+ needsDeploy = true;
287
+ reason = 'the deployed Security Agent resources differ from the local infra code (drift)';
288
+ }
289
+ else {
290
+ needsDeploy = false;
291
+ reason = 'up to date';
292
+ }
293
+ }
294
+ else if (diff.touchesSecurityAgent) {
295
+ needsDeploy = true;
296
+ reason = 'the deployed Security Agent resources differ from the local infra code (drift)';
297
+ }
298
+ else if (diff.changes.length) {
299
+ needsDeploy = false;
300
+ reason = `the Security Agent resources are up to date; the other ${diff.changes.length} change(s) in ${name} are not deployed by this command (run cdk deploy yourself if you want them)`;
301
+ }
302
+ else {
303
+ needsDeploy = false;
304
+ reason = 'up to date';
305
+ }
306
+ return { stack: name, deployed: st.deployed, status: st.status, diff, needsDeploy, reason, assemblyDir, diffFile, cdkEnv };
307
+ }
308
+ export function md5(path) {
309
+ return createHash('md5').update(readFileSync(path)).digest('hex');
310
+ }
311
+ /** Reads everything and decides each item. Makes no writes. */
312
+ function planService(ctx, id, repo, stackReady, stackName) {
313
+ const items = [];
314
+ const conventionRole = `arn:aws:iam::${id.account}:role/${SERVICE_ROLE_NAME}`;
315
+ const svc = { agentSpaceId: null, scanBucket: null, serviceRoleArn: conventionRole, app: null, appCount: 0, review: null, ssmValue: null, zip: null, remoteEtag: null, items };
316
+ if (!stackReady) {
317
+ const later = (key, label, detail, c = []) => { items.push({ key, label, state: 'after-deploy', detail, calls: c }); };
318
+ later('agent-space', 'Agent space', `created by the stack (${AGENT_SPACE_NAME})`);
319
+ later('application', 'Application', 'looked up after the deploy; created and given the service role if missing', [calls.createApplication(), calls.updateApplication('<application id>', conventionRole)]);
320
+ if ('error' in repo) {
321
+ items.push({ key: 'code-review', label: 'Code review', state: 'skip', detail: repo.error, calls: [] });
322
+ return svc;
323
+ }
324
+ later('repo-zip', 'Repo snapshot', `git archive HEAD uploaded to s3://<scan bucket>/${repo.zipKey} if missing or different`, [calls.putObject('<scan bucket>', repo.zipKey, '<git archive of HEAD>')]);
325
+ later('code-review', 'Code review', `"${repo.title}" created if missing`, [calls.createCodeReview('<agent space id>', repo.title, conventionRole, '<scan bucket>', repo.zipKey)]);
326
+ later('ssm-code-review', 'SSM code review id', `${repo.param} set to the code review id`, [calls.putParameter(repo.param, '<code review id>', false)]);
327
+ return svc;
328
+ }
329
+ // Agent space: the stack writes its id to SSM; confirm it exists.
330
+ svc.agentSpaceId = getParam(ctx, PARAM_AGENT_SPACE);
331
+ svc.scanBucket = getParam(ctx, PARAM_SCAN_BUCKET);
332
+ svc.serviceRoleArn = getParam(ctx, PARAM_SERVICE_ROLE) || conventionRole;
333
+ const spaces = call(ctx, ['securityagent', 'list-agent-spaces']).json?.agentSpaceSummaries || [];
334
+ const space = spaces.find((s) => s.agentSpaceId === svc.agentSpaceId) || spaces.find((s) => s.name === AGENT_SPACE_NAME);
335
+ if (!space)
336
+ throw new Abort(`${stackName} is deployed but no agent space named ${AGENT_SPACE_NAME} was found (SSM ${PARAM_AGENT_SPACE}: ${svc.agentSpaceId ?? 'missing'}). Check the stack in the CloudFormation console.`);
337
+ if (svc.agentSpaceId && space.agentSpaceId !== svc.agentSpaceId) {
338
+ throw new Abort(`SSM ${PARAM_AGENT_SPACE} holds ${svc.agentSpaceId}, which is not an agent space here; ${AGENT_SPACE_NAME} is ${space.agentSpaceId}. The stack owns that parameter, so redeploy it rather than editing the value.`);
339
+ }
340
+ svc.agentSpaceId = space.agentSpaceId;
341
+ items.push({ key: 'agent-space', label: 'Agent space', state: 'ok', detail: `${space.agentSpaceId} (${space.name})`, calls: [] });
342
+ // Application (one per account; the web console).
343
+ const apps = call(ctx, ['securityagent', 'list-applications']).json?.applicationSummaries || [];
344
+ svc.appCount = apps.length;
345
+ if (!apps.length) {
346
+ items.push({ key: 'application', label: 'Application', state: 'create', detail: `none yet; create it and attach ${svc.serviceRoleArn}`,
347
+ calls: [calls.createApplication(), calls.updateApplication('<new application id>', svc.serviceRoleArn)] });
348
+ }
349
+ else {
350
+ const appId = String(apps[0].applicationId);
351
+ const got = call(ctx, ['securityagent', 'get-application', '--application-id', appId]).json;
352
+ const roleArn = got?.roleArn ? String(got.roleArn) : null;
353
+ svc.app = { id: appId, roleArn };
354
+ const many = apps.length > 1 ? ` (${apps.length} applications in this region; using the first)` : '';
355
+ // The stack is deployed, so it owns the expected role: missing means it was
356
+ // deleted outside CloudFormation, and re-attaching it would not help.
357
+ const exists = roleExists(ctx, svc.serviceRoleArn);
358
+ if (exists === false)
359
+ items.push({ key: 'application', label: 'Application', state: 'blocked', calls: [],
360
+ detail: `${appId}: role ${roleName(svc.serviceRoleArn)} does not exist although the stack is deployed (deleted outside CloudFormation?). Recreate it through the stack before re-running${many}` });
361
+ else if (roleArn === svc.serviceRoleArn)
362
+ items.push({ key: 'application', label: 'Application', state: 'ok', calls: [],
363
+ detail: `${appId}, role attached${exists === null ? ' (could not check that it exists: no iam:GetRole)' : ''}${many}` });
364
+ else
365
+ items.push({ key: 'application', label: 'Application', state: 'update', calls: [calls.updateApplication(appId, svc.serviceRoleArn)],
366
+ detail: `${appId}: role is ${roleArn ?? 'not set'}, expected ${svc.serviceRoleArn} (drift)${many}` });
367
+ }
368
+ if ('error' in repo) {
369
+ items.push({ key: 'code-review', label: 'Code review', state: 'skip', detail: repo.error, calls: [] });
370
+ return svc;
371
+ }
372
+ if (!svc.scanBucket)
373
+ throw new Abort(`SSM ${PARAM_SCAN_BUCKET} is missing although ${stackName} has the Security Agent resources; redeploy the stack.`);
374
+ // Code review for this repo: the SSM pointer if it is still valid, else one with the CI title.
375
+ svc.ssmValue = getParam(ctx, repo.param);
376
+ const reviews = call(ctx, ['securityagent', 'list-code-reviews', '--agent-space-id', space.agentSpaceId]).json?.codeReviewSummaries || [];
377
+ const bySsm = svc.ssmValue ? reviews.find((r) => r.codeReviewId === svc.ssmValue) : undefined;
378
+ const byTitle = reviews.find((r) => r.title === repo.title);
379
+ if (bySsm)
380
+ svc.review = { id: bySsm.codeReviewId, title: bySsm.title, source: 'ssm' };
381
+ else if (byTitle)
382
+ svc.review = { id: byTitle.codeReviewId, title: byTitle.title, source: 'title' };
383
+ if (svc.review) {
384
+ items.push({ key: 'repo-zip', label: 'Repo snapshot', state: 'skip', detail: 'not needed: the code review already exists', calls: [] });
385
+ items.push({ key: 'code-review', label: 'Code review', state: 'ok', detail: `${svc.review.id} ("${svc.review.title}")`, calls: [] });
386
+ }
387
+ else {
388
+ // Build the snapshot now so the plan can say whether the upload is needed.
389
+ const dir = ctx.deps.makeTempDir();
390
+ const zipPath = join(dir, 'repo.zip');
391
+ const z = ctx.deps.git(['archive', 'HEAD', '--format=zip', '-o', zipPath]);
392
+ if (!z.ok)
393
+ throw new Abort(`git archive HEAD failed: ${z.stderr.split('\n').pop()}`);
394
+ svc.zip = { path: zipPath, md5: md5(zipPath) };
395
+ const head = call(ctx, ['s3api', 'head-object', '--bucket', svc.scanBucket, '--key', repo.zipKey], ['404', 'NotFound', 'NoSuchKey']);
396
+ svc.remoteEtag = head.ok ? String(head.json?.ETag || '').replace(/"/g, '') : null;
397
+ if (svc.remoteEtag === svc.zip.md5) {
398
+ items.push({ key: 'repo-zip', label: 'Repo snapshot', state: 'ok', detail: `s3://${svc.scanBucket}/${repo.zipKey} already matches HEAD (MD5 ${svc.zip.md5}); upload skipped`, calls: [] });
399
+ }
400
+ else {
401
+ items.push({ key: 'repo-zip', label: 'Repo snapshot', state: svc.remoteEtag ? 'update' : 'create', calls: [calls.putObject(svc.scanBucket, repo.zipKey, zipPath)],
402
+ detail: svc.remoteEtag ? `s3://${svc.scanBucket}/${repo.zipKey} differs from HEAD; replace it` : `upload git archive of HEAD to s3://${svc.scanBucket}/${repo.zipKey}` });
403
+ }
404
+ items.push({ key: 'code-review', label: 'Code review', state: 'create', detail: `create "${repo.title}"`,
405
+ calls: [calls.createCodeReview(space.agentSpaceId, repo.title, svc.serviceRoleArn, svc.scanBucket, repo.zipKey)] });
406
+ }
407
+ const target = svc.review?.id ?? null;
408
+ if (target && svc.ssmValue === target) {
409
+ items.push({ key: 'ssm-code-review', label: 'SSM code review id', state: 'ok', detail: `${repo.param} = ${target}`, calls: [] });
410
+ }
411
+ else if (!svc.ssmValue) {
412
+ items.push({ key: 'ssm-code-review', label: 'SSM code review id', state: 'create', detail: `set ${repo.param}`, calls: [calls.putParameter(repo.param, target ?? '<new code review id>', false)] });
413
+ }
414
+ else {
415
+ items.push({ key: 'ssm-code-review', label: 'SSM code review id', state: 'conflict', calls: [calls.putParameter(repo.param, target ?? '<new code review id>', true)],
416
+ detail: `${repo.param} holds ${svc.ssmValue}, which is not a code review in this agent space; it would be replaced with ${target ?? 'the new code review id'} (asks first)` });
417
+ }
418
+ return svc;
419
+ }
420
+ // ---------------------------------------------------------------------------
421
+ // Output
422
+ // ---------------------------------------------------------------------------
423
+ const STATE_WORD = { ok: 'ok', create: 'create', update: 'update', conflict: 'CONFLICT', skip: 'skip', 'after-deploy': 'after deploy', blocked: 'BLOCKED' };
424
+ function printPlan(ctx, stack, svc) {
425
+ const { deps, opts } = ctx;
426
+ deps.log('');
427
+ for (const l of formatDiff(stack.diff, stack.stack))
428
+ deps.log(l);
429
+ if (stack.diffFile)
430
+ deps.log(` Full cdk diff: ${stack.diffFile}`);
431
+ deps.log(` Deploy: ${stack.needsDeploy ? 'yes' : 'no'} (${stack.reason})`);
432
+ if (stack.needsDeploy)
433
+ deps.log(` cdk deploy --app ${stack.assemblyDir} ${stack.stack} --exclusively${opts.profile ? ` --profile ${opts.profile}` : ''} (CDK_DEFAULT_REGION=${opts.region})`);
434
+ deps.log('');
435
+ deps.log('Security Agent:');
436
+ for (const it of svc.items) {
437
+ deps.log(` [${STATE_WORD[it.state]}] ${it.label}: ${it.detail}`);
438
+ if (it.state !== 'ok' && it.state !== 'skip' && it.state !== 'blocked')
439
+ for (const c of it.calls)
440
+ deps.log(` ${display(c, opts)}`);
441
+ }
442
+ }
443
+ function hasWrites(stack, svc) {
444
+ return stack.needsDeploy || svc.items.some((i) => i.state === 'create' || i.state === 'update' || i.state === 'conflict' || i.state === 'after-deploy');
445
+ }
446
+ async function confirm(ctx, id, question) {
447
+ const { deps, opts } = ctx;
448
+ if (opts.yes) {
449
+ deps.log(`--yes: approved for account ${accountLabel(id)} in ${opts.region}.`);
450
+ return true;
451
+ }
452
+ if (!deps.isTTY) {
453
+ deps.log('Refusing to change anything: there is no terminal to confirm on. Re-run with --yes to approve these changes, or with --dry-run to only see the plan.');
454
+ return false;
455
+ }
456
+ const a = (await deps.ask(`${question} [y/N] `)).trim();
457
+ return /^y(es)?$/i.test(a);
458
+ }
459
+ // ---------------------------------------------------------------------------
460
+ // Apply
461
+ // ---------------------------------------------------------------------------
462
+ function applyService(ctx, svc, repo, overwriteOk) {
463
+ const { deps } = ctx;
464
+ const item = (k) => svc.items.find((i) => i.key === k);
465
+ let applicationId = svc.app?.id ?? '';
466
+ const app = item('application');
467
+ if (app?.state === 'create') {
468
+ const r = call(ctx, calls.createApplication());
469
+ applicationId = String(r.json?.applicationId || '');
470
+ if (!applicationId)
471
+ throw new Abort('create-application returned no applicationId');
472
+ deps.log(` Created application ${applicationId}`);
473
+ }
474
+ if (app?.state === 'create' || app?.state === 'update') {
475
+ call(ctx, calls.updateApplication(applicationId, svc.serviceRoleArn));
476
+ deps.log(` Attached role ${svc.serviceRoleArn} to application ${applicationId}`);
477
+ }
478
+ if ('error' in repo)
479
+ return { applicationId, codeReviewId: null };
480
+ let codeReviewId = svc.review?.id ?? null;
481
+ const zip = item('repo-zip');
482
+ if ((zip?.state === 'create' || zip?.state === 'update') && svc.zip && svc.scanBucket) {
483
+ call(ctx, calls.putObject(svc.scanBucket, repo.zipKey, svc.zip.path));
484
+ deps.log(` Uploaded repo snapshot to s3://${svc.scanBucket}/${repo.zipKey}`);
485
+ }
486
+ if (item('code-review')?.state === 'create' && svc.agentSpaceId && svc.scanBucket) {
487
+ const r = call(ctx, calls.createCodeReview(svc.agentSpaceId, repo.title, svc.serviceRoleArn, svc.scanBucket, repo.zipKey));
488
+ codeReviewId = String(r.json?.codeReviewId || '');
489
+ if (!codeReviewId)
490
+ throw new Abort('create-code-review returned no codeReviewId');
491
+ deps.log(` Created code review ${codeReviewId} ("${repo.title}")`);
492
+ }
493
+ const ssm = item('ssm-code-review');
494
+ if (codeReviewId && (ssm?.state === 'create' || (ssm?.state === 'conflict' && overwriteOk))) {
495
+ call(ctx, calls.putParameter(repo.param, codeReviewId, ssm.state === 'conflict'));
496
+ deps.log(` Set ${repo.param} = ${codeReviewId}`);
497
+ }
498
+ return { applicationId, codeReviewId };
499
+ }
500
+ export function preflight(ctx) {
501
+ const v = cliVersionOk(ctx.cliVersion);
502
+ if (!v.ok)
503
+ throw new Abort(`prism-cli enable security needs AWS CLI ${MIN_AWS_CLI_TEXT} or later for the securityagent commands (found ${v.version}). Install it from https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html`);
504
+ return identity(ctx);
505
+ }
506
+ /** Runs `enable security`. Returns the process exit code. */
507
+ export async function enableSecurity(input, deps) {
508
+ const readOnly = !!(input.dryRun || input.status);
509
+ // Before any AWS call: what this command does, or that this run changes nothing.
510
+ if (input.status)
511
+ deps.log('Read-only status check (--status): nothing will be changed.');
512
+ else if (input.dryRun)
513
+ deps.log('Dry run (--dry-run): every lookup runs, nothing will be changed.');
514
+ else
515
+ for (const l of DEPLOY_NOTICE)
516
+ deps.log(l);
517
+ deps.log('');
518
+ const r = resolveRegion(input, deps);
519
+ if (!r) {
520
+ deps.log(`Error: ${NO_REGION_MESSAGE}`);
521
+ return 1;
522
+ }
523
+ const opts = { ...input, region: r.region, regionSource: r.source };
524
+ const ctx = { opts, deps, cliVersion: deps.awsVersion(), readOnly };
525
+ try {
526
+ if (opts.status)
527
+ return securityStatus(ctx);
528
+ const id = preflight(ctx);
529
+ printIdentity(ctx, id);
530
+ const repo = resolveRepo(opts, deps);
531
+ deps.log('error' in repo ? `Repo: ${repo.error}` : `Repo: ${repo.id} (code review slug ${repo.slug})`);
532
+ // Probe the securityagent commands before the slow synth, so an old CLI fails fast.
533
+ const spaces = call(ctx, ['securityagent', 'list-agent-spaces']).json?.agentSpaceSummaries || [];
534
+ const target = chooseTarget(ctx, spaces);
535
+ deps.log(`Stack: ${target.stack.name} (${target.why})`);
536
+ const stack = planStack(ctx, id, target);
537
+ const stackReady = target.stack.hasSecurityAgent;
538
+ let svc = planService(ctx, id, repo, stackReady, target.stack.name);
539
+ printPlan(ctx, stack, svc);
540
+ deps.log('');
541
+ if (opts.dryRun) {
542
+ deps.log('Dry run: nothing was changed.');
543
+ return 0;
544
+ }
545
+ const blocked = svc.items.find((i) => i.state === 'blocked');
546
+ if (blocked)
547
+ throw new Abort(`${blocked.label}: ${blocked.detail}. Nothing was changed.`);
548
+ if (!hasWrites(stack, svc)) {
549
+ deps.log('Already set up: nothing to change.');
550
+ return finish(ctx, id, repo, svc.agentSpaceId, svc.app?.id ?? '', svc.review?.id ?? null);
551
+ }
552
+ const what = stack.needsDeploy ? `deploy ${stack.stack} and apply the Security Agent changes above` : 'apply the Security Agent changes above';
553
+ if (!(await confirm(ctx, id, `${what[0].toUpperCase()}${what.slice(1)} in account ${accountLabel(id)}, region ${opts.region}?`))) {
554
+ deps.log('Nothing was changed.');
555
+ return 1;
556
+ }
557
+ if (stack.needsDeploy) {
558
+ deps.log(`Deploying ${stack.stack}...`);
559
+ if (!deps.cdk.deploy(stack.assemblyDir, stack.stack, stack.cdkEnv, { approveAll: !!opts.yes }))
560
+ throw new Abort('cdk deploy failed (see the output above). Nothing after the deploy was attempted.');
561
+ svc = planService(ctx, id, repo, true, stack.stack);
562
+ deps.log('After the deploy:');
563
+ for (const it of svc.items)
564
+ deps.log(` [${STATE_WORD[it.state]}] ${it.label}: ${it.detail}`);
565
+ }
566
+ let overwriteOk = false;
567
+ const conflict = svc.items.find((i) => i.state === 'conflict');
568
+ if (conflict) {
569
+ deps.log(` ${conflict.detail}`);
570
+ overwriteOk = !!opts.yes || (deps.isTTY && /^y(es)?$/i.test((await deps.ask('Overwrite that SSM parameter? [y/N] ')).trim()));
571
+ if (!overwriteOk)
572
+ deps.log(' Left the SSM parameter unchanged.');
573
+ }
574
+ const res = applyService(ctx, svc, repo, overwriteOk);
575
+ if (conflict && !overwriteOk) {
576
+ deps.log('The CI eval gate still reads the old value. Re-run and approve the overwrite to fix it.');
577
+ return 1;
578
+ }
579
+ return finish(ctx, id, repo, svc.agentSpaceId, res.applicationId, res.codeReviewId);
580
+ }
581
+ catch (e) {
582
+ if (e instanceof Abort) {
583
+ deps.log(`Error: ${redact(e.message)}`);
584
+ return 1;
585
+ }
586
+ throw e;
587
+ }
588
+ }
589
+ function finish(ctx, id, repo, space, applicationId, codeReviewId) {
590
+ const { deps, opts } = ctx;
591
+ if (!('error' in repo) && codeReviewId && space) {
592
+ // Ignored first, so the file is never shown as untracked.
593
+ const excluded = ensureMarkerIgnored(repo.root, deps.git);
594
+ if (excluded)
595
+ deps.log(`Added ${MARKER_PATH} to ${excluded} so git ignores it in this clone.`);
596
+ const w = writeSecurityMarker(repo.root, { agentSpaceId: space, applicationId, codeReviewId, region: opts.region, account: id.account }, deps.now());
597
+ deps.log(w.wrote || w.migrated
598
+ ? `Wrote ${w.path} (ids, account and region; no secrets; local only, not committed)`
599
+ : `${w.path} is already current (local only, not committed)`);
600
+ if (w.migrated)
601
+ deps.log(`Moved the security block out of .prism/config.json into ${MARKER_PATH}; the rest of config.json is unchanged.`);
602
+ }
603
+ deps.log('');
604
+ deps.log(`Security Agent web console: https://${opts.region}.console.aws.amazon.com/securityagent/home?region=${opts.region} (choose "Admin access").`);
605
+ if (!('error' in repo))
606
+ deps.log(`The CI eval gate reads ${repo.param}; check everything with: prism-cli enable security --status`);
607
+ return 0;
608
+ }
609
+ /** Local check for the CI step that calls Security Agent (the eval gate's start-code-review-job). */
610
+ export function ciWorkflowStep(repoRoot) {
611
+ const files = ['.github/workflows/prism-eval-gate.yml', '.gitlab-ci.yml', '.prism/gitlab-workflows/prism-eval-gate.yml'];
612
+ for (const f of files) {
613
+ try {
614
+ const t = readFileSync(resolve(repoRoot, f), 'utf8');
615
+ if (t.includes('start-code-review-job'))
616
+ return { mark: 'ok', detail: `${f} runs the Security Agent code review` };
617
+ }
618
+ catch { /* not present */ }
619
+ }
620
+ return { mark: 'missing', detail: 'no eval gate workflow with the Security Agent step (prism-cli bootstrapper install-github-workflows)' };
621
+ }
622
+ function securityStatus(ctx) {
623
+ const { deps, opts } = ctx;
624
+ const rows = [];
625
+ const add = (m, label, detail) => rows.push([m, label, detail]);
626
+ const print = () => {
627
+ deps.log(`Security Agent status (region ${opts.region}, ${opts.regionSource}; credentials: ${credentialSource(opts, deps.env)}):`);
628
+ for (const [m, l, d] of rows)
629
+ deps.log(` [${m}] ${l}: ${d}`);
630
+ };
631
+ let id;
632
+ try {
633
+ id = preflight(ctx);
634
+ }
635
+ catch (e) {
636
+ add('missing', 'Credentials', e instanceof Abort ? redact(e.message).split('\n')[0] : String(e));
637
+ print();
638
+ return 1;
639
+ }
640
+ add('ok', 'Credentials', `account ${accountLabel(id)}, caller ${id.arn || 'unknown'}`);
641
+ const repo = resolveRepo(opts, deps);
642
+ try {
643
+ const pipe = readStack(ctx, PIPELINE_STACK);
644
+ const solo = readStack(ctx, STANDALONE_STACK);
645
+ const host = pipe.hasSecurityAgent && solo.hasSecurityAgent ? null : pipe.hasSecurityAgent ? pipe : solo.hasSecurityAgent ? solo : undefined;
646
+ if (host === null)
647
+ add('warn', 'Stack', `both ${PIPELINE_STACK} and ${STANDALONE_STACK} hold Security Agent resources (should be impossible)`);
648
+ else if (host)
649
+ add('ok', 'Stack', `${host.name} ${host.status}, with the Security Agent resources${host === pipe ? ' (pipeline layout)' : ' (standalone layout)'}`);
650
+ else
651
+ add('missing', 'Stack', `neither ${PIPELINE_STACK}${pipe.deployed ? ` (deployed, ${pipe.status}, without Security Agent)` : ' (not deployed)'} nor ${STANDALONE_STACK} holds Security Agent`);
652
+ const spaceParam = getParam(ctx, PARAM_AGENT_SPACE);
653
+ const spaces = call(ctx, ['securityagent', 'list-agent-spaces']).json?.agentSpaceSummaries || [];
654
+ const space = spaces.find((s) => s.agentSpaceId === spaceParam) || spaces.find((s) => s.name === AGENT_SPACE_NAME);
655
+ add(space ? 'ok' : 'missing', 'Agent space', space ? `${space.agentSpaceId} (${space.name})` : `none named ${AGENT_SPACE_NAME}`);
656
+ const roleParam = getParam(ctx, PARAM_SERVICE_ROLE);
657
+ const apps = call(ctx, ['securityagent', 'list-applications']).json?.applicationSummaries || [];
658
+ if (!apps.length)
659
+ add('missing', 'Application', 'none');
660
+ else {
661
+ const got = call(ctx, ['securityagent', 'get-application', '--application-id', String(apps[0].applicationId)]).json;
662
+ const want = roleParam || `arn:aws:iam::${id.account}:role/${SERVICE_ROLE_NAME}`;
663
+ const appId = String(apps[0].applicationId);
664
+ const many = apps.length > 1 ? ` (${apps.length} applications in this region; checked the first)` : '';
665
+ if (got?.roleArn !== want)
666
+ add('warn', 'Application', `${appId}, role ${got?.roleArn ?? 'not set'} (expected ${want})${many}`);
667
+ else {
668
+ const exists = roleExists(ctx, want);
669
+ // No stack yet: enable security deploys the role. A deployed stack owns it, so it was deleted outside CloudFormation.
670
+ const fix = host ? `${host.name} is deployed, so it was deleted outside CloudFormation; recreate it through the stack` : 'run prism-cli enable security';
671
+ if (exists === false)
672
+ add('warn', 'Application', `${appId}: role ${roleName(want)} attached but does not exist; ${fix}${many}`);
673
+ else
674
+ add('ok', 'Application', `${appId}, role attached${exists === null ? ' (could not check that it exists: no iam:GetRole)' : ''}${many}`);
675
+ }
676
+ }
677
+ for (const [name, label] of [[PARAM_AGENT_SPACE, 'SSM agent-space-id'], [PARAM_SCAN_BUCKET, 'SSM scan-bucket'], [PARAM_SERVICE_ROLE, 'SSM service-role-arn']]) {
678
+ const v = name === PARAM_AGENT_SPACE ? spaceParam : name === PARAM_SERVICE_ROLE ? roleParam : getParam(ctx, name);
679
+ add(v ? 'ok' : 'missing', label, v ? `${name} = ${v}` : `${name} not set`);
680
+ }
681
+ // Threat models (create-threat-model) refuse S3 objects in a bucket the agent space does not list.
682
+ const bucket = getParam(ctx, PARAM_SCAN_BUCKET);
683
+ if (!space || !bucket)
684
+ add('unknown', 'Scan bucket on agent space', 'needs the agent space and SSM scan-bucket first');
685
+ else {
686
+ const listed = spaceBuckets(ctx, space.agentSpaceId);
687
+ if (listed === null)
688
+ add('unknown', 'Scan bucket on agent space', `batch-get-agent-spaces did not return ${space.agentSpaceId}`);
689
+ else if (listsBucket(listed, bucket))
690
+ add('ok', 'Scan bucket on agent space', `${bucket} is in awsResources.s3Buckets`);
691
+ else
692
+ add('missing', 'Scan bucket on agent space', `${bucket} is not in awsResources.s3Buckets, so security threat-model fails; run prism-cli enable security to update the stack`);
693
+ }
694
+ if ('error' in repo)
695
+ add('unknown', 'Code review', repo.error);
696
+ else {
697
+ const v = getParam(ctx, repo.param);
698
+ const reviews = space ? call(ctx, ['securityagent', 'list-code-reviews', '--agent-space-id', space.agentSpaceId]).json?.codeReviewSummaries || [] : [];
699
+ const found = v ? reviews.find((r) => r.codeReviewId === v) : undefined;
700
+ if (!v)
701
+ add('missing', 'Code review', `${repo.param} not set (the CI gate creates one on its first run, or run prism-cli enable security)`);
702
+ else
703
+ add(found ? 'ok' : 'warn', 'Code review', found ? `${v} ("${found.title}") from ${repo.param}` : `${repo.param} = ${v}, but no such code review in the agent space`);
704
+ const legacy = getParam(ctx, LEGACY_PARAM_CODE_REVIEW);
705
+ if (legacy)
706
+ add('warn', 'Legacy SSM', `${LEGACY_PARAM_CODE_REVIEW} = ${legacy} (written by the old securityagent setup; nothing reads it)`);
707
+ const ci = ciWorkflowStep(repo.root);
708
+ add(ci.mark, 'CI workflow step', ci.detail);
709
+ const ms = readSecurityMarkerWithSource(repo.root);
710
+ if (!ms)
711
+ add('missing', 'Local marker', `no ${MARKER_PATH}`);
712
+ else if (ms.source === 'local')
713
+ add('ok', 'Local marker', `${MARKER_PATH} (code review ${ms.marker.codeReviewId}, set up ${ms.marker.setupAt || 'unknown'})`);
714
+ else
715
+ add('warn', 'Local marker', `legacy security block in .prism/config.json (code review ${ms.marker.codeReviewId}); the next prism-cli enable security moves it to ${MARKER_PATH}`);
716
+ }
717
+ }
718
+ catch (e) {
719
+ if (!(e instanceof Abort))
720
+ throw e;
721
+ add('unknown', 'Stopped', redact(e.message).split('\n')[0]);
722
+ print();
723
+ return 1;
724
+ }
725
+ print();
726
+ return 0;
727
+ }
728
+ //# sourceMappingURL=setup.js.map