@prism-d1/cli 1.1.2 → 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.
- package/dist/assets/github-workflows/prism-agent-eval.yml +2 -1
- package/dist/assets/github-workflows/prism-eval-gate-kiro.yml +12 -2
- package/dist/assets/gitlab-workflows/prism-eval-gate.yml +8 -0
- package/dist/assets/infra/bin/app.ts +61 -37
- package/dist/assets/infra/lib/constructs/otel-collector-construct.ts +96 -0
- package/dist/assets/infra/lib/constructs/security-agent-construct.ts +35 -30
- package/dist/assets/infra/lib/dashboard-stack.ts +27 -0
- package/dist/assets/infra/lib/lambda/otel-receiver.ts +278 -8
- package/dist/assets/infra/lib/lambda/pricing/litellm-snapshot.json +3769 -0
- package/dist/assets/infra/lib/lambda/pricing/price-source.ts +56 -0
- package/dist/assets/infra/lib/lambda/pricing/price-table.ts +240 -0
- package/dist/assets/infra/lib/lambda/pricing/prices-key.ts +6 -0
- package/dist/assets/infra/lib/lambda/pricing/reprice.ts +108 -0
- package/dist/assets/infra/lib/lambda/pricing-refresh.ts +352 -0
- package/dist/assets/infra/lib/lambda/velocity-widget.ts +124 -1
- 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/infra/package.json +4 -1
- package/dist/assets/metric-hooks/prepare-commit-msg +3 -1
- 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/codewatch/config.d.ts +17 -0
- package/dist/src/codewatch/config.d.ts.map +1 -0
- package/dist/src/codewatch/config.js +40 -0
- package/dist/src/codewatch/config.js.map +1 -0
- package/dist/src/codewatch/gitleaks.d.ts +49 -0
- package/dist/src/codewatch/gitleaks.d.ts.map +1 -0
- package/dist/src/codewatch/gitleaks.js +160 -0
- package/dist/src/codewatch/gitleaks.js.map +1 -0
- package/dist/src/codewatch/globalHooks.d.ts +52 -0
- package/dist/src/codewatch/globalHooks.d.ts.map +1 -0
- package/dist/src/codewatch/globalHooks.js +235 -0
- package/dist/src/codewatch/globalHooks.js.map +1 -0
- package/dist/src/codewatch/hooks.d.ts +38 -0
- package/dist/src/codewatch/hooks.d.ts.map +1 -0
- package/dist/src/codewatch/hooks.js +176 -0
- package/dist/src/codewatch/hooks.js.map +1 -0
- package/dist/src/codewatch/prepush.d.ts +37 -0
- package/dist/src/codewatch/prepush.d.ts.map +1 -0
- package/dist/src/codewatch/prepush.js +231 -0
- package/dist/src/codewatch/prepush.js.map +1 -0
- package/dist/src/codewatch/results.d.ts +210 -0
- package/dist/src/codewatch/results.d.ts.map +1 -0
- package/dist/src/codewatch/results.js +127 -0
- package/dist/src/codewatch/results.js.map +1 -0
- package/dist/src/codewatch/reviewers/claude.d.ts +9 -0
- package/dist/src/codewatch/reviewers/claude.d.ts.map +1 -0
- package/dist/src/codewatch/reviewers/claude.js +48 -0
- package/dist/src/codewatch/reviewers/claude.js.map +1 -0
- package/dist/src/codewatch/reviewers/index.d.ts +39 -0
- package/dist/src/codewatch/reviewers/index.d.ts.map +1 -0
- package/dist/src/codewatch/reviewers/index.js +97 -0
- package/dist/src/codewatch/reviewers/index.js.map +1 -0
- package/dist/src/codewatch/reviewers/kiro.d.ts +14 -0
- package/dist/src/codewatch/reviewers/kiro.d.ts.map +1 -0
- package/dist/src/codewatch/reviewers/kiro.js +32 -0
- package/dist/src/codewatch/reviewers/kiro.js.map +1 -0
- package/dist/src/codewatch/reviewers/types.d.ts +56 -0
- package/dist/src/codewatch/reviewers/types.d.ts.map +1 -0
- package/dist/src/codewatch/reviewers/types.js +91 -0
- package/dist/src/codewatch/reviewers/types.js.map +1 -0
- package/dist/src/codewatch/rules.d.ts +12 -0
- package/dist/src/codewatch/rules.d.ts.map +1 -0
- package/dist/src/codewatch/rules.js +27 -0
- package/dist/src/codewatch/rules.js.map +1 -0
- package/dist/src/commands/_top/dashboard.d.ts +17 -0
- package/dist/src/commands/_top/dashboard.d.ts.map +1 -0
- package/dist/src/commands/_top/dashboard.js +76 -0
- package/dist/src/commands/_top/dashboard.js.map +1 -0
- 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/bedrock-protection/scan-account.d.ts.map +1 -1
- package/dist/src/commands/bedrock-protection/scan-account.js +2 -1
- 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 +16 -4
- package/dist/src/commands/bedrock-protection/scan-org.js.map +1 -1
- package/dist/src/commands/bootstrapper/install-eval-harness.d.ts.map +1 -1
- package/dist/src/commands/bootstrapper/install-eval-harness.js +15 -48
- package/dist/src/commands/bootstrapper/install-eval-harness.js.map +1 -1
- package/dist/src/commands/bootstrapper/install-git-hooks.d.ts +13 -0
- package/dist/src/commands/bootstrapper/install-git-hooks.d.ts.map +1 -1
- package/dist/src/commands/bootstrapper/install-git-hooks.js +28 -147
- package/dist/src/commands/bootstrapper/install-git-hooks.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 -443
- package/dist/src/commands/bootstrapper/setup-otel-sync.js.map +1 -1
- package/dist/src/commands/codewatch/pre-commit.d.ts +7 -0
- package/dist/src/commands/codewatch/pre-commit.d.ts.map +1 -0
- package/dist/src/commands/codewatch/pre-commit.js +30 -0
- package/dist/src/commands/codewatch/pre-commit.js.map +1 -0
- package/dist/src/commands/codewatch/pre-push.d.ts +16 -0
- package/dist/src/commands/codewatch/pre-push.d.ts.map +1 -0
- package/dist/src/commands/codewatch/pre-push.js +32 -0
- package/dist/src/commands/codewatch/pre-push.js.map +1 -0
- package/dist/src/commands/codewatch/report.d.ts +22 -0
- package/dist/src/commands/codewatch/report.d.ts.map +1 -0
- package/dist/src/commands/codewatch/report.js +64 -0
- package/dist/src/commands/codewatch/report.js.map +1 -0
- 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/codewatch.d.ts +15 -0
- package/dist/src/commands/enable/codewatch.d.ts.map +1 -0
- package/dist/src/commands/enable/codewatch.js +186 -0
- package/dist/src/commands/enable/codewatch.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 +130 -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/git/claude-session-context.d.ts.map +1 -1
- package/dist/src/commands/git/claude-session-context.js +2 -1
- package/dist/src/commands/git/claude-session-context.js.map +1 -1
- package/dist/src/commands/git/commit-trailers.js +1 -1
- package/dist/src/commands/git/commit-trailers.js.map +1 -1
- 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 +43 -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 +31 -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 +32 -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 +24 -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 +23 -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/commands/workshop/verify-setup.d.ts.map +1 -1
- package/dist/src/commands/workshop/verify-setup.js +5 -3
- package/dist/src/commands/workshop/verify-setup.js.map +1 -1
- package/dist/src/dashboard/codewatch.d.ts +106 -0
- package/dist/src/dashboard/codewatch.d.ts.map +1 -0
- package/dist/src/dashboard/codewatch.js +120 -0
- package/dist/src/dashboard/codewatch.js.map +1 -0
- package/dist/src/dashboard/commits.d.ts +96 -0
- package/dist/src/dashboard/commits.d.ts.map +1 -0
- package/dist/src/dashboard/commits.js +207 -0
- package/dist/src/dashboard/commits.js.map +1 -0
- package/dist/src/dashboard/page.d.ts +10 -0
- package/dist/src/dashboard/page.d.ts.map +1 -0
- package/dist/src/dashboard/page.js +470 -0
- package/dist/src/dashboard/page.js.map +1 -0
- package/dist/src/dashboard/payload.d.ts +114 -0
- package/dist/src/dashboard/payload.d.ts.map +1 -0
- package/dist/src/dashboard/payload.js +96 -0
- package/dist/src/dashboard/payload.js.map +1 -0
- package/dist/src/dashboard/report.d.ts +99 -0
- package/dist/src/dashboard/report.d.ts.map +1 -0
- package/dist/src/dashboard/report.js +300 -0
- package/dist/src/dashboard/report.js.map +1 -0
- package/dist/src/dashboard/repos.d.ts +58 -0
- package/dist/src/dashboard/repos.d.ts.map +1 -0
- package/dist/src/dashboard/repos.js +111 -0
- package/dist/src/dashboard/repos.js.map +1 -0
- 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/server.d.ts +52 -0
- package/dist/src/dashboard/server.d.ts.map +1 -0
- package/dist/src/dashboard/server.js +163 -0
- package/dist/src/dashboard/server.js.map +1 -0
- package/dist/src/dashboard/setup.d.ts +67 -0
- package/dist/src/dashboard/setup.d.ts.map +1 -0
- package/dist/src/dashboard/setup.js +146 -0
- package/dist/src/dashboard/setup.js.map +1 -0
- package/dist/src/dashboard/usage.d.ts +43 -0
- package/dist/src/dashboard/usage.d.ts.map +1 -0
- package/dist/src/dashboard/usage.js +88 -0
- package/dist/src/dashboard/usage.js.map +1 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +48 -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 +193 -0
- package/dist/src/security/setup.d.ts.map +1 -0
- package/dist/src/security/setup.js +728 -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-bedrock.d.ts +1 -0
- package/dist/src/utils/audit-bedrock.d.ts.map +1 -1
- package/dist/src/utils/audit-bedrock.js +133 -0
- package/dist/src/utils/audit-bedrock.js.map +1 -1
- package/dist/src/utils/audit-org.d.ts +1 -0
- package/dist/src/utils/audit-org.d.ts.map +1 -1
- package/dist/src/utils/audit-org.js +155 -0
- package/dist/src/utils/audit-org.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 +41 -0
- package/dist/src/utils/otel-sync-schedule.d.ts.map +1 -0
- package/dist/src/utils/otel-sync-schedule.js +90 -0
- package/dist/src/utils/otel-sync-schedule.js.map +1 -0
- package/dist/src/utils/tool-check.d.ts +14 -0
- package/dist/src/utils/tool-check.d.ts.map +1 -0
- package/dist/src/utils/tool-check.js +42 -0
- package/dist/src/utils/tool-check.js.map +1 -0
- package/package.json +3 -2
|
@@ -0,0 +1,1057 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `prism-cli security pentest`: run an AWS Security Agent penetration test
|
|
3
|
+
* against an application the caller owns, by deploying it into a fresh, private,
|
|
4
|
+
* temporary environment in the caller's account and testing only that.
|
|
5
|
+
*
|
|
6
|
+
* Design: docs/DESIGN-prism-cli-pentest.md (its open questions were resolved
|
|
7
|
+
* by a hand-run spike). It reuses the account/region/identity, the AWS-call
|
|
8
|
+
* seam (deps.aws), the CDK runner seam (deps.cdk), the pending-record and
|
|
9
|
+
* polling style, and the result cache from enable security / code-review.
|
|
10
|
+
*
|
|
11
|
+
* Version 1 only tests the environment it just deployed (no arbitrary URL):
|
|
12
|
+
* the target is built from the repo in front of it, deployed to a private VPC
|
|
13
|
+
* (no public endpoint, no internet gateway, no 0.0.0.0/0 ingress), reached by
|
|
14
|
+
* the pentest agent's ENI over local VPC routing, and verified by PRIVATE_VPC
|
|
15
|
+
* domain verification before the job starts.
|
|
16
|
+
*
|
|
17
|
+
* The AWS-writing and CDK/Docker-running parts go through seams, so tests run
|
|
18
|
+
* the whole orchestration against a fake account and a fake adapter.
|
|
19
|
+
*/
|
|
20
|
+
import { basename } from 'node:path';
|
|
21
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
22
|
+
import { isAbsolute, join, normalize } from 'node:path';
|
|
23
|
+
import { makeResult, readResultEntries, writeResult, } from '../codewatch/results.js';
|
|
24
|
+
import { redact } from './errors.js';
|
|
25
|
+
import { Abort, call, preflight, printIdentity, resolveRegion, resolveRepo, NO_REGION_MESSAGE, } from './setup.js';
|
|
26
|
+
import { POLL_FIRST_MS, POLL_MAX_MS, POLL_RETRIES, RETRY_FIRST_MS, RISK_ORDER, TERMINAL, resolveRepoRoot, } from './review.js';
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Constants
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
/** A pentest runs for hours (the spike took ~2h50m), so --wait defaults to 6h, not code-review's 30m. */
|
|
31
|
+
export const PENTEST_DEFAULT_TIMEOUT_MIN = 360;
|
|
32
|
+
/** Terminal job states (STOPPING is not terminal). Same set as code-review. */
|
|
33
|
+
export { TERMINAL };
|
|
34
|
+
export const FAIL_ON_LEVELS = ['critical', 'high', 'medium', 'low'];
|
|
35
|
+
const RANK = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, INFORMATIONAL: 0, UNKNOWN: 0 };
|
|
36
|
+
const riskRank = (r) => RANK[r] ?? 0;
|
|
37
|
+
const DESCRIPTION_CHARS = 200;
|
|
38
|
+
/** Findings per list-findings page and ids per batch-get-findings call (as code-review assumes). */
|
|
39
|
+
const FINDINGS_PAGE = 100;
|
|
40
|
+
const DETAILS_BATCH = 25;
|
|
41
|
+
export const PENTEST_NOTICE = 'prism-cli security pentest deploys a TEMPORARY private environment (a VPC with private subnets only, Fargate behind an internal ALB, VPC endpoints, a service role, a source bucket and a log group) into the AWS account your credentials point to, builds and pushes your app image, uploads the repo at HEAD, registers and verifies a PRIVATE_VPC target domain, and starts an AWS Security Agent penetration test against that environment only. The environment is reachable only by the pentest agent; it has no public endpoint. It is torn down when the job finishes (--keep leaves it up, --cleanup removes it later).';
|
|
42
|
+
export const COST_WARNING = 'Cost: AWS Security Agent penetration testing is billed in task-hours (publicly reported at $50 per task-hour, per-second metered; verify current pricing at the AWS Security Agent pricing page), plus the Fargate, internal ALB and VPC-endpoint running cost for as long as the environment is up. A run can last hours (the reference run took ~2h50m).';
|
|
43
|
+
export const PENTEST_CONFIG_PATH = '.prism/pentest.json';
|
|
44
|
+
/** Env var keys whose value must never be a literal in the committed file. */
|
|
45
|
+
const SECRETY_KEY = /pass|secret|token|key|cred|pwd/i;
|
|
46
|
+
const PROVIDER_TYPES = new Set(['SECRETS_MANAGER', 'AWS_LAMBDA', 'AWS_IAM_ROLE']);
|
|
47
|
+
/**
|
|
48
|
+
* Loads and validates `.prism/pentest.json`. A sensitive-looking env value must
|
|
49
|
+
* be a Secrets Manager reference ({ "secret": "<arn>" }), never a literal, so
|
|
50
|
+
* no credential lands in the committed file.
|
|
51
|
+
*/
|
|
52
|
+
export function loadPentestConfig(repoRoot, override) {
|
|
53
|
+
const file = join(repoRoot, PENTEST_CONFIG_PATH);
|
|
54
|
+
let raw = {};
|
|
55
|
+
if (existsSync(file)) {
|
|
56
|
+
try {
|
|
57
|
+
raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
throw new Abort(`${PENTEST_CONFIG_PATH} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const appType = override?.appType || String(raw.appType || 'dockerfile');
|
|
64
|
+
// "path" is the Docker build context and "dockerfile" the Dockerfile, both
|
|
65
|
+
// relative to the repo root. --app-dir / --dockerfile override them; --path
|
|
66
|
+
// only picks the repo root. Neither may point outside the repo.
|
|
67
|
+
const inRepo = (v) => !isAbsolute(v) && !normalize(v).split(/[\\/]/).includes('..');
|
|
68
|
+
const path = String(override?.appDir || raw.path || '.');
|
|
69
|
+
if (!inRepo(path)) {
|
|
70
|
+
throw new Abort(`${override?.appDir ? '--app-dir' : `${PENTEST_CONFIG_PATH}: "path"`} must be a folder inside the repo (relative to the repo root, no ".."), got ${JSON.stringify(path)}`);
|
|
71
|
+
}
|
|
72
|
+
const dockerfile = String(override?.dockerfile || raw.dockerfile || join(path, 'Dockerfile'));
|
|
73
|
+
if (!inRepo(dockerfile)) {
|
|
74
|
+
throw new Abort(`${override?.dockerfile ? '--dockerfile' : `${PENTEST_CONFIG_PATH}: "dockerfile"`} must be a file inside the repo (relative to the repo root, no ".."), got ${JSON.stringify(dockerfile)}`);
|
|
75
|
+
}
|
|
76
|
+
const port = Number(raw.port ?? 8080);
|
|
77
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535)
|
|
78
|
+
throw new Abort(`${PENTEST_CONFIG_PATH}: "port" must be a TCP port, got ${JSON.stringify(raw.port)}`);
|
|
79
|
+
const healthPath = String(raw.healthPath || '/health');
|
|
80
|
+
if (!healthPath.startsWith('/'))
|
|
81
|
+
throw new Abort(`${PENTEST_CONFIG_PATH}: "healthPath" must start with "/", got ${JSON.stringify(raw.healthPath)}`);
|
|
82
|
+
const env = [];
|
|
83
|
+
const secretEnv = [];
|
|
84
|
+
const envObj = raw.env && typeof raw.env === 'object' ? raw.env : {};
|
|
85
|
+
for (const [name, v] of Object.entries(envObj)) {
|
|
86
|
+
if (v && typeof v === 'object' && typeof v.secret === 'string') {
|
|
87
|
+
if (!v.secret)
|
|
88
|
+
throw new Abort(`${PENTEST_CONFIG_PATH}: env "${name}" has an empty secret reference`);
|
|
89
|
+
secretEnv.push({ name, secret: v.secret });
|
|
90
|
+
}
|
|
91
|
+
else if (typeof v === 'string') {
|
|
92
|
+
if (SECRETY_KEY.test(name))
|
|
93
|
+
throw new Abort(`${PENTEST_CONFIG_PATH}: env "${name}" looks sensitive; give it as a Secrets Manager reference ({ "secret": "<arn>" }), never a literal value in the committed file.`);
|
|
94
|
+
env.push({ name, value: v });
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
throw new Abort(`${PENTEST_CONFIG_PATH}: env "${name}" must be a string or { "secret": "<arn>" }`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const actors = [];
|
|
101
|
+
for (const a of Array.isArray(raw.actors) ? raw.actors : []) {
|
|
102
|
+
const identifier = String(a?.identifier || '');
|
|
103
|
+
if (!identifier)
|
|
104
|
+
throw new Abort(`${PENTEST_CONFIG_PATH}: each actor needs an "identifier"`);
|
|
105
|
+
const auth = a?.auth || {};
|
|
106
|
+
const providerType = String(auth.providerType || 'SECRETS_MANAGER');
|
|
107
|
+
if (!PROVIDER_TYPES.has(providerType))
|
|
108
|
+
throw new Abort(`${PENTEST_CONFIG_PATH}: actor "${identifier}" providerType must be one of ${[...PROVIDER_TYPES].join(', ')}`);
|
|
109
|
+
const value = String(auth.secret || auth.value || '');
|
|
110
|
+
if (!value)
|
|
111
|
+
throw new Abort(`${PENTEST_CONFIG_PATH}: actor "${identifier}" needs a "secret"/"value" (an ARN; never a literal credential)`);
|
|
112
|
+
actors.push({ identifier, uris: Array.isArray(a.uris) ? a.uris.map(String) : undefined, auth: { providerType, value } });
|
|
113
|
+
}
|
|
114
|
+
const excludeRiskTypes = Array.isArray(raw.excludeRiskTypes) ? raw.excludeRiskTypes.map(String) : [];
|
|
115
|
+
return { appType, path, dockerfile, port, healthPath, env, secretEnv, actors, excludeRiskTypes };
|
|
116
|
+
}
|
|
117
|
+
export function toPentestJob(j) {
|
|
118
|
+
const steps = Array.isArray(j?.steps) ? j.steps : [];
|
|
119
|
+
const ctxs = Array.isArray(j?.executionContext) ? j.executionContext : [];
|
|
120
|
+
const running = steps.find((s) => s?.status === 'IN_PROGRESS');
|
|
121
|
+
const err = ctxs.filter((c) => c?.contextType === 'ERROR' || c?.contextType === 'CLIENT_ERROR').map((c) => String(c.context || '')).filter(Boolean);
|
|
122
|
+
const info = j?.errorInformation;
|
|
123
|
+
const reason = [err.join('; '), info ? `${info.code || ''}${info.message ? `: ${info.message}` : ''}` : ''].filter(Boolean).join('; ') || null;
|
|
124
|
+
return {
|
|
125
|
+
jobId: String(j?.pentestJobId || ''),
|
|
126
|
+
status: String(j?.status || 'UNKNOWN'),
|
|
127
|
+
step: running?.name ? String(running.name) : null,
|
|
128
|
+
reason,
|
|
129
|
+
overview: j?.overview ? firstParagraph(String(j.overview)) : null,
|
|
130
|
+
createdAt: j?.createdAt ? String(j.createdAt) : undefined,
|
|
131
|
+
updatedAt: j?.updatedAt ? String(j.updatedAt) : undefined,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
function firstParagraph(s) {
|
|
135
|
+
const p = s.split(/\n\s*\n/)[0].replace(/\s+/g, ' ').trim();
|
|
136
|
+
return p.length > 500 ? `${p.slice(0, 499)}…` : p;
|
|
137
|
+
}
|
|
138
|
+
/** The id at the end of an ARN (`.../sg-123` -> `sg-123`); an id passes through. */
|
|
139
|
+
const arnId = (arn) => arn.split('/').pop() || arn;
|
|
140
|
+
/** The Env fields a stored record carries, for paths that rebuild a result from it. */
|
|
141
|
+
function envOf(p) {
|
|
142
|
+
return { targetHost: p.target_host, stack: p.stack || '', slug: p.slug || '', expiresAt: p.expires_at || '', agentSgId: p.agent_security_group_id ?? null, appLogGroup: p.app_log_group ?? null, targetDomainId: p.target_domain_id ?? null };
|
|
143
|
+
}
|
|
144
|
+
/** Common pentest info fields for both the pending record and its completion. */
|
|
145
|
+
function pentestInfo(o) {
|
|
146
|
+
return {
|
|
147
|
+
job_id: o.job.jobId, pentest_id: o.pentestId, agent_space_id: o.agentSpaceId, agent_space_dedicated: o.dedicated,
|
|
148
|
+
region: o.region, account: o.account, target_host: o.env.targetHost, job_status: o.job.status, counts: o.counts,
|
|
149
|
+
overview: o.job.overview ?? null, stack: o.env.stack || null, slug: o.env.slug || null, expires_at: o.env.expiresAt,
|
|
150
|
+
agent_security_group_id: o.env.agentSgId ?? null, app_log_group: o.env.appLogGroup ?? null,
|
|
151
|
+
target_domain_id: o.env.targetDomainId ?? null,
|
|
152
|
+
service_created_at: o.job.createdAt ?? null, service_updated_at: o.job.updatedAt ?? null,
|
|
153
|
+
service_duration_s: serviceSeconds(o.job), wall_s: o.wallS ?? null,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
function serviceSeconds(job) {
|
|
157
|
+
if (!TERMINAL.has(job.status) || !job.createdAt || !job.updatedAt)
|
|
158
|
+
return null;
|
|
159
|
+
const a = Date.parse(job.createdAt), b = Date.parse(job.updatedAt);
|
|
160
|
+
return Number.isFinite(a) && Number.isFinite(b) && b >= a ? Math.round((b - a) / 1000) : null;
|
|
161
|
+
}
|
|
162
|
+
export function pendingPentestResult(o) {
|
|
163
|
+
return makeResult({
|
|
164
|
+
kind: 'security-pentest', hook: 'manual', tool: 'security-agent', status: 'pending',
|
|
165
|
+
commit: o.commit, branch: o.branch, started_at: o.startedAt, finished_at: o.startedAt,
|
|
166
|
+
pentest: pentestInfo({ ...o, counts: {} }),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
function toSeverity(risk) {
|
|
170
|
+
return riskRank(risk) >= 3 ? 'high' : riskRank(risk) === 2 ? 'medium' : 'low';
|
|
171
|
+
}
|
|
172
|
+
function shortText(s) {
|
|
173
|
+
const one = redact(s).replace(/\s+/g, ' ').trim();
|
|
174
|
+
return one.length > DESCRIPTION_CHARS ? `${one.slice(0, DESCRIPTION_CHARS - 1)}…` : one;
|
|
175
|
+
}
|
|
176
|
+
export function countByRisk(fs) {
|
|
177
|
+
const c = Object.fromEntries(RISK_ORDER.map((r) => [r, 0]));
|
|
178
|
+
for (const f of fs)
|
|
179
|
+
c[RISK_ORDER.includes(f.riskLevel) ? f.riskLevel : 'UNKNOWN']++;
|
|
180
|
+
return c;
|
|
181
|
+
}
|
|
182
|
+
export function completedPentestResult(o) {
|
|
183
|
+
const counts = countByRisk(o.findings);
|
|
184
|
+
const ok = o.job.status === 'COMPLETED';
|
|
185
|
+
const findings = sortFindings(o.findings).map((f) => ({
|
|
186
|
+
severity: toSeverity(f.riskLevel), category: 'security', rule: f.riskType || f.title,
|
|
187
|
+
file: '', line: null, message: f.description ? `${f.title}: ${shortText(f.description)}` : f.title,
|
|
188
|
+
risk_level: f.riskLevel, title: f.title, cwe: null, finding_id: f.findingId, confidence: f.confidence,
|
|
189
|
+
}));
|
|
190
|
+
return makeResult({
|
|
191
|
+
kind: 'security-pentest', hook: 'manual', tool: 'security-agent',
|
|
192
|
+
status: !ok ? 'error' : findings.length ? 'findings' : 'ok',
|
|
193
|
+
commit: o.commit, branch: o.branch,
|
|
194
|
+
started_at: o.startedAt, finished_at: o.job.updatedAt || o.now.toISOString(),
|
|
195
|
+
findings,
|
|
196
|
+
summary: ok ? (o.job.overview || totalsLine(counts)) : null,
|
|
197
|
+
reason: ok ? null : `job ${o.job.status}${o.job.reason ? `: ${redact(o.job.reason)}` : ''}`,
|
|
198
|
+
pentest: pentestInfo({ ...o, counts }),
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
function sortFindings(fs) {
|
|
202
|
+
return [...fs].sort((a, b) => riskRank(b.riskLevel) - riskRank(a.riskLevel) || a.title.localeCompare(b.title));
|
|
203
|
+
}
|
|
204
|
+
export function totalsLine(c) {
|
|
205
|
+
const total = Object.values(c).reduce((a, b) => a + b, 0);
|
|
206
|
+
return `${total} finding${total === 1 ? '' : 's'}: ` + RISK_ORDER.map((r) => `${c[r]} ${r.toLowerCase()}`).join(', ');
|
|
207
|
+
}
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
// Reads: job and findings
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
function jobArgs(agentSpaceId, jobId) {
|
|
212
|
+
return ['securityagent', 'batch-get-pentest-jobs', '--agent-space-id', agentSpaceId, '--pentest-job-ids', jobId];
|
|
213
|
+
}
|
|
214
|
+
export function getPentestJob(ctx, agentSpaceId, jobId) {
|
|
215
|
+
const j = (call(ctx, jobArgs(agentSpaceId, jobId)).json?.pentestJobs || [])[0];
|
|
216
|
+
if (!j)
|
|
217
|
+
throw new Abort(`No pentest job ${jobId} in agent space ${agentSpaceId}.`);
|
|
218
|
+
return toPentestJob(j);
|
|
219
|
+
}
|
|
220
|
+
function paged(ctx, args, key) {
|
|
221
|
+
const out = [];
|
|
222
|
+
let token;
|
|
223
|
+
for (let page = 0; page < 1000; page++) {
|
|
224
|
+
const r = call(ctx, [...args, '--max-items', String(FINDINGS_PAGE), ...(token ? ['--starting-token', token] : [])]);
|
|
225
|
+
out.push(...(r.json?.[key] || []));
|
|
226
|
+
token = r.json?.NextToken || r.json?.nextToken || undefined;
|
|
227
|
+
if (!token)
|
|
228
|
+
return out;
|
|
229
|
+
}
|
|
230
|
+
throw new Abort(`${args[1]} returned more than 1000 pages; stopping.`);
|
|
231
|
+
}
|
|
232
|
+
/** Pentest findings, dropping FALSE_POSITIVE confidence unless `includeFP`. */
|
|
233
|
+
export function fetchPentestFindings(ctx, agentSpaceId, jobId, includeFP) {
|
|
234
|
+
const summaries = paged(ctx, ['securityagent', 'list-findings', '--agent-space-id', agentSpaceId, '--pentest-job-id', jobId], 'findingsSummaries');
|
|
235
|
+
const details = new Map();
|
|
236
|
+
for (let i = 0; i < summaries.length; i += DETAILS_BATCH) {
|
|
237
|
+
const ids = summaries.slice(i, i + DETAILS_BATCH).map((s) => String(s.findingId)).filter(Boolean);
|
|
238
|
+
if (!ids.length)
|
|
239
|
+
continue;
|
|
240
|
+
const r = ctx.deps.aws(['securityagent', 'batch-get-findings', '--agent-space-id', agentSpaceId, '--finding-ids', ...ids], ctx.opts.region);
|
|
241
|
+
if (r.ok)
|
|
242
|
+
for (const f of r.json?.findings || [])
|
|
243
|
+
details.set(String(f.findingId), f);
|
|
244
|
+
}
|
|
245
|
+
const all = summaries.map((s) => {
|
|
246
|
+
const d = details.get(String(s.findingId)) || {};
|
|
247
|
+
return {
|
|
248
|
+
findingId: String(s.findingId || ''),
|
|
249
|
+
riskLevel: String(d.riskLevel || s.riskLevel || 'UNKNOWN').toUpperCase(),
|
|
250
|
+
confidence: String(d.confidence || s.confidence || '').toUpperCase(),
|
|
251
|
+
title: String(d.name || s.name || '(untitled finding)'),
|
|
252
|
+
riskType: String(d.riskType || s.riskType || ''),
|
|
253
|
+
description: String(d.description || ''),
|
|
254
|
+
};
|
|
255
|
+
});
|
|
256
|
+
return includeFP ? all : all.filter((f) => f.confidence !== 'FALSE_POSITIVE');
|
|
257
|
+
}
|
|
258
|
+
export function failOnHit(fs, failOn) {
|
|
259
|
+
if (!failOn)
|
|
260
|
+
return false;
|
|
261
|
+
const min = riskRank(failOn.toUpperCase());
|
|
262
|
+
return fs.some((f) => riskRank(f.riskLevel) >= min && riskRank(f.riskLevel) > 0);
|
|
263
|
+
}
|
|
264
|
+
function printFindings(say, fs) {
|
|
265
|
+
const sorted = sortFindings(fs);
|
|
266
|
+
for (const level of RISK_ORDER) {
|
|
267
|
+
const group = sorted.filter((f) => f.riskLevel === level || (level === 'UNKNOWN' && !RISK_ORDER.includes(f.riskLevel)));
|
|
268
|
+
if (!group.length)
|
|
269
|
+
continue;
|
|
270
|
+
say(`${level} (${group.length})`);
|
|
271
|
+
for (const f of group) {
|
|
272
|
+
say(` ${f.title}${f.confidence ? ` [${f.confidence.toLowerCase()} confidence]` : ''}`);
|
|
273
|
+
if (f.description)
|
|
274
|
+
say(` ${shortText(f.description)}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
say(totalsLine(countByRisk(fs)));
|
|
278
|
+
}
|
|
279
|
+
const mmss = (ms) => `${String(Math.floor(ms / 60000)).padStart(2, '0')}:${String(Math.floor(ms / 1000) % 60).padStart(2, '0')}`;
|
|
280
|
+
const NOT_TRANSIENT = /ResourceNotFound|ValidationException|NotFound/;
|
|
281
|
+
async function getJobRetrying(ctx, agentSpaceId, jobId, signal) {
|
|
282
|
+
const deps = ctx.deps;
|
|
283
|
+
const args = jobArgs(agentSpaceId, jobId);
|
|
284
|
+
for (let attempt = 0;; attempt++) {
|
|
285
|
+
const r = ctx.deps.aws(args, ctx.opts.region);
|
|
286
|
+
if (r.ok) {
|
|
287
|
+
const j = (r.json?.pentestJobs || [])[0];
|
|
288
|
+
if (!j)
|
|
289
|
+
throw new Abort(`No pentest job ${jobId} in agent space ${agentSpaceId}.`);
|
|
290
|
+
return toPentestJob(j);
|
|
291
|
+
}
|
|
292
|
+
if (NOT_TRANSIENT.test(r.raw) || attempt >= POLL_RETRIES)
|
|
293
|
+
throw new Abort(`${redact(r.raw).split('\n')[0]}\n${fetchLaterHint(ctx, jobId)}`);
|
|
294
|
+
const wait = RETRY_FIRST_MS * 2 ** attempt;
|
|
295
|
+
ctx.deps.log(` Poll failed; retrying in ${wait / 1000}s (${attempt + 1}/${POLL_RETRIES})`);
|
|
296
|
+
await deps.sleep(wait, signal);
|
|
297
|
+
if (signal.aborted)
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
export async function pollPentest(ctx, agentSpaceId, first, timeoutMin) {
|
|
302
|
+
const deps = ctx.deps;
|
|
303
|
+
const start = deps.now().getTime();
|
|
304
|
+
const limit = timeoutMin * 60_000;
|
|
305
|
+
const ac = new AbortController();
|
|
306
|
+
let interrupted = false;
|
|
307
|
+
const off = deps.onInterrupt(() => { interrupted = true; ac.abort(); });
|
|
308
|
+
let job = first;
|
|
309
|
+
let delay = POLL_FIRST_MS;
|
|
310
|
+
try {
|
|
311
|
+
while (!TERMINAL.has(job.status)) {
|
|
312
|
+
const elapsed = deps.now().getTime() - start;
|
|
313
|
+
if (elapsed >= limit)
|
|
314
|
+
return { kind: 'timeout', job };
|
|
315
|
+
await deps.sleep(Math.min(delay, limit - elapsed), ac.signal);
|
|
316
|
+
if (interrupted)
|
|
317
|
+
return { kind: 'interrupted', job };
|
|
318
|
+
delay = Math.min(delay * 2, POLL_MAX_MS);
|
|
319
|
+
const next = await getJobRetrying(ctx, agentSpaceId, job.jobId, ac.signal);
|
|
320
|
+
if (!next)
|
|
321
|
+
return { kind: 'interrupted', job };
|
|
322
|
+
job = next;
|
|
323
|
+
const line = `[${mmss(deps.now().getTime() - start)}] ${job.status}${job.step ? ` (${job.step})` : ''}`;
|
|
324
|
+
if (deps.isTTY)
|
|
325
|
+
deps.ticker(line);
|
|
326
|
+
else
|
|
327
|
+
ctx.deps.log(` ${line}`);
|
|
328
|
+
}
|
|
329
|
+
return { kind: 'done', job };
|
|
330
|
+
}
|
|
331
|
+
finally {
|
|
332
|
+
off();
|
|
333
|
+
if (deps.isTTY)
|
|
334
|
+
deps.ticker('');
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
/** The --profile/--region this run was given, so a printed follow-up command targets the same account and region. */
|
|
338
|
+
export const followUpFlags = (ctx) => `${ctx.opts.profile ? ` --profile ${ctx.opts.profile}` : ''}${ctx.opts.regionSource === '--region' ? ` --region ${ctx.opts.region}` : ''}`;
|
|
339
|
+
const cleanupCmd = (ctx) => `prism-cli security pentest --cleanup${followUpFlags(ctx)}`;
|
|
340
|
+
const fetchLaterHint = (ctx, jobId) => `The job keeps running in AWS. Check it later with: prism-cli security pentest --status${followUpFlags(ctx)} (or: prism-cli security findings --pentest ${jobId}${followUpFlags(ctx)})`;
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
// Helpers shared by the command paths
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
function begin(input, deps, readOnly, say) {
|
|
345
|
+
const r = resolveRegion(input, deps);
|
|
346
|
+
if (!r) {
|
|
347
|
+
say(`Error: ${NO_REGION_MESSAGE}`);
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
const opts = { ...input, region: r.region, regionSource: r.source };
|
|
351
|
+
return { opts, deps: { ...deps, log: say }, cliVersion: deps.awsVersion(), readOnly };
|
|
352
|
+
}
|
|
353
|
+
function checkedRoot(deps, path, say) {
|
|
354
|
+
try {
|
|
355
|
+
return resolveRepoRoot(deps, path);
|
|
356
|
+
}
|
|
357
|
+
catch (e) {
|
|
358
|
+
if (e instanceof Abort) {
|
|
359
|
+
say(`Error: ${redact(e.message)}`);
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
362
|
+
throw e;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
function needRepo(ctx, root) {
|
|
366
|
+
const repo = resolveRepo(ctx.opts, ctx.deps, root);
|
|
367
|
+
if ('error' in repo)
|
|
368
|
+
throw new Abort(repo.error);
|
|
369
|
+
return repo;
|
|
370
|
+
}
|
|
371
|
+
async function confirm(ctx, question) {
|
|
372
|
+
const { deps, opts } = ctx;
|
|
373
|
+
if (opts.yes) {
|
|
374
|
+
deps.log(`--yes: approved.`);
|
|
375
|
+
return true;
|
|
376
|
+
}
|
|
377
|
+
if (!ctx.deps.isTTY) {
|
|
378
|
+
deps.log('Refusing to deploy or start anything: there is no terminal to confirm on. Re-run with --yes to approve, or --dry-run to only see the plan.');
|
|
379
|
+
return false;
|
|
380
|
+
}
|
|
381
|
+
const a = (await ctx.deps.ask(`${question} [y/N] `)).trim();
|
|
382
|
+
return /^y(es)?$/i.test(a);
|
|
383
|
+
}
|
|
384
|
+
function parseTimeout(v) {
|
|
385
|
+
if (v === undefined)
|
|
386
|
+
return PENTEST_DEFAULT_TIMEOUT_MIN;
|
|
387
|
+
const n = Number(v);
|
|
388
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
389
|
+
throw new Abort(`--timeout must be a positive number of minutes, got "${v}"`);
|
|
390
|
+
return n;
|
|
391
|
+
}
|
|
392
|
+
function parseLevel(flag, v) {
|
|
393
|
+
if (v === undefined)
|
|
394
|
+
return undefined;
|
|
395
|
+
if (!FAIL_ON_LEVELS.includes(v.toLowerCase()))
|
|
396
|
+
throw new Abort(`${flag} must be one of: ${FAIL_ON_LEVELS.join(', ')}`);
|
|
397
|
+
return v.toLowerCase();
|
|
398
|
+
}
|
|
399
|
+
/** The newest security-pentest record for this account+region (pending first, else any), used by --status/--cleanup/findings. */
|
|
400
|
+
export function latestPentestEntry(root, account, region, jobId) {
|
|
401
|
+
const entries = readResultEntries(root)
|
|
402
|
+
.filter(({ result: r }) => r.kind === 'security-pentest' && r.pentest?.account === account && r.pentest?.region === region && (!jobId || r.pentest?.job_id === jobId))
|
|
403
|
+
.sort((a, b) => b.result.started_at.localeCompare(a.result.started_at));
|
|
404
|
+
// A pending record is the one still driving an environment; then one whose
|
|
405
|
+
// environment is still up (a failed teardown, a health timeout); then the newest.
|
|
406
|
+
return entries.find((e) => e.result.status === 'pending')
|
|
407
|
+
?? entries.find((e) => e.result.pentest?.stack)
|
|
408
|
+
?? entries[0] ?? null;
|
|
409
|
+
}
|
|
410
|
+
const mmssOf = (ms) => `${String(Math.floor(ms / 60000)).padStart(2, '0')}:${String(Math.floor(ms / 1000) % 60).padStart(2, '0')}`;
|
|
411
|
+
const profileHint = (ctx) => `--region ${ctx.opts.region}${ctx.opts.profile ? ` --profile ${ctx.opts.profile}` : ''}`;
|
|
412
|
+
// ---------------------------------------------------------------------------
|
|
413
|
+
// Healthy target wait
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
/** cdk deploy returns before the ALB has a healthy target; starting then gets 503s and PREFLIGHT fails. */
|
|
416
|
+
export const HEALTH_TIMEOUT_MS = 10 * 60_000;
|
|
417
|
+
export const HEALTH_POLL_MS = 15_000;
|
|
418
|
+
function targetStates(ctx, tgArn) {
|
|
419
|
+
const r = call(ctx, ['elbv2', 'describe-target-health', '--target-group-arn', tgArn]);
|
|
420
|
+
return (r.json?.TargetHealthDescriptions || []).map((d) => ({
|
|
421
|
+
state: String(d?.TargetHealth?.State || 'unknown'),
|
|
422
|
+
reason: String(d?.TargetHealth?.Reason || ''),
|
|
423
|
+
description: String(d?.TargetHealth?.Description || ''),
|
|
424
|
+
}));
|
|
425
|
+
}
|
|
426
|
+
const describeTargets = (ts) => ts.length
|
|
427
|
+
? ts.map((t) => `${t.state}${t.reason ? ` (${t.reason})` : ''}${t.description ? `: ${t.description}` : ''}`).join('; ')
|
|
428
|
+
: 'no targets registered yet';
|
|
429
|
+
/**
|
|
430
|
+
* Polls the ALB target group until at least one target is `healthy`. On
|
|
431
|
+
* timeout aborts with the targets' state, reason and description and how to
|
|
432
|
+
* read the app's logs -- before anything is created in Security Agent.
|
|
433
|
+
*/
|
|
434
|
+
export async function waitForHealthyTarget(ctx, env, say, timeoutMs = HEALTH_TIMEOUT_MS) {
|
|
435
|
+
const deps = ctx.deps;
|
|
436
|
+
const start = deps.now().getTime();
|
|
437
|
+
say(`Waiting for the app to pass the ALB health check (up to ${Math.round(timeoutMs / 60_000)} min)...`);
|
|
438
|
+
const never = new AbortController().signal;
|
|
439
|
+
for (;;) {
|
|
440
|
+
const ts = targetStates(ctx, env.targetGroupArn);
|
|
441
|
+
const elapsed = deps.now().getTime() - start;
|
|
442
|
+
if (ts.some((t) => t.state === 'healthy')) {
|
|
443
|
+
say(` [${mmssOf(elapsed)}] healthy`);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
say(` [${mmssOf(elapsed)}] ${describeTargets(ts)}`);
|
|
447
|
+
if (elapsed >= timeoutMs) {
|
|
448
|
+
throw new Abort([
|
|
449
|
+
`the app never passed the ALB health check within ${Math.round(timeoutMs / 60_000)} min, so no pentest was created.`,
|
|
450
|
+
`Targets: ${describeTargets(ts)}`,
|
|
451
|
+
`App logs (log group ${env.appLogGroup}): aws logs tail ${env.appLogGroup} --since 30m ${profileHint(ctx)}`,
|
|
452
|
+
`The environment is left up so you can read them; remove it with: ${cleanupCmd(ctx)}`,
|
|
453
|
+
].join('\n'));
|
|
454
|
+
}
|
|
455
|
+
await deps.sleep(Math.min(HEALTH_POLL_MS, timeoutMs - elapsed), never);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
// ---------------------------------------------------------------------------
|
|
459
|
+
// Teardown
|
|
460
|
+
// ---------------------------------------------------------------------------
|
|
461
|
+
/** Security Agent removes its ENIs a while after a job ends; the stack cannot delete the subnets/SG until then. */
|
|
462
|
+
export const ENI_DRAIN_TIMEOUT_MS = 10 * 60_000;
|
|
463
|
+
export const ENI_POLL_MS = 15_000;
|
|
464
|
+
const TAIL_LINES = 20;
|
|
465
|
+
/** Waits until no ENI uses the agent security group. Returns how many remained (0 = drained). */
|
|
466
|
+
async function waitForEnisDrained(ctx, sgId, say) {
|
|
467
|
+
const deps = ctx.deps;
|
|
468
|
+
const start = deps.now().getTime();
|
|
469
|
+
const never = new AbortController().signal;
|
|
470
|
+
let announced = false;
|
|
471
|
+
for (;;) {
|
|
472
|
+
const r = ctx.deps.aws(['ec2', 'describe-network-interfaces', '--filters', `Name=group-id,Values=${sgId}`], ctx.opts.region);
|
|
473
|
+
if (!r.ok) {
|
|
474
|
+
say(` could not list the agent's network interfaces (${redact(r.raw).split('\n')[0]}); deleting the stack anyway`);
|
|
475
|
+
return 0;
|
|
476
|
+
}
|
|
477
|
+
const enis = r.json?.NetworkInterfaces || [];
|
|
478
|
+
if (!enis.length) {
|
|
479
|
+
if (announced)
|
|
480
|
+
say(' the agent network interfaces are gone');
|
|
481
|
+
return 0;
|
|
482
|
+
}
|
|
483
|
+
const elapsed = deps.now().getTime() - start;
|
|
484
|
+
say(` [${mmssOf(elapsed)}] waiting for ${enis.length} agent network interface(s) to be released (${enis.map((e) => `${e.NetworkInterfaceId} ${e.Status || ''}`.trim()).join(', ')})`);
|
|
485
|
+
announced = true;
|
|
486
|
+
if (elapsed >= ENI_DRAIN_TIMEOUT_MS)
|
|
487
|
+
return enis.length;
|
|
488
|
+
await deps.sleep(Math.min(ENI_POLL_MS, ENI_DRAIN_TIMEOUT_MS - elapsed), never);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
/** The stack's DELETE_FAILED events, newest first, one line each. */
|
|
492
|
+
function deleteFailures(ctx, stack) {
|
|
493
|
+
const r = ctx.deps.aws(['cloudformation', 'describe-stack-events', '--stack-name', stack], ctx.opts.region);
|
|
494
|
+
if (!r.ok)
|
|
495
|
+
return [];
|
|
496
|
+
const seen = new Set();
|
|
497
|
+
const out = [];
|
|
498
|
+
for (const e of r.json?.StackEvents || []) {
|
|
499
|
+
if (e?.ResourceStatus !== 'DELETE_FAILED' || seen.has(e.LogicalResourceId))
|
|
500
|
+
continue;
|
|
501
|
+
seen.add(e.LogicalResourceId);
|
|
502
|
+
out.push(`${e.LogicalResourceId} (${e.ResourceType}): ${redact(String(e.ResourceStatusReason || ''))}`);
|
|
503
|
+
}
|
|
504
|
+
return out.slice(0, 10);
|
|
505
|
+
}
|
|
506
|
+
/** How long one stack deletion may take (VPC endpoints, the ALB and the bucket-emptying custom resource). */
|
|
507
|
+
export const STACK_DELETE_TIMEOUT_MS = 30 * 60_000;
|
|
508
|
+
export const STACK_POLL_MS = 15_000;
|
|
509
|
+
const GONE = /does not exist/;
|
|
510
|
+
/**
|
|
511
|
+
* Deletes the recorded stack with CloudFormation directly and waits for it to
|
|
512
|
+
* go. No synth: `cdk destroy` re-synthesizes the app, which needs the deploy's
|
|
513
|
+
* full context (appDir, ...) and lookups, and failed before reaching
|
|
514
|
+
* CloudFormation. On failure prints the error tail and DELETE_FAILED resources.
|
|
515
|
+
*/
|
|
516
|
+
async function deleteStackOnce(ctx, stack, say) {
|
|
517
|
+
const deps = ctx.deps;
|
|
518
|
+
const never = new AbortController().signal;
|
|
519
|
+
const fail = (raw, what) => {
|
|
520
|
+
const lines = redact(raw).trimEnd().split('\n').filter(Boolean);
|
|
521
|
+
say(` ${what} for ${stack} failed. Last lines:`);
|
|
522
|
+
for (const l of lines.slice(-TAIL_LINES))
|
|
523
|
+
say(` ${l}`);
|
|
524
|
+
return { ok: false, reason: lines.pop() || what };
|
|
525
|
+
};
|
|
526
|
+
say(` deleting the environment stack ${stack} (a few minutes)...`);
|
|
527
|
+
const del = ctx.deps.aws(['cloudformation', 'delete-stack', '--stack-name', stack], ctx.opts.region);
|
|
528
|
+
if (!del.ok)
|
|
529
|
+
return GONE.test(del.raw) ? { ok: true, reason: '' } : fail(del.raw, 'delete-stack');
|
|
530
|
+
const start = deps.now().getTime();
|
|
531
|
+
for (;;) {
|
|
532
|
+
const d = ctx.deps.aws(['cloudformation', 'describe-stacks', '--stack-name', stack], ctx.opts.region);
|
|
533
|
+
if (!d.ok) {
|
|
534
|
+
if (GONE.test(d.raw)) {
|
|
535
|
+
say(` deleted the environment stack ${stack}`);
|
|
536
|
+
return { ok: true, reason: '' };
|
|
537
|
+
}
|
|
538
|
+
return fail(d.raw, 'describe-stacks');
|
|
539
|
+
}
|
|
540
|
+
const s = d.json?.Stacks?.[0] || {};
|
|
541
|
+
const status = String(s.StackStatus || 'UNKNOWN');
|
|
542
|
+
if (status === 'DELETE_COMPLETE') {
|
|
543
|
+
say(` deleted the environment stack ${stack}`);
|
|
544
|
+
return { ok: true, reason: '' };
|
|
545
|
+
}
|
|
546
|
+
const elapsed = deps.now().getTime() - start;
|
|
547
|
+
if (status === 'DELETE_FAILED' || elapsed >= STACK_DELETE_TIMEOUT_MS) {
|
|
548
|
+
const reason = status === 'DELETE_FAILED' ? `DELETE_FAILED: ${redact(String(s.StackStatusReason || ''))}` : `still ${status} after ${Math.round(STACK_DELETE_TIMEOUT_MS / 60_000)} min`;
|
|
549
|
+
say(` stack ${stack}: ${reason}`);
|
|
550
|
+
const failed = deleteFailures(ctx, stack);
|
|
551
|
+
if (failed.length) {
|
|
552
|
+
say(' Resources that failed to delete:');
|
|
553
|
+
for (const f of failed)
|
|
554
|
+
say(` ${f}`);
|
|
555
|
+
}
|
|
556
|
+
return { ok: false, reason };
|
|
557
|
+
}
|
|
558
|
+
say(` [${mmssOf(elapsed)}] ${status}`);
|
|
559
|
+
await deps.sleep(Math.min(STACK_POLL_MS, STACK_DELETE_TIMEOUT_MS - elapsed), never);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Tears down the environment an entry recorded: stop a running job, delete the
|
|
564
|
+
* pentest and a dedicated agent space, wait for the agent's ENIs to drain, then
|
|
565
|
+
* delete the recorded stack, retrying once after another drain. Best effort per step,
|
|
566
|
+
* so it can be re-run. Writes the record: environment cleared on success, a
|
|
567
|
+
* `teardown_failed` marker (stack kept for --cleanup) on failure. Returns ok.
|
|
568
|
+
*/
|
|
569
|
+
async function teardown(ctx, root, entry, say) {
|
|
570
|
+
const p = entry.result.pentest;
|
|
571
|
+
const tryCall = (args, what) => {
|
|
572
|
+
const r = ctx.deps.aws(args, ctx.opts.region);
|
|
573
|
+
say(r.ok ? ` ${what}` : ` ${what}: skipped (${redact(r.raw).split('\n')[0]})`);
|
|
574
|
+
};
|
|
575
|
+
if (p.job_id && !TERMINAL.has(p.job_status))
|
|
576
|
+
tryCall(['securityagent', 'stop-pentest-job', '--agent-space-id', p.agent_space_id, '--pentest-job-id', p.job_id], 'stopped the job');
|
|
577
|
+
if (p.pentest_id)
|
|
578
|
+
tryCall(['securityagent', 'batch-delete-pentests', '--agent-space-id', p.agent_space_id, '--pentest-ids', p.pentest_id], 'deleted the pentest');
|
|
579
|
+
if (p.agent_space_dedicated && p.agent_space_id)
|
|
580
|
+
tryCall(['securityagent', 'delete-agent-space', '--agent-space-id', p.agent_space_id], 'deleted the dedicated agent space');
|
|
581
|
+
// After the agent space, which references it. Records from before the id was kept have none.
|
|
582
|
+
if (p.target_domain_id)
|
|
583
|
+
tryCall(['securityagent', 'delete-target-domain', '--target-domain-id', p.target_domain_id], 'deleted the target domain');
|
|
584
|
+
if (!p.stack) {
|
|
585
|
+
if (p.target_domain_id)
|
|
586
|
+
writeResult(root, { ...entry.result, pentest: { ...p, target_domain_id: null } }, entry.id);
|
|
587
|
+
return true;
|
|
588
|
+
}
|
|
589
|
+
const drain = () => (p.agent_security_group_id ? waitForEnisDrained(ctx, p.agent_security_group_id, say) : Promise.resolve(0));
|
|
590
|
+
await drain();
|
|
591
|
+
let r = await deleteStackOnce(ctx, p.stack, say);
|
|
592
|
+
if (!r.ok) {
|
|
593
|
+
say(' retrying once after the agent network interfaces drain...');
|
|
594
|
+
await drain();
|
|
595
|
+
r = await deleteStackOnce(ctx, p.stack, say);
|
|
596
|
+
}
|
|
597
|
+
const now = ctx.deps.now().toISOString();
|
|
598
|
+
if (r.ok) {
|
|
599
|
+
writeResult(root, { ...entry.result, pentest: { ...p, stack: null, slug: null, teardown_failed: null, target_domain_id: null } }, entry.id);
|
|
600
|
+
say('Environment torn down; the result record is kept.');
|
|
601
|
+
return true;
|
|
602
|
+
}
|
|
603
|
+
writeResult(root, { ...entry.result, pentest: { ...p, teardown_failed: { at: now, reason: r.reason } } }, entry.id);
|
|
604
|
+
say(`Teardown failed; the stack ${p.stack} is still up (and may cost money). Retry with: ${cleanupCmd(ctx)}`);
|
|
605
|
+
return false;
|
|
606
|
+
}
|
|
607
|
+
/**
|
|
608
|
+
* The environment stack a record belongs to: the stored name, else the slug,
|
|
609
|
+
* else derived from the target host. shortcut: the derivation follows the
|
|
610
|
+
* dockerfile adapter's naming (PrismPentest-<slug>, app.<slug>.prism.internal)
|
|
611
|
+
* and exists for records whose earlier teardown cleared the name; drop it once
|
|
612
|
+
* those records are gone.
|
|
613
|
+
*/
|
|
614
|
+
export function recordedStackName(p) {
|
|
615
|
+
if (p.stack)
|
|
616
|
+
return p.stack;
|
|
617
|
+
const slug = p.slug || /^app\.(.+)\.prism\.internal$/.exec(p.target_host || '')?.[1];
|
|
618
|
+
return slug ? `PrismPentest-${slug}` : null;
|
|
619
|
+
}
|
|
620
|
+
/** Does the stack exist (and is not DELETE_COMPLETE)? null when it cannot be told. */
|
|
621
|
+
function stackExists(ctx, stack) {
|
|
622
|
+
const r = ctx.deps.aws(['cloudformation', 'describe-stacks', '--stack-name', stack], ctx.opts.region);
|
|
623
|
+
if (r.ok)
|
|
624
|
+
return String(r.json?.Stacks?.[0]?.StackStatus || '') !== 'DELETE_COMPLETE';
|
|
625
|
+
return GONE.test(r.raw) ? false : null;
|
|
626
|
+
}
|
|
627
|
+
export function teardownFailedLine(ctx, p) {
|
|
628
|
+
return `Teardown failed at ${p.teardown_failed.at}: ${p.teardown_failed.reason}. The stack ${p.stack} is still up; retry with: ${cleanupCmd(ctx)}`;
|
|
629
|
+
}
|
|
630
|
+
// ---------------------------------------------------------------------------
|
|
631
|
+
// Commands
|
|
632
|
+
// ---------------------------------------------------------------------------
|
|
633
|
+
/** `prism-cli security pentest`. Returns the exit code. */
|
|
634
|
+
export async function securityPentest(input, deps, app) {
|
|
635
|
+
const say = deps.log;
|
|
636
|
+
const root = checkedRoot(deps, input.path, say);
|
|
637
|
+
if (!root)
|
|
638
|
+
return 1;
|
|
639
|
+
// --status and --cleanup act on an existing run and deploy nothing.
|
|
640
|
+
if (input.status || input.cleanup)
|
|
641
|
+
return runStatusOrCleanup(input, deps, app, root, say);
|
|
642
|
+
// Local checks first: a bad config, app type or Dockerfile path is reported
|
|
643
|
+
// before the notice and before any AWS call.
|
|
644
|
+
let cfg;
|
|
645
|
+
try {
|
|
646
|
+
cfg = loadPentestConfig(root, { appType: input.appType, appDir: input.appDir, dockerfile: input.dockerfile });
|
|
647
|
+
if (input.appType && input.appType !== app.type)
|
|
648
|
+
throw new Abort(`--app-type ${input.appType} is not supported; v1 ships "${app.type}".`);
|
|
649
|
+
if (cfg.appType !== app.type)
|
|
650
|
+
throw new Abort(`${PENTEST_CONFIG_PATH} appType "${cfg.appType}" is not supported; v1 ships "${app.type}".`);
|
|
651
|
+
if (!app.detect(join(root, cfg.path), join(root, cfg.dockerfile))) {
|
|
652
|
+
throw new Abort(`no ${app.type} app: build context ${cfg.path} / Dockerfile ${cfg.dockerfile} not found in the repo; set "path" and "dockerfile" in ${PENTEST_CONFIG_PATH}, or pass --app-dir / --dockerfile.`);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
catch (e) {
|
|
656
|
+
if (e instanceof Abort) {
|
|
657
|
+
say(`Error: ${redact(e.message)}`);
|
|
658
|
+
return 1;
|
|
659
|
+
}
|
|
660
|
+
throw e;
|
|
661
|
+
}
|
|
662
|
+
say(PENTEST_NOTICE);
|
|
663
|
+
say('');
|
|
664
|
+
const ctx = begin(input, deps, false, say);
|
|
665
|
+
if (!ctx)
|
|
666
|
+
return 1;
|
|
667
|
+
try {
|
|
668
|
+
const failOn = parseLevel('--fail-on', input.failOn);
|
|
669
|
+
const timeout = parseTimeout(input.timeout);
|
|
670
|
+
const id = preflight(ctx);
|
|
671
|
+
printIdentity(ctx, id);
|
|
672
|
+
const repo = needRepo(ctx, root);
|
|
673
|
+
const plan = app.plan(ctx, repo, cfg);
|
|
674
|
+
say('');
|
|
675
|
+
say('Plan (no changes made yet):');
|
|
676
|
+
say(plan.diffText);
|
|
677
|
+
say('');
|
|
678
|
+
say(`Target: ${plan.targetHost} (private; only this URL is in scope)`);
|
|
679
|
+
say(COST_WARNING);
|
|
680
|
+
if (input.dryRun) {
|
|
681
|
+
say('');
|
|
682
|
+
say('--dry-run: nothing was deployed or started.');
|
|
683
|
+
return 0;
|
|
684
|
+
}
|
|
685
|
+
say('');
|
|
686
|
+
if (!(await confirm(ctx, `Deploy this environment and start a penetration test in account ${id.account}, region ${ctx.opts.region}?`))) {
|
|
687
|
+
say('Aborted; nothing was changed.');
|
|
688
|
+
return 1;
|
|
689
|
+
}
|
|
690
|
+
// Deploy the private target environment.
|
|
691
|
+
say('Deploying the private target environment...');
|
|
692
|
+
const env = app.apply(ctx, repo, cfg, plan, true);
|
|
693
|
+
say(`Target up: ${env.targetUrl}`);
|
|
694
|
+
const envRec = { targetHost: env.targetHost, stack: env.stack, slug: env.slug, expiresAt: env.expiresAt, agentSgId: arnId(env.agentSecurityGroupArn), appLogGroup: env.appLogGroup };
|
|
695
|
+
// Only start once the ALB serves the app: a 5xx target fails PREFLIGHT.
|
|
696
|
+
try {
|
|
697
|
+
await waitForHealthyTarget(ctx, env, say);
|
|
698
|
+
}
|
|
699
|
+
catch (e) {
|
|
700
|
+
if (!(e instanceof Abort))
|
|
701
|
+
throw e;
|
|
702
|
+
// Record the environment (no pentest exists) so --cleanup can remove it.
|
|
703
|
+
const at = deps.now().toISOString();
|
|
704
|
+
const path = writeResult(root, makeResult({
|
|
705
|
+
kind: 'security-pentest', hook: 'manual', tool: 'security-agent', status: 'error',
|
|
706
|
+
commit: gitLine(ctx, repo, ['rev-parse', 'HEAD']), branch: gitLine(ctx, repo, ['rev-parse', '--abbrev-ref', 'HEAD']),
|
|
707
|
+
started_at: at, finished_at: at, reason: 'the app never became healthy behind the ALB; no pentest was created',
|
|
708
|
+
pentest: pentestInfo({ job: { jobId: '', status: 'NOT_STARTED' }, pentestId: '', agentSpaceId: '', dedicated: false, region: ctx.opts.region, account: id.account, env: envRec, counts: {} }),
|
|
709
|
+
}));
|
|
710
|
+
say(`Error: ${redact(e.message)}`);
|
|
711
|
+
say(`Saved ${path}`);
|
|
712
|
+
return 1;
|
|
713
|
+
}
|
|
714
|
+
// Verify the target domain (PRIVATE_VPC: UNREACHABLE is OK, only FAILED is an error).
|
|
715
|
+
const domainId = createAndVerifyDomain(ctx, env.targetHost, say);
|
|
716
|
+
envRec.targetDomainId = domainId;
|
|
717
|
+
// Resolve the agent space, then the pentest, then start the job.
|
|
718
|
+
const { agentSpaceId, dedicated } = resolveAgentSpace(ctx, env, domainId, say);
|
|
719
|
+
const pentestId = createPentest(ctx, cfg, env, agentSpaceId, say);
|
|
720
|
+
const startJson = call(ctx, ['securityagent', 'start-pentest-job', '--agent-space-id', agentSpaceId, '--pentest-id', pentestId]).json;
|
|
721
|
+
const started = toPentestJob({ ...startJson, status: startJson?.status || 'IN_PROGRESS' });
|
|
722
|
+
if (!started.jobId)
|
|
723
|
+
throw new Abort('start-pentest-job returned no pentestJobId.');
|
|
724
|
+
say(`Started pentest job ${started.jobId}`);
|
|
725
|
+
const commit = gitLine(ctx, repo, ['rev-parse', 'HEAD']);
|
|
726
|
+
const branch = gitLine(ctx, repo, ['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
727
|
+
const recordBase = { pentestId, agentSpaceId, dedicated, region: ctx.opts.region, account: id.account, env: envRec, commit, branch };
|
|
728
|
+
const startedAt = deps.now().toISOString();
|
|
729
|
+
let pendingId;
|
|
730
|
+
try {
|
|
731
|
+
const path = writeResult(root, pendingPentestResult({ ...recordBase, job: started, startedAt }));
|
|
732
|
+
pendingId = basename(path, '.json');
|
|
733
|
+
say(`Saved ${path} (pending)`);
|
|
734
|
+
}
|
|
735
|
+
catch (e) {
|
|
736
|
+
say(`Warning: could not save the pending record (${redact(e instanceof Error ? e.message : String(e))}).`);
|
|
737
|
+
}
|
|
738
|
+
if (!input.wait) {
|
|
739
|
+
say('');
|
|
740
|
+
say(`Not waiting (the default). The environment stays up until the job finishes.`);
|
|
741
|
+
say(`Check status: prism-cli security pentest --status${followUpFlags(ctx)}`);
|
|
742
|
+
say(` prism-cli security findings --pentest ${started.jobId}${followUpFlags(ctx)}`);
|
|
743
|
+
say(`Remove it now: ${cleanupCmd(ctx)}`);
|
|
744
|
+
return 0;
|
|
745
|
+
}
|
|
746
|
+
say(`Waiting for the job (up to ${timeout} min; Ctrl-C stops waiting, the job keeps running)...`);
|
|
747
|
+
const startMs = deps.now().getTime();
|
|
748
|
+
const out = await pollPentest(ctx, agentSpaceId, started, timeout);
|
|
749
|
+
if (out.kind === 'interrupted') {
|
|
750
|
+
say('');
|
|
751
|
+
say(`Stopped waiting. ${fetchLaterHint(ctx, started.jobId)}`);
|
|
752
|
+
return 130;
|
|
753
|
+
}
|
|
754
|
+
if (out.kind === 'timeout') {
|
|
755
|
+
say(`Timed out after ${timeout} min (job ${started.jobId} is ${out.job.status}). ${fetchLaterHint(ctx, started.jobId)}`);
|
|
756
|
+
return 2;
|
|
757
|
+
}
|
|
758
|
+
const wallS = Math.round((deps.now().getTime() - startMs) / 1000);
|
|
759
|
+
const findings = out.job.status === 'COMPLETED' ? fetchPentestFindings(ctx, agentSpaceId, started.jobId, !!input.includeFalsePositives) : [];
|
|
760
|
+
const result = completedPentestResult({ ...recordBase, job: out.job, startedAt, findings, now: deps.now(), wallS });
|
|
761
|
+
const path = writeResult(root, result, pendingId);
|
|
762
|
+
say('');
|
|
763
|
+
if (out.job.status === 'COMPLETED') {
|
|
764
|
+
if (out.job.overview) {
|
|
765
|
+
say(out.job.overview);
|
|
766
|
+
say('');
|
|
767
|
+
}
|
|
768
|
+
if (findings.length)
|
|
769
|
+
printFindings(say, findings);
|
|
770
|
+
else
|
|
771
|
+
say('No findings.');
|
|
772
|
+
}
|
|
773
|
+
else {
|
|
774
|
+
say(`Error: job ${started.jobId} ended ${out.job.status}${out.job.reason ? `: ${redact(out.job.reason)}` : ''}`);
|
|
775
|
+
}
|
|
776
|
+
say(`Saved ${path}`);
|
|
777
|
+
// Teardown unless --keep.
|
|
778
|
+
if (input.keep) {
|
|
779
|
+
say(`--keep: the environment stays up. Remove it later with: ${cleanupCmd(ctx)}`);
|
|
780
|
+
}
|
|
781
|
+
else {
|
|
782
|
+
say('Tearing down the environment...');
|
|
783
|
+
if (!(await teardown(ctx, root, { id: basename(path, '.json'), result }, say)))
|
|
784
|
+
return 1;
|
|
785
|
+
}
|
|
786
|
+
if (out.job.status !== 'COMPLETED')
|
|
787
|
+
return 1;
|
|
788
|
+
if (failOnHit(findings, failOn)) {
|
|
789
|
+
say(`--fail-on ${failOn}: findings at or above ${failOn}.`);
|
|
790
|
+
return 1;
|
|
791
|
+
}
|
|
792
|
+
return 0;
|
|
793
|
+
}
|
|
794
|
+
catch (e) {
|
|
795
|
+
if (e instanceof Abort) {
|
|
796
|
+
say(`Error: ${redact(e.message)}`);
|
|
797
|
+
return 1;
|
|
798
|
+
}
|
|
799
|
+
throw e;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
/** --status (refresh + print, tear down on terminal unless --keep) and --cleanup (remove now). */
|
|
803
|
+
async function runStatusOrCleanup(input, deps, app, root, say) {
|
|
804
|
+
if (input.cleanup)
|
|
805
|
+
say('Cleaning up the pentest environment (stops the job if running, deletes what the run created).');
|
|
806
|
+
else
|
|
807
|
+
say('Read-only status (refreshes the latest pentest job; tears the environment down only once the job has finished).');
|
|
808
|
+
say('');
|
|
809
|
+
const ctx = begin(input, deps, false, say);
|
|
810
|
+
if (!ctx)
|
|
811
|
+
return 1;
|
|
812
|
+
try {
|
|
813
|
+
const id = preflight(ctx);
|
|
814
|
+
printIdentity(ctx, id);
|
|
815
|
+
const entry = latestPentestEntry(root, id.account, ctx.opts.region);
|
|
816
|
+
if (!entry || !entry.result.pentest) {
|
|
817
|
+
say('No pentest runs recorded for this repo in this account and region.');
|
|
818
|
+
return 0;
|
|
819
|
+
}
|
|
820
|
+
const p = entry.result.pentest;
|
|
821
|
+
say(p.job_id ? `Pentest job: ${p.job_id} (recorded ${p.job_status}; target ${p.target_host})` : `No pentest job was started: ${redact(entry.result.reason || 'unknown')}`);
|
|
822
|
+
if (!p.stack && input.cleanup) {
|
|
823
|
+
// A finished record with no stack: either it really was deleted, or an
|
|
824
|
+
// older teardown cleared the name although the delete never happened.
|
|
825
|
+
// Look the stack up by its recorded (or derived) name and delete it if it is still there.
|
|
826
|
+
const name = recordedStackName(p);
|
|
827
|
+
if (!name) {
|
|
828
|
+
say('The record names no environment stack; nothing to delete.');
|
|
829
|
+
return 0;
|
|
830
|
+
}
|
|
831
|
+
const exists = stackExists(ctx, name);
|
|
832
|
+
if (exists === false) {
|
|
833
|
+
say(`The environment stack ${name} no longer exists.`);
|
|
834
|
+
if (p.target_domain_id)
|
|
835
|
+
return (await teardown(ctx, root, entry, say)) ? 0 : 1;
|
|
836
|
+
return 0;
|
|
837
|
+
}
|
|
838
|
+
say(`The environment stack ${name} still exists${exists === null ? ' (or could not be checked)' : ''}; deleting it.`);
|
|
839
|
+
const revived = { id: entry.id, result: { ...entry.result, pentest: { ...p, stack: name, slug: name.replace(/^PrismPentest-/, '') } } };
|
|
840
|
+
say('Tearing down:');
|
|
841
|
+
return (await teardown(ctx, root, revived, say)) ? 0 : 1;
|
|
842
|
+
}
|
|
843
|
+
if (!p.stack) {
|
|
844
|
+
say('The environment for this run was already torn down.');
|
|
845
|
+
return 0;
|
|
846
|
+
}
|
|
847
|
+
if (p.teardown_failed)
|
|
848
|
+
say(teardownFailedLine(ctx, p));
|
|
849
|
+
if (input.cleanup) {
|
|
850
|
+
say('Tearing down:');
|
|
851
|
+
return (await teardown(ctx, root, entry, say)) ? 0 : 1;
|
|
852
|
+
}
|
|
853
|
+
// A failed teardown or a run that never started is retried by --cleanup only:
|
|
854
|
+
// --status stays a quick read, never a 10-minute ENI wait.
|
|
855
|
+
if (p.teardown_failed || !p.job_id) {
|
|
856
|
+
say(`Remove the environment with: ${cleanupCmd(ctx)}`);
|
|
857
|
+
return p.teardown_failed ? 1 : 0;
|
|
858
|
+
}
|
|
859
|
+
// --status: refresh the job.
|
|
860
|
+
const job = getPentestJob(ctx, p.agent_space_id, p.job_id);
|
|
861
|
+
say(`Now: ${job.status}${job.step ? ` (${job.step})` : ''}`);
|
|
862
|
+
if (!TERMINAL.has(job.status)) {
|
|
863
|
+
say('Still running; check back later.');
|
|
864
|
+
return 0;
|
|
865
|
+
}
|
|
866
|
+
const findings = job.status === 'COMPLETED' ? fetchPentestFindings(ctx, p.agent_space_id, p.job_id, !!input.includeFalsePositives) : [];
|
|
867
|
+
const result = completedPentestResult({
|
|
868
|
+
job, pentestId: p.pentest_id, agentSpaceId: p.agent_space_id, dedicated: p.agent_space_dedicated,
|
|
869
|
+
region: ctx.opts.region, account: id.account, env: envOf(p),
|
|
870
|
+
commit: entry.result.commit, branch: entry.result.branch, startedAt: entry.result.started_at, findings, now: deps.now(),
|
|
871
|
+
});
|
|
872
|
+
const path = writeResult(root, result, entry.id);
|
|
873
|
+
say('');
|
|
874
|
+
if (job.status === 'COMPLETED') {
|
|
875
|
+
if (job.overview) {
|
|
876
|
+
say(job.overview);
|
|
877
|
+
say('');
|
|
878
|
+
}
|
|
879
|
+
if (findings.length)
|
|
880
|
+
printFindings(say, findings);
|
|
881
|
+
else
|
|
882
|
+
say('No findings.');
|
|
883
|
+
}
|
|
884
|
+
else
|
|
885
|
+
say(`Job ended ${job.status}${job.reason ? `: ${redact(job.reason)}` : ''}`);
|
|
886
|
+
say(`Saved ${path}`);
|
|
887
|
+
if (input.keep) {
|
|
888
|
+
say(`--keep: the environment stays up. Remove it with: ${cleanupCmd(ctx)}`);
|
|
889
|
+
return 0;
|
|
890
|
+
}
|
|
891
|
+
say('The job has finished; tearing the environment down:');
|
|
892
|
+
return (await teardown(ctx, root, { id: entry.id, result }, say)) ? 0 : 1;
|
|
893
|
+
}
|
|
894
|
+
catch (e) {
|
|
895
|
+
if (e instanceof Abort) {
|
|
896
|
+
say(`Error: ${redact(e.message)}`);
|
|
897
|
+
return 1;
|
|
898
|
+
}
|
|
899
|
+
throw e;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* `prism-cli security findings --pentest [<job>]`. Read-only in AWS: refreshes
|
|
904
|
+
* the pentest job, completes the cached record in place, and prints the
|
|
905
|
+
* findings. Never tears the environment down (that is pentest --status /
|
|
906
|
+
* --cleanup). Returns the exit code.
|
|
907
|
+
*/
|
|
908
|
+
export async function securityPentestFindings(input, deps) {
|
|
909
|
+
const say = deps.log;
|
|
910
|
+
const root = checkedRoot(deps, input.path, say);
|
|
911
|
+
if (!root)
|
|
912
|
+
return 1;
|
|
913
|
+
say('Read-only (security findings --pentest): results are cached to .prism/results/; nothing in AWS is changed.');
|
|
914
|
+
say('');
|
|
915
|
+
const ctx = begin(input, deps, true, say);
|
|
916
|
+
if (!ctx)
|
|
917
|
+
return 1;
|
|
918
|
+
try {
|
|
919
|
+
const min = parseLevel('--severity', input.severity);
|
|
920
|
+
const timeout = parseTimeout(input.timeout);
|
|
921
|
+
const id = preflight(ctx);
|
|
922
|
+
printIdentity(ctx, id);
|
|
923
|
+
const entry = latestPentestEntry(root, id.account, ctx.opts.region, input.job);
|
|
924
|
+
if (!entry || !entry.result.pentest) {
|
|
925
|
+
say(input.job ? `No pentest record for job ${input.job} in this account and region.` : 'No pentest runs recorded for this repo in this account and region. Start one with: prism-cli security pentest');
|
|
926
|
+
return 0;
|
|
927
|
+
}
|
|
928
|
+
const p = entry.result.pentest;
|
|
929
|
+
if (!p.job_id) {
|
|
930
|
+
say(`The latest run never started a pentest job: ${redact(entry.result.reason || 'no job id recorded')}`);
|
|
931
|
+
return 1;
|
|
932
|
+
}
|
|
933
|
+
say(`Pentest job: ${p.job_id} (recorded ${p.job_status}; target ${p.target_host})`);
|
|
934
|
+
let job = getPentestJob(ctx, p.agent_space_id, p.job_id);
|
|
935
|
+
say(`Job: ${job.status}${job.createdAt ? `, started ${job.createdAt}` : ''}`);
|
|
936
|
+
if (!TERMINAL.has(job.status)) {
|
|
937
|
+
if (!input.wait) {
|
|
938
|
+
say(`The job is still ${job.status}. Re-run with --wait to wait for it, or check back later.`);
|
|
939
|
+
return 2;
|
|
940
|
+
}
|
|
941
|
+
const out = await pollPentest(ctx, p.agent_space_id, job, timeout);
|
|
942
|
+
if (out.kind === 'interrupted') {
|
|
943
|
+
say('');
|
|
944
|
+
say(`Stopped waiting. ${fetchLaterHint(ctx, p.job_id)}`);
|
|
945
|
+
return 130;
|
|
946
|
+
}
|
|
947
|
+
if (out.kind === 'timeout') {
|
|
948
|
+
say(`Timed out after ${timeout} min (still ${out.job.status}). ${fetchLaterHint(ctx, p.job_id)}`);
|
|
949
|
+
return 2;
|
|
950
|
+
}
|
|
951
|
+
job = out.job;
|
|
952
|
+
}
|
|
953
|
+
const findings = job.status === 'COMPLETED' ? fetchPentestFindings(ctx, p.agent_space_id, p.job_id, !!input.includeFalsePositives) : [];
|
|
954
|
+
const result = completedPentestResult({
|
|
955
|
+
job, pentestId: p.pentest_id, agentSpaceId: p.agent_space_id, dedicated: p.agent_space_dedicated,
|
|
956
|
+
region: ctx.opts.region, account: id.account, env: envOf(p),
|
|
957
|
+
commit: entry.result.commit, branch: entry.result.branch, startedAt: entry.result.started_at, findings, now: deps.now(),
|
|
958
|
+
});
|
|
959
|
+
result.pentest.teardown_failed = p.teardown_failed ?? null;
|
|
960
|
+
const path = writeResult(root, result, entry.id);
|
|
961
|
+
say('');
|
|
962
|
+
if (p.teardown_failed)
|
|
963
|
+
say(teardownFailedLine(ctx, p));
|
|
964
|
+
if (job.status !== 'COMPLETED') {
|
|
965
|
+
say(`Error: job ${p.job_id} ended ${job.status}${job.reason ? `: ${redact(job.reason)}` : ''}`);
|
|
966
|
+
say(`Saved ${path}`);
|
|
967
|
+
return 1;
|
|
968
|
+
}
|
|
969
|
+
if (job.overview) {
|
|
970
|
+
say(job.overview);
|
|
971
|
+
say('');
|
|
972
|
+
}
|
|
973
|
+
const shown = min ? findings.filter((f) => riskRank(f.riskLevel) >= riskRank(min.toUpperCase()) && riskRank(f.riskLevel) > 0) : findings;
|
|
974
|
+
if (shown.length)
|
|
975
|
+
printFindings(say, shown);
|
|
976
|
+
else
|
|
977
|
+
say(min ? `No findings at ${min} or above.` : 'No findings.');
|
|
978
|
+
if (min && shown.length !== findings.length)
|
|
979
|
+
say(`(${findings.length - shown.length} below ${min} not shown; ${totalsLine(countByRisk(findings))})`);
|
|
980
|
+
say(`Saved ${path}`);
|
|
981
|
+
return 0;
|
|
982
|
+
}
|
|
983
|
+
catch (e) {
|
|
984
|
+
if (e instanceof Abort) {
|
|
985
|
+
say(`Error: ${redact(e.message)}`);
|
|
986
|
+
return 1;
|
|
987
|
+
}
|
|
988
|
+
throw e;
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
// ---------------------------------------------------------------------------
|
|
992
|
+
// AWS steps (each a single call builder so dry-run/apply cannot disagree)
|
|
993
|
+
// ---------------------------------------------------------------------------
|
|
994
|
+
/** create-target-domain (PRIVATE_VPC) + verify. UNREACHABLE and VERIFIED/PENDING are OK; FAILED aborts. Returns the domain id. */
|
|
995
|
+
export function createAndVerifyDomain(ctx, host, say) {
|
|
996
|
+
const created = call(ctx, ['securityagent', 'create-target-domain', '--target-domain-name', host, '--verification-method', 'PRIVATE_VPC',
|
|
997
|
+
'--tags', JSON.stringify({ 'prism:purpose': 'pentest' })]).json;
|
|
998
|
+
const domainId = String(created?.targetDomainId || '');
|
|
999
|
+
if (!domainId)
|
|
1000
|
+
throw new Abort('create-target-domain returned no targetDomainId.');
|
|
1001
|
+
const verified = call(ctx, ['securityagent', 'verify-target-domain', '--target-domain-id', domainId]).json;
|
|
1002
|
+
const status = String(verified?.status || created?.verificationStatus || 'PENDING').toUpperCase();
|
|
1003
|
+
if (status === 'FAILED')
|
|
1004
|
+
throw new Abort(`target domain ${host} failed verification: ${redact(String(verified?.verificationStatusReason || ''))}`);
|
|
1005
|
+
say(`Target domain ${host}: ${status}${status === 'UNREACHABLE' ? ' (expected for a private host; usable for PRIVATE_VPC pentests)' : ''}`);
|
|
1006
|
+
return domainId;
|
|
1007
|
+
}
|
|
1008
|
+
/**
|
|
1009
|
+
* The agent space to run in. v1 creates a DEDICATED pentest space per run (the
|
|
1010
|
+
* spike-proven path), bound to the run's VPC, role, log group and target
|
|
1011
|
+
* domain. shortcut: reuse the shared PrismD1SecurityAgent space via
|
|
1012
|
+
* update-agent-space once that path is proven in an account (see the design's
|
|
1013
|
+
* open question); reusing it untested risks the code-review space's config.
|
|
1014
|
+
*/
|
|
1015
|
+
export function resolveAgentSpace(ctx, env, domainId, say) {
|
|
1016
|
+
const awsResources = JSON.stringify({
|
|
1017
|
+
vpcs: [{ vpcArn: env.vpcArn, securityGroupArns: [env.agentSecurityGroupArn], subnetArns: env.subnetArns }],
|
|
1018
|
+
iamRoles: [env.serviceRoleArn], logGroups: [env.logGroup],
|
|
1019
|
+
});
|
|
1020
|
+
const created = call(ctx, ['securityagent', 'create-agent-space', '--name', `pentest-${env.slug}`,
|
|
1021
|
+
'--description', 'prism-cli security pentest (temporary, per-run)',
|
|
1022
|
+
'--aws-resources', awsResources, '--target-domain-ids', domainId]).json;
|
|
1023
|
+
const agentSpaceId = String(created?.agentSpaceId || '');
|
|
1024
|
+
if (!agentSpaceId)
|
|
1025
|
+
throw new Abort('create-agent-space returned no agentSpaceId.');
|
|
1026
|
+
say(`Agent space: ${agentSpaceId} (dedicated to this run)`);
|
|
1027
|
+
return { agentSpaceId, dedicated: true };
|
|
1028
|
+
}
|
|
1029
|
+
/** create-pentest with vpc-config (ONE subnet), a single ALLOW rule for the target URL, and the source zip. Returns the pentest id. */
|
|
1030
|
+
export function createPentest(ctx, cfg, env, agentSpaceId, say) {
|
|
1031
|
+
const assets = { endpoints: [{ uri: env.targetUrl }] };
|
|
1032
|
+
if (env.sourceZipS3)
|
|
1033
|
+
assets.sourceCode = [{ s3Location: env.sourceZipS3 }];
|
|
1034
|
+
if (cfg.actors.length)
|
|
1035
|
+
assets.actors = cfg.actors.map((a) => ({ identifier: a.identifier, ...(a.uris ? { uris: a.uris } : {}), authentication: { providerType: a.auth.providerType, value: a.auth.value } }));
|
|
1036
|
+
const args = ['securityagent', 'create-pentest', '--title', `prism-pentest-${env.slug}`, '--agent-space-id', agentSpaceId,
|
|
1037
|
+
'--assets', JSON.stringify(assets),
|
|
1038
|
+
'--service-role', env.serviceRoleArn,
|
|
1039
|
+
'--log-config', JSON.stringify({ logGroup: env.logGroup }),
|
|
1040
|
+
// create-pentest accepts exactly ONE subnet (spike-confirmed); the agent space holds the full list.
|
|
1041
|
+
'--vpc-config', JSON.stringify({ vpcArn: env.vpcArn, securityGroupArns: [env.agentSecurityGroupArn], subnetArns: [env.pentestSubnetArn] }),
|
|
1042
|
+
'--network-traffic-config', JSON.stringify({ rules: [{ effect: 'ALLOW', pattern: env.targetUrl, networkTrafficRuleType: 'URL' }] }),
|
|
1043
|
+
'--code-remediation-strategy', 'DISABLED'];
|
|
1044
|
+
if (cfg.excludeRiskTypes.length)
|
|
1045
|
+
args.push('--exclude-risk-types', ...cfg.excludeRiskTypes);
|
|
1046
|
+
const r = call(ctx, args).json;
|
|
1047
|
+
const pentestId = String(r?.pentestId || '');
|
|
1048
|
+
if (!pentestId)
|
|
1049
|
+
throw new Abort('create-pentest returned no pentestId.');
|
|
1050
|
+
say(`Pentest: ${pentestId}`);
|
|
1051
|
+
return pentestId;
|
|
1052
|
+
}
|
|
1053
|
+
function gitLine(ctx, repo, args) {
|
|
1054
|
+
const r = ctx.deps.git(['-C', repo.root, ...args]);
|
|
1055
|
+
return r.ok ? r.stdout.trim() : null;
|
|
1056
|
+
}
|
|
1057
|
+
//# sourceMappingURL=pentest.js.map
|