@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,653 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `prism-cli security threat-model` and `prism-cli security threats`: run an
|
|
3
|
+
* AWS Security Agent threat model of this repo at HEAD, optionally focused by
|
|
4
|
+
* design docs (scope docs), and read its threats.
|
|
5
|
+
*
|
|
6
|
+
* Calls (see docs/RESEARCH-threat-model-design-review.md):
|
|
7
|
+
* create-threat-model (assets.sourceCode = the HEAD zip, scopeDocs = the docs,
|
|
8
|
+
* both in the scan bucket), start-threat-model-job,
|
|
9
|
+
* batch-get-threat-model-jobs (poll; carries systemOverview),
|
|
10
|
+
* list-threats + batch-get-threats (details), and for "the latest job"
|
|
11
|
+
* list-threat-models + list-threat-model-jobs.
|
|
12
|
+
*
|
|
13
|
+
* One threat model per run: a job snapshots its inputs, but updating a shared
|
|
14
|
+
* per-repo threat model before each run would race between two developers.
|
|
15
|
+
*
|
|
16
|
+
* Account, region, credentials, the identity block, polling and the pending
|
|
17
|
+
* record work as in `security code-review`. Results go to .prism/results/
|
|
18
|
+
* with kind security-threat-model.
|
|
19
|
+
*/
|
|
20
|
+
import { realpathSync, statSync } from 'node:fs';
|
|
21
|
+
import { basename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
22
|
+
import { makeResult, readResultEntries, writeResult } from '../codewatch/results.js';
|
|
23
|
+
import { explainAwsError, redact } from './errors.js';
|
|
24
|
+
import { begin, checkedRoot, DETAILS_BATCH, needRepo, NOT_SET_UP, paged, parseLevel, parseTimeout, PARAM_SCAN_BUCKET, pollUntilDone, repoRelative, serviceSeconds, shortText, TERMINAL, upload, } from './review.js';
|
|
25
|
+
import { Abort, bucketNotListed, call, getParam, listsBucket, PARAM_AGENT_SPACE, PARAM_SERVICE_ROLE, preflight, printIdentity, SERVICE_ROLE_NAME, spaceBuckets, } from './setup.js';
|
|
26
|
+
// The first live run (a full repo plus one scope doc) took ~51 min, well past
|
|
27
|
+
// the 5-30 min AWS's IDE guide gives, so wait up to two hours by default.
|
|
28
|
+
export const DEFAULT_TM_TIMEOUT_MIN = 120;
|
|
29
|
+
/** Threat severities, most severe first (the API's enum). */
|
|
30
|
+
export const SEVERITY_ORDER = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW', 'INFO'];
|
|
31
|
+
const SEVERITY_RANK = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, INFO: 0 };
|
|
32
|
+
const sevRank = (s) => SEVERITY_RANK[s] ?? 0;
|
|
33
|
+
export const STRIDE_ORDER = ['SPOOFING', 'TAMPERING', 'REPUDIATION', 'INFORMATION_DISCLOSURE', 'DENIAL_OF_SERVICE', 'ELEVATION_OF_PRIVILEGE'];
|
|
34
|
+
/** Scope-doc formats the user guide lists. */
|
|
35
|
+
export const DOC_EXTENSIONS = ['.md', '.txt', '.pdf', '.doc', '.docx', '.png', '.jpg', '.jpeg'];
|
|
36
|
+
/** Used when no --doc is given: the Kiro spec files, like the AWS IDE integration. */
|
|
37
|
+
export const DEFAULT_DOC = /^\.kiro\/specs\/[^/]+\/(design|requirements)\.md$/;
|
|
38
|
+
/**
|
|
39
|
+
* shortcut: the threat-model API documents no scope-doc limits, so these are
|
|
40
|
+
* the web app's design-review limits, used only to warn. Replace them once a
|
|
41
|
+
* live run shows the real ones (research doc, open question Q2).
|
|
42
|
+
*/
|
|
43
|
+
export const DOC_WARN = { count: 5, bytes: 2 * 1024 * 1024, textBytes: 512 * 1024, total: 6 * 1024 * 1024 };
|
|
44
|
+
const TEXT_EXT = new Set(['.md', '.txt']);
|
|
45
|
+
/** Overview text printed and shown on the dashboard. */
|
|
46
|
+
const OVERVIEW_CHARS = 600;
|
|
47
|
+
/** Evidence paths printed per threat. */
|
|
48
|
+
const EVIDENCE_SHOWN = 3;
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Setup and hints
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
/** The agent space, from SSM only: a file in the repo must never choose where code is uploaded. */
|
|
53
|
+
function agentSpace(ctx, id) {
|
|
54
|
+
const space = getParam(ctx, PARAM_AGENT_SPACE);
|
|
55
|
+
if (!space)
|
|
56
|
+
throw new Abort(`SSM ${PARAM_AGENT_SPACE} is not set in account ${id.account}, region ${ctx.opts.region}: ${NOT_SET_UP}.`);
|
|
57
|
+
return space;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* What a failed threat-model write or job might be about, since the API does
|
|
61
|
+
* not document it yet (research doc, open questions Q1 and Q2).
|
|
62
|
+
*/
|
|
63
|
+
export function unknownsHint() {
|
|
64
|
+
return [
|
|
65
|
+
'Hint: the threat-model API documents no scope-doc limits; they may be too large or too many. Try fewer or smaller --doc files, or --no-docs.',
|
|
66
|
+
' See docs/RESEARCH-threat-model-design-review.md (open question Q2).',
|
|
67
|
+
];
|
|
68
|
+
}
|
|
69
|
+
/** The service's refusal of an S3 object in a bucket the agent space does not list. */
|
|
70
|
+
const BUCKET_NOT_LISTED = /S3 bucket not found in agent instance S3 buckets/i;
|
|
71
|
+
/** A write call; an error that is not about credentials, permissions or the CLI gets the scope-doc hint. */
|
|
72
|
+
function writeCall(ctx, args, bucket) {
|
|
73
|
+
const r = ctx.deps.aws(args, ctx.opts.region);
|
|
74
|
+
if (r.ok)
|
|
75
|
+
return r;
|
|
76
|
+
// The preflight checks this; the service saying so anyway (the list changed since) gets the same fix.
|
|
77
|
+
if (BUCKET_NOT_LISTED.test(r.raw))
|
|
78
|
+
throw new Abort(bucketNotListed(bucket));
|
|
79
|
+
const why = explainAwsError(args, r.raw, ctx.cliVersion);
|
|
80
|
+
throw new Abort(why.kind === 'other' ? [why.message, ...unknownsHint()].join('\n') : why.message);
|
|
81
|
+
}
|
|
82
|
+
const threatsHint = (jobId) => `The job keeps running in AWS. Fetch its threats later with: prism-cli security threats --job ${jobId}`;
|
|
83
|
+
/** Threat model titles: one per run, all starting with the repo's prefix (how --latest finds them). */
|
|
84
|
+
export function titlePrefix(slug) {
|
|
85
|
+
return `${slug}-tm-`.replace(/[^A-Za-z0-9_-]/g, '-');
|
|
86
|
+
}
|
|
87
|
+
function runTitle(slug, tip, now) {
|
|
88
|
+
const stamp = now.toISOString().replace(/[-:]/g, '').replace(/\.\d+/, '');
|
|
89
|
+
return `${titlePrefix(slug)}${tip.slice(0, 12)}-${stamp}`.replace(/[^A-Za-z0-9_-]/g, '-').slice(0, 100);
|
|
90
|
+
}
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
// Scope docs (read from HEAD, never the working tree)
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
const real = (p) => { try {
|
|
95
|
+
return realpathSync(p);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return resolve(p);
|
|
99
|
+
} };
|
|
100
|
+
/** `git ls-tree HEAD -- <path>` for exactly that path; null when HEAD does not have it. */
|
|
101
|
+
function treeEntry(deps, root, path) {
|
|
102
|
+
const r = deps.git(['-C', root, 'ls-tree', 'HEAD', '--', path]);
|
|
103
|
+
if (!r.ok)
|
|
104
|
+
return null;
|
|
105
|
+
for (const line of r.stdout.split('\n')) {
|
|
106
|
+
const m = /^(\d+) (\w+) [0-9a-f]+\t(.*)$/.exec(line);
|
|
107
|
+
if (m && m[3] === path)
|
|
108
|
+
return { mode: m[1], type: m[2] };
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
/** A --doc argument as a repo path, or an error. Relative paths are taken from the working directory, like a shell would. */
|
|
113
|
+
export function docRepoPath(root, cwd, arg) {
|
|
114
|
+
const abs = resolve(real(cwd), arg);
|
|
115
|
+
const rel = relative(real(root), abs);
|
|
116
|
+
if (!rel || rel.startsWith('..') || isAbsolute(rel))
|
|
117
|
+
throw new Abort(`--doc ${arg} is outside the repository ${root}.`);
|
|
118
|
+
return rel.split(sep).join('/');
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* The scope docs for this run, as repo paths in HEAD: --doc (validated),
|
|
122
|
+
* none with --no-docs, else the Kiro spec files HEAD has.
|
|
123
|
+
*/
|
|
124
|
+
export function selectDocs(deps, root, input) {
|
|
125
|
+
const given = input.doc ?? [];
|
|
126
|
+
if (input.docs === false) {
|
|
127
|
+
if (given.length)
|
|
128
|
+
throw new Abort('--doc and --no-docs cannot be used together.');
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
if (!given.length) {
|
|
132
|
+
const r = deps.git(['-C', root, 'ls-tree', '-r', '--name-only', 'HEAD', '--', '.kiro/specs']);
|
|
133
|
+
if (!r.ok)
|
|
134
|
+
return [];
|
|
135
|
+
return r.stdout.split('\n').map((l) => l.trim()).filter((l) => DEFAULT_DOC.test(l) && treeEntry(deps, root, l)?.mode !== '120000').sort();
|
|
136
|
+
}
|
|
137
|
+
const out = [];
|
|
138
|
+
for (const arg of given) {
|
|
139
|
+
const path = docRepoPath(root, deps.cwd(), arg);
|
|
140
|
+
if (!DOC_EXTENSIONS.includes(extname(path).toLowerCase()))
|
|
141
|
+
throw new Abort(`--doc ${arg}: unsupported file type; scope docs can be ${DOC_EXTENSIONS.join(', ')}.`);
|
|
142
|
+
const e = treeEntry(deps, root, path);
|
|
143
|
+
if (!e)
|
|
144
|
+
throw new Abort(`--doc ${arg} is not in HEAD. Commit it first: the threat model reads its docs from the commit it models.`);
|
|
145
|
+
if (e.mode === '120000')
|
|
146
|
+
throw new Abort(`--doc ${arg} is a symlink in HEAD; pass the file it points to.`);
|
|
147
|
+
if (e.type !== 'blob')
|
|
148
|
+
throw new Abort(`--doc ${arg} is not a file in HEAD.`);
|
|
149
|
+
if (!out.includes(path))
|
|
150
|
+
out.push(path);
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
/** Writes each doc's blob from HEAD to a temp file (binary-safe: git writes the file itself). */
|
|
155
|
+
function materializeDocs(ctx, root, paths, prefix) {
|
|
156
|
+
const dir = ctx.deps.makeTempDir();
|
|
157
|
+
return paths.map((path, i) => {
|
|
158
|
+
const file = join(dir, `${i}-${basename(path)}`);
|
|
159
|
+
if (!ctx.deps.git(['-C', root, 'show', `--output=${file}`, `HEAD:${path}`]).ok)
|
|
160
|
+
throw new Abort(`git show HEAD:${path} failed.`);
|
|
161
|
+
let size = 0;
|
|
162
|
+
try {
|
|
163
|
+
size = statSync(file).size;
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
throw new Abort(`git show wrote no file for ${path}.`);
|
|
167
|
+
}
|
|
168
|
+
return { path, file, size, key: `${prefix}/docs/${path}` };
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
/** Warnings when the docs exceed the web app's design-review limits (the API's own limits are unknown). */
|
|
172
|
+
export function docLimitWarnings(docs) {
|
|
173
|
+
const mb = (n) => `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
174
|
+
const out = [];
|
|
175
|
+
if (docs.length > DOC_WARN.count)
|
|
176
|
+
out.push(`${docs.length} scope docs`);
|
|
177
|
+
for (const d of docs) {
|
|
178
|
+
const cap = TEXT_EXT.has(extname(d.path).toLowerCase()) ? DOC_WARN.textBytes : DOC_WARN.bytes;
|
|
179
|
+
if (d.size > cap)
|
|
180
|
+
out.push(`${d.path} is ${mb(d.size)}`);
|
|
181
|
+
}
|
|
182
|
+
const total = docs.reduce((a, d) => a + d.size, 0);
|
|
183
|
+
if (total > DOC_WARN.total)
|
|
184
|
+
out.push(`${mb(total)} in total`);
|
|
185
|
+
if (!out.length)
|
|
186
|
+
return [];
|
|
187
|
+
return [`Warning: ${out.join('; ')}. The web app's design review allows 5 files, 2 MB each (0.5 MB for .md and .txt), 6 MB in total; the threat-model API documents no limits. If the job fails, trim the docs.`];
|
|
188
|
+
}
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
// Jobs and threats (reads)
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
function toTmJob(j) {
|
|
193
|
+
// shortcut: the AWS CLI returns untyped JSON; only these fields are read.
|
|
194
|
+
const e = j?.errorInformation;
|
|
195
|
+
return {
|
|
196
|
+
threatModelJobId: String(j?.threatModelJobId || ''),
|
|
197
|
+
threatModelId: String(j?.threatModelId || ''),
|
|
198
|
+
status: String(j?.status || 'UNKNOWN'),
|
|
199
|
+
createdAt: j?.createdAt ? String(j.createdAt) : undefined,
|
|
200
|
+
updatedAt: j?.updatedAt ? String(j.updatedAt) : undefined,
|
|
201
|
+
errorCode: e?.code ? String(e.code) : null,
|
|
202
|
+
reason: e?.message ? String(e.message) : e?.code ? String(e.code) : null,
|
|
203
|
+
systemOverview: typeof j?.systemOverview === 'string' && j.systemOverview.trim() ? j.systemOverview : null,
|
|
204
|
+
step: null,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
const tmJobArgs = (space, jobId) => ['securityagent', 'batch-get-threat-model-jobs', '--agent-space-id', space, '--threat-model-job-ids', jobId];
|
|
208
|
+
function parseTmJob(json, space, jobId) {
|
|
209
|
+
const j = (json?.threatModelJobs || [])[0];
|
|
210
|
+
if (!j)
|
|
211
|
+
throw new Abort(`No threat model job ${jobId} in agent space ${space}.`);
|
|
212
|
+
return toTmJob(j);
|
|
213
|
+
}
|
|
214
|
+
export function getTmJob(ctx, space, jobId) {
|
|
215
|
+
return parseTmJob(call(ctx, tmJobArgs(space, jobId)).json, space, jobId);
|
|
216
|
+
}
|
|
217
|
+
function tmPoller(space, jobId) {
|
|
218
|
+
return { args: tmJobArgs(space, jobId), parse: (j) => parseTmJob(j, space, jobId), hint: threatsHint(jobId) };
|
|
219
|
+
}
|
|
220
|
+
/** The newest job of the newest threat model this repo's runs created, or null. */
|
|
221
|
+
export function latestTmJob(ctx, space, slug) {
|
|
222
|
+
const prefix = titlePrefix(slug);
|
|
223
|
+
const models = paged(ctx, ['securityagent', 'list-threat-models', '--agent-space-id', space], 'threatModelSummaries')
|
|
224
|
+
.filter((m) => String(m?.title || '').startsWith(prefix))
|
|
225
|
+
.sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
|
|
226
|
+
for (const m of models) {
|
|
227
|
+
const jobs = paged(ctx, ['securityagent', 'list-threat-model-jobs', '--agent-space-id', space, '--threat-model-id', String(m.threatModelId)], 'threatModelJobSummaries')
|
|
228
|
+
.sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
|
|
229
|
+
// The summary has no systemOverview; read the job itself.
|
|
230
|
+
if (jobs.length)
|
|
231
|
+
return getTmJob(ctx, space, String(jobs[0].threatModelJobId));
|
|
232
|
+
}
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
const strList = (v) => (Array.isArray(v) ? v.map((x) => String(x)).filter(Boolean) : []);
|
|
236
|
+
export function fetchThreats(ctx, space, jobId, repoRoot) {
|
|
237
|
+
const summaries = paged(ctx, ['securityagent', 'list-threats', '--agent-space-id', space, '--threat-job-id', jobId], 'threats');
|
|
238
|
+
const details = new Map();
|
|
239
|
+
for (let i = 0; i < summaries.length; i += DETAILS_BATCH) {
|
|
240
|
+
const ids = summaries.slice(i, i + DETAILS_BATCH).map((s) => String(s.threatId || '')).filter(Boolean);
|
|
241
|
+
if (!ids.length)
|
|
242
|
+
continue;
|
|
243
|
+
// Details are best effort: a caller without batch-get-threats still gets the summaries.
|
|
244
|
+
const r = ctx.deps.aws(['securityagent', 'batch-get-threats', '--agent-space-id', space, '--threat-ids', ...ids], ctx.opts.region);
|
|
245
|
+
if (r.ok)
|
|
246
|
+
for (const t of r.json?.threats || [])
|
|
247
|
+
details.set(String(t.threatId), t);
|
|
248
|
+
}
|
|
249
|
+
return summaries.map((s) => {
|
|
250
|
+
const d = details.get(String(s.threatId)) || {};
|
|
251
|
+
const evidence = (Array.isArray(d.evidence) ? d.evidence : []).map((e) => String(e?.path || '')).filter(Boolean)
|
|
252
|
+
.map((p) => repoRelative(p, repoRoot));
|
|
253
|
+
return {
|
|
254
|
+
threatId: String(s.threatId || ''),
|
|
255
|
+
severity: String(d.severity || s.severity || 'INFO').toUpperCase(),
|
|
256
|
+
status: String(d.status || s.status || ''),
|
|
257
|
+
title: String(d.title || s.title || '(untitled threat)'),
|
|
258
|
+
statement: String(d.statement || s.statement || ''),
|
|
259
|
+
stride: strList(d.stride ?? s.stride).map((x) => x.toUpperCase()),
|
|
260
|
+
recommendation: String(d.recommendation || ''),
|
|
261
|
+
evidence: [...new Set(evidence)],
|
|
262
|
+
impactedAssets: strList(d.impactedAssets),
|
|
263
|
+
};
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
// Output
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
/** "INFORMATION_DISCLOSURE" -> "Information disclosure". */
|
|
270
|
+
export function strideLabel(s) {
|
|
271
|
+
const w = s.toLowerCase().replace(/_/g, ' ');
|
|
272
|
+
return w.charAt(0).toUpperCase() + w.slice(1);
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* The overview's first paragraph of prose, for the terminal and dashboard:
|
|
276
|
+
* markdown headings skipped, whitespace collapsed, secrets redacted, capped.
|
|
277
|
+
*/
|
|
278
|
+
export function firstParagraph(text) {
|
|
279
|
+
if (!text)
|
|
280
|
+
return '';
|
|
281
|
+
for (const block of text.replace(/\r\n/g, '\n').split(/\n\s*\n/)) {
|
|
282
|
+
const prose = block.split('\n').filter((l) => !/^\s*#/.test(l)).join(' ').replace(/\s+/g, ' ').trim();
|
|
283
|
+
if (!prose)
|
|
284
|
+
continue;
|
|
285
|
+
const one = redact(prose);
|
|
286
|
+
return one.length > OVERVIEW_CHARS ? `${one.slice(0, OVERVIEW_CHARS - 3)}...` : one;
|
|
287
|
+
}
|
|
288
|
+
return '';
|
|
289
|
+
}
|
|
290
|
+
export function countBySeverity(ts) {
|
|
291
|
+
const c = Object.fromEntries(SEVERITY_ORDER.map((s) => [s, 0]));
|
|
292
|
+
for (const t of ts)
|
|
293
|
+
c[t.severity in c ? t.severity : 'INFO']++;
|
|
294
|
+
return c;
|
|
295
|
+
}
|
|
296
|
+
export function countByStride(ts) {
|
|
297
|
+
const c = Object.fromEntries(STRIDE_ORDER.map((s) => [s, 0]));
|
|
298
|
+
for (const t of ts)
|
|
299
|
+
for (const s of t.stride)
|
|
300
|
+
if (s in c)
|
|
301
|
+
c[s]++;
|
|
302
|
+
return c;
|
|
303
|
+
}
|
|
304
|
+
export function sortThreats(ts) {
|
|
305
|
+
return [...ts].sort((a, b) => sevRank(b.severity) - sevRank(a.severity) || a.title.localeCompare(b.title));
|
|
306
|
+
}
|
|
307
|
+
export function threatTotals(c) {
|
|
308
|
+
const total = Object.values(c).reduce((a, b) => a + b, 0);
|
|
309
|
+
return `${total} threat${total === 1 ? '' : 's'}: ` + SEVERITY_ORDER.map((s) => `${c[s] ?? 0} ${s.toLowerCase()}`).join(', ');
|
|
310
|
+
}
|
|
311
|
+
function strideLine(c) {
|
|
312
|
+
const parts = STRIDE_ORDER.filter((s) => (c[s] || 0) > 0).map((s) => `${c[s]} ${strideLabel(s).toLowerCase()}`);
|
|
313
|
+
return parts.length ? `STRIDE: ${parts.join(', ')}` : null;
|
|
314
|
+
}
|
|
315
|
+
/** Grouped by severity: STRIDE categories and title, then the recommendation and evidence paths. */
|
|
316
|
+
export function printThreats(say, ts) {
|
|
317
|
+
const sorted = sortThreats(ts);
|
|
318
|
+
for (const level of SEVERITY_ORDER) {
|
|
319
|
+
const group = sorted.filter((t) => t.severity === level || (level === 'INFO' && !SEVERITY_ORDER.includes(t.severity)));
|
|
320
|
+
if (!group.length)
|
|
321
|
+
continue;
|
|
322
|
+
say(`${level} (${group.length})`);
|
|
323
|
+
for (const t of group) {
|
|
324
|
+
say(` ${t.stride.length ? `[${t.stride.map(strideLabel).join(', ')}] ` : ''}${redact(t.title)}`);
|
|
325
|
+
if (t.recommendation)
|
|
326
|
+
say(` Recommendation: ${shortText(t.recommendation)}`);
|
|
327
|
+
if (t.evidence.length) {
|
|
328
|
+
const more = t.evidence.length > EVIDENCE_SHOWN ? ` (+${t.evidence.length - EVIDENCE_SHOWN} more)` : '';
|
|
329
|
+
say(` Evidence: ${t.evidence.slice(0, EVIDENCE_SHOWN).join(', ')}${more}`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const sl = strideLine(countByStride(ts));
|
|
334
|
+
if (sl)
|
|
335
|
+
say(sl);
|
|
336
|
+
say(threatTotals(countBySeverity(ts)));
|
|
337
|
+
}
|
|
338
|
+
function toSeverity(s) {
|
|
339
|
+
return sevRank(s) >= 3 ? 'high' : sevRank(s) === 2 ? 'medium' : 'low';
|
|
340
|
+
}
|
|
341
|
+
/** Written right after start-threat-model-job, so a lost wait loses nothing. */
|
|
342
|
+
export function pendingThreatModel(o) {
|
|
343
|
+
return makeResult({
|
|
344
|
+
kind: 'security-threat-model', hook: 'manual', tool: 'security-agent', status: 'pending',
|
|
345
|
+
commit: o.tip ?? null, branch: o.branch ?? null, started_at: o.startedAt, finished_at: o.startedAt,
|
|
346
|
+
threat_model: {
|
|
347
|
+
job_id: o.job.threatModelJobId, threat_model_id: o.threatModelId, agent_space_id: o.space, region: o.region, account: o.account,
|
|
348
|
+
job_status: o.job.status, counts: {}, stride_counts: {}, scope_docs: o.scopeDocs, system_overview: null,
|
|
349
|
+
service_created_at: o.job.createdAt ?? null, service_updated_at: null,
|
|
350
|
+
},
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
export function toThreatModelResult(o) {
|
|
354
|
+
const ok = o.job.status === 'COMPLETED';
|
|
355
|
+
const counts = countBySeverity(o.threats);
|
|
356
|
+
const findings = sortThreats(o.threats).map((t) => ({
|
|
357
|
+
severity: toSeverity(t.severity), category: 'security', rule: t.stride.join(',') || 'threat',
|
|
358
|
+
file: t.evidence[0] || '', line: null, message: t.statement ? `${t.title}: ${shortText(t.statement)}` : t.title,
|
|
359
|
+
risk_level: t.severity, title: t.title, threat_id: t.threatId, stride: t.stride,
|
|
360
|
+
recommendation: redact(t.recommendation), evidence: t.evidence,
|
|
361
|
+
}));
|
|
362
|
+
return makeResult({
|
|
363
|
+
kind: 'security-threat-model', hook: 'manual', tool: 'security-agent',
|
|
364
|
+
status: !ok ? 'error' : findings.length ? 'findings' : 'ok',
|
|
365
|
+
commit: o.tip ?? null, branch: o.branch ?? null,
|
|
366
|
+
started_at: o.startedAt || o.job.createdAt || o.now.toISOString(),
|
|
367
|
+
finished_at: o.job.updatedAt || o.now.toISOString(),
|
|
368
|
+
findings,
|
|
369
|
+
summary: ok ? threatTotals(counts) : null,
|
|
370
|
+
reason: ok ? null : `job ${o.job.status}${o.job.reason ? `: ${redact(o.job.reason)}` : ''}`,
|
|
371
|
+
threat_model: {
|
|
372
|
+
job_id: o.job.threatModelJobId, threat_model_id: o.threatModelId || o.job.threatModelId, agent_space_id: o.space, region: o.region, account: o.account,
|
|
373
|
+
job_status: o.job.status, counts, stride_counts: countByStride(o.threats), scope_docs: o.scopeDocs,
|
|
374
|
+
system_overview: o.job.systemOverview ? redact(o.job.systemOverview) : null,
|
|
375
|
+
service_created_at: o.job.createdAt ?? null, service_updated_at: o.job.updatedAt ?? null,
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
/** Pending records in this repo for this account, region and agent space, newest first. */
|
|
380
|
+
export function pendingThreatModels(root, account, region, space) {
|
|
381
|
+
return readResultEntries(root)
|
|
382
|
+
.filter(({ result: r }) => r.kind === 'security-threat-model' && r.status === 'pending' && r.threat_model?.job_id
|
|
383
|
+
&& r.threat_model.account === account && r.threat_model.region === region && r.threat_model.agent_space_id === space)
|
|
384
|
+
.sort((a, b) => b.result.started_at.localeCompare(a.result.started_at));
|
|
385
|
+
}
|
|
386
|
+
function fromPending(p) {
|
|
387
|
+
return { threatModelId: p.threat_model.threat_model_id, tip: p.commit, branch: p.branch, scopeDocs: p.threat_model.scope_docs || [], startedAt: p.started_at };
|
|
388
|
+
}
|
|
389
|
+
export function failOnThreat(ts, failOn) {
|
|
390
|
+
if (!failOn)
|
|
391
|
+
return false;
|
|
392
|
+
const min = sevRank(failOn.toUpperCase());
|
|
393
|
+
return ts.some((t) => sevRank(t.severity) >= min && sevRank(t.severity) > 0);
|
|
394
|
+
}
|
|
395
|
+
/** A FAILED / STOPPED job: saves the error result and prints why, with the unknowns hint for a client error. Exit 1. */
|
|
396
|
+
function saveFailed(ctx, json, result, job, repo, space, id) {
|
|
397
|
+
const deps = ctx.deps;
|
|
398
|
+
const path = writeResult(repo.root, result, id);
|
|
399
|
+
if (json)
|
|
400
|
+
deps.out(JSON.stringify(result, null, 2) + '\n');
|
|
401
|
+
ctx.deps.log(`Error: job ${job.threatModelJobId} ended ${job.status}${job.reason ? `: ${redact(job.reason)}` : ''}`);
|
|
402
|
+
if (job.errorCode === 'CLIENT_ERROR')
|
|
403
|
+
for (const l of unknownsHint())
|
|
404
|
+
ctx.deps.log(l);
|
|
405
|
+
ctx.deps.log(`Saved ${path}`);
|
|
406
|
+
return 1;
|
|
407
|
+
}
|
|
408
|
+
function finishThreats(ctx, json, result, threats, repo, job, minLevel, id) {
|
|
409
|
+
const deps = ctx.deps;
|
|
410
|
+
const shown = minLevel ? threats.filter((t) => sevRank(t.severity) >= sevRank(minLevel.toUpperCase()) && sevRank(t.severity) > 0) : threats;
|
|
411
|
+
const path = writeResult(repo.root, result, id);
|
|
412
|
+
if (json) {
|
|
413
|
+
deps.out(JSON.stringify(result, null, 2) + '\n');
|
|
414
|
+
deps.err(`Saved ${path}`);
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
const say = ctx.deps.log;
|
|
418
|
+
const overview = firstParagraph(job.systemOverview);
|
|
419
|
+
say('');
|
|
420
|
+
if (overview) {
|
|
421
|
+
say('System overview:');
|
|
422
|
+
say(` ${overview}`);
|
|
423
|
+
say(' (the full overview is in the saved result, field threat_model.system_overview)');
|
|
424
|
+
say('');
|
|
425
|
+
}
|
|
426
|
+
if (shown.length)
|
|
427
|
+
printThreats(say, shown);
|
|
428
|
+
else
|
|
429
|
+
say(minLevel ? `No threats at ${minLevel} or above.` : 'No threats.');
|
|
430
|
+
if (minLevel && shown.length !== threats.length)
|
|
431
|
+
say(`(${threats.length - shown.length} below ${minLevel} not shown; ${threatTotals(countBySeverity(threats))})`);
|
|
432
|
+
say(`Saved ${path}`);
|
|
433
|
+
}
|
|
434
|
+
// ---------------------------------------------------------------------------
|
|
435
|
+
// Commands
|
|
436
|
+
// ---------------------------------------------------------------------------
|
|
437
|
+
export const THREAT_MODEL_NOTICE = 'prism-cli security threat-model uploads the repo at HEAD and its scope docs (read from HEAD) to the Security Agent scan bucket under threat-model/<slug>/<commit>/, creates a threat model and starts a threat model job in the AWS account your credentials point to. Those are its only AWS writes; AWS does not charge for threat modeling while it is in preview. The result is saved to .prism/results/.';
|
|
438
|
+
/** `prism-cli security threat-model`. Returns the exit code. */
|
|
439
|
+
export async function securityThreatModel(input, deps) {
|
|
440
|
+
const say = input.json ? deps.err : deps.log;
|
|
441
|
+
const root = checkedRoot(deps, input.path, say);
|
|
442
|
+
if (!root)
|
|
443
|
+
return 1;
|
|
444
|
+
// Local checks before the notice and any AWS call: a bad --doc prints one Error line.
|
|
445
|
+
let docPaths;
|
|
446
|
+
try {
|
|
447
|
+
docPaths = selectDocs(deps, root, input);
|
|
448
|
+
}
|
|
449
|
+
catch (e) {
|
|
450
|
+
if (e instanceof Abort) {
|
|
451
|
+
say(`Error: ${redact(e.message)}`);
|
|
452
|
+
return 1;
|
|
453
|
+
}
|
|
454
|
+
throw e;
|
|
455
|
+
}
|
|
456
|
+
say(THREAT_MODEL_NOTICE);
|
|
457
|
+
say('');
|
|
458
|
+
const ctx = begin(input, deps, false, say);
|
|
459
|
+
if (!ctx)
|
|
460
|
+
return 1;
|
|
461
|
+
try {
|
|
462
|
+
const failOn = parseLevel('--fail-on', input.failOn);
|
|
463
|
+
const timeout = parseTimeout(input.timeout, DEFAULT_TM_TIMEOUT_MIN);
|
|
464
|
+
const id = preflight(ctx);
|
|
465
|
+
printIdentity(ctx, id);
|
|
466
|
+
const repo = needRepo(ctx, root);
|
|
467
|
+
const space = agentSpace(ctx, id);
|
|
468
|
+
const bucket = getParam(ctx, PARAM_SCAN_BUCKET);
|
|
469
|
+
if (!bucket)
|
|
470
|
+
throw new Abort(`SSM ${PARAM_SCAN_BUCKET} is not set in this account and region: ${NOT_SET_UP}.`);
|
|
471
|
+
const role = getParam(ctx, PARAM_SERVICE_ROLE) || `arn:aws:iam::${id.account}:role/${SERVICE_ROLE_NAME}`;
|
|
472
|
+
say(`Repo: ${repo.id}`);
|
|
473
|
+
say(`Agent space: ${space} (from SSM ${PARAM_AGENT_SPACE})`);
|
|
474
|
+
// Before any write: create-threat-model refuses S3 objects in a bucket the agent space does not list.
|
|
475
|
+
const listed = spaceBuckets(ctx, space);
|
|
476
|
+
if (listed === null)
|
|
477
|
+
throw new Abort(`batch-get-agent-spaces did not return agent space ${space} (from SSM ${PARAM_AGENT_SPACE}); run prism-cli enable security --status.`);
|
|
478
|
+
if (!listsBucket(listed, bucket))
|
|
479
|
+
throw new Abort(bucketNotListed(bucket));
|
|
480
|
+
const tipR = deps.git(['-C', root, 'rev-parse', 'HEAD']);
|
|
481
|
+
const tip = tipR.ok ? tipR.stdout.trim() : '';
|
|
482
|
+
if (!tip)
|
|
483
|
+
throw new Abort('Could not read HEAD; is this a repository with at least one commit?');
|
|
484
|
+
const br = deps.git(['-C', root, 'rev-parse', '--abbrev-ref', 'HEAD']);
|
|
485
|
+
const branch = br.ok ? br.stdout.trim() : null;
|
|
486
|
+
const prefix = `threat-model/${repo.slug}/${tip.slice(0, 12)}`;
|
|
487
|
+
const dir = deps.makeTempDir();
|
|
488
|
+
const zip = join(dir, 'source.zip');
|
|
489
|
+
if (!deps.git(['-C', root, 'archive', 'HEAD', '--format=zip', '-o', zip]).ok)
|
|
490
|
+
throw new Abort('git archive HEAD failed.');
|
|
491
|
+
const docs = materializeDocs(ctx, root, docPaths, prefix);
|
|
492
|
+
say(`Source: the repo at ${tip.slice(0, 12)}`);
|
|
493
|
+
say(docs.length
|
|
494
|
+
? `Scope docs: ${docs.map((d) => d.path).join(', ')}`
|
|
495
|
+
: `Scope docs: none (${input.docs === false ? '--no-docs' : 'no --doc and no .kiro/specs/*/design.md or requirements.md in HEAD'}); the source alone is modeled`);
|
|
496
|
+
for (const w of docLimitWarnings(docs))
|
|
497
|
+
say(w);
|
|
498
|
+
const source = { file: zip, key: `${prefix}/source.zip` };
|
|
499
|
+
for (const s of [source, ...docs]) {
|
|
500
|
+
const uri = `s3://${bucket}/${s.key}`;
|
|
501
|
+
say(upload(ctx, bucket, s) ? `Uploaded ${uri}` : `${uri} is already up to date; upload skipped`);
|
|
502
|
+
}
|
|
503
|
+
const title = runTitle(repo.slug, tip, deps.now());
|
|
504
|
+
const created = writeCall(ctx, [
|
|
505
|
+
'securityagent', 'create-threat-model', '--agent-space-id', space, '--title', title, '--service-role', role,
|
|
506
|
+
'--description', `prism-cli threat model of ${repo.id} at ${tip.slice(0, 12)}`,
|
|
507
|
+
'--assets', JSON.stringify({ sourceCode: [{ s3Location: `s3://${bucket}/${source.key}` }] }),
|
|
508
|
+
...(docs.length ? ['--scope-docs', JSON.stringify(docs.map((d) => ({ s3Location: `s3://${bucket}/${d.key}` })))] : []),
|
|
509
|
+
], bucket);
|
|
510
|
+
const threatModelId = String(created.json?.threatModelId || '');
|
|
511
|
+
if (!threatModelId)
|
|
512
|
+
throw new Abort('create-threat-model returned no threatModelId.');
|
|
513
|
+
say(`Threat model: ${threatModelId} ("${title}")`);
|
|
514
|
+
const startedAt = deps.now().getTime();
|
|
515
|
+
const started = toTmJob(writeCall(ctx, ['securityagent', 'start-threat-model-job', '--agent-space-id', space, '--threat-model-id', threatModelId], bucket).json);
|
|
516
|
+
if (!started.threatModelJobId)
|
|
517
|
+
throw new Abort('start-threat-model-job returned no threatModelJobId.');
|
|
518
|
+
const job = { ...started, threatModelId, status: started.status === 'UNKNOWN' ? 'IN_PROGRESS' : started.status };
|
|
519
|
+
say(`Started job ${job.threatModelJobId}`);
|
|
520
|
+
const kept = { space, region: ctx.opts.region, account: id.account, threatModelId, tip, branch, scopeDocs: docPaths, startedAt: new Date(startedAt).toISOString() };
|
|
521
|
+
let pendingId;
|
|
522
|
+
try {
|
|
523
|
+
const p = writeResult(repo.root, pendingThreatModel({ ...kept, job }));
|
|
524
|
+
pendingId = basename(p, '.json');
|
|
525
|
+
say(`Saved ${p} (pending)`);
|
|
526
|
+
}
|
|
527
|
+
catch (e) {
|
|
528
|
+
say(`Warning: could not save the pending record (${redact(e instanceof Error ? e.message : String(e))}).`);
|
|
529
|
+
}
|
|
530
|
+
if (input.wait === false) {
|
|
531
|
+
say(`Not waiting (--no-wait). ${threatsHint(job.threatModelJobId)}`);
|
|
532
|
+
return 0;
|
|
533
|
+
}
|
|
534
|
+
say(`Waiting for the job (up to ${timeout} min; Ctrl-C stops waiting, the job keeps running)...`);
|
|
535
|
+
const out = await pollUntilDone(ctx, tmPoller(space, job.threatModelJobId), job, timeout);
|
|
536
|
+
if (out.kind === 'interrupted') {
|
|
537
|
+
say('');
|
|
538
|
+
say(`Stopped waiting. ${threatsHint(job.threatModelJobId)}`);
|
|
539
|
+
return 130;
|
|
540
|
+
}
|
|
541
|
+
if (out.kind === 'timeout') {
|
|
542
|
+
say(`Timed out after ${timeout} min (job ${job.threatModelJobId} is ${out.job.status}). ${threatsHint(job.threatModelJobId)}`);
|
|
543
|
+
return 2;
|
|
544
|
+
}
|
|
545
|
+
const wallS = Math.round((deps.now().getTime() - startedAt) / 1000);
|
|
546
|
+
const svc = serviceSeconds(out.job);
|
|
547
|
+
say(`Finished in ${wallS}s here${svc !== null ? `; the service reports ${svc}s from job creation to its last update` : ''}.`);
|
|
548
|
+
const base = { ...kept, job: out.job, now: deps.now() };
|
|
549
|
+
if (out.job.status !== 'COMPLETED')
|
|
550
|
+
return saveFailed(ctx, !!input.json, toThreatModelResult({ ...base, threats: [] }), out.job, repo, space, pendingId);
|
|
551
|
+
const threats = fetchThreats(ctx, space, out.job.threatModelJobId, repo.root);
|
|
552
|
+
finishThreats(ctx, !!input.json, toThreatModelResult({ ...base, threats }), threats, repo, out.job, undefined, pendingId);
|
|
553
|
+
if (failOnThreat(threats, failOn)) {
|
|
554
|
+
say(`--fail-on ${failOn}: threats at or above ${failOn}.`);
|
|
555
|
+
return 1;
|
|
556
|
+
}
|
|
557
|
+
return 0;
|
|
558
|
+
}
|
|
559
|
+
catch (e) {
|
|
560
|
+
if (e instanceof Abort) {
|
|
561
|
+
say(`Error: ${redact(e.message)}`);
|
|
562
|
+
return 1;
|
|
563
|
+
}
|
|
564
|
+
throw e;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
/** `prism-cli security threats`. Read-only: no AWS writes. Returns the exit code. */
|
|
568
|
+
export async function securityThreats(input, deps) {
|
|
569
|
+
const say = input.json ? deps.err : deps.log;
|
|
570
|
+
const root = checkedRoot(deps, input.path, say);
|
|
571
|
+
if (!root)
|
|
572
|
+
return 1;
|
|
573
|
+
say('Read-only (security threats): nothing in AWS will be changed; results are cached to .prism/results/.');
|
|
574
|
+
say('');
|
|
575
|
+
const ctx = begin(input, deps, true, say);
|
|
576
|
+
if (!ctx)
|
|
577
|
+
return 1;
|
|
578
|
+
try {
|
|
579
|
+
const min = parseLevel('--severity', input.severity);
|
|
580
|
+
const timeout = parseTimeout(input.timeout, DEFAULT_TM_TIMEOUT_MIN);
|
|
581
|
+
const id = preflight(ctx);
|
|
582
|
+
printIdentity(ctx, id);
|
|
583
|
+
const repo = needRepo(ctx, root);
|
|
584
|
+
const space = agentSpace(ctx, id);
|
|
585
|
+
const pendings = pendingThreatModels(repo.root, id.account, ctx.opts.region, space);
|
|
586
|
+
let pending = null;
|
|
587
|
+
let job;
|
|
588
|
+
if (input.job) {
|
|
589
|
+
if (!/^[A-Za-z0-9_-]{1,128}$/.test(input.job))
|
|
590
|
+
throw new Abort(`--job must be a job id, got "${input.job}"`);
|
|
591
|
+
pending = pendings.find((e) => e.result.threat_model.job_id === input.job) ?? null;
|
|
592
|
+
job = getTmJob(ctx, space, input.job);
|
|
593
|
+
}
|
|
594
|
+
else if (pendings.length) {
|
|
595
|
+
pending = pendings[0];
|
|
596
|
+
if (pendings.length > 1) {
|
|
597
|
+
say(`Also pending: ${pendings.slice(1).map((e) => `${e.result.threat_model.job_id} (started ${e.result.started_at})`).join(', ')}. Fetch one with --job <id>.`);
|
|
598
|
+
}
|
|
599
|
+
job = getTmJob(ctx, space, pending.result.threat_model.job_id);
|
|
600
|
+
}
|
|
601
|
+
else {
|
|
602
|
+
job = latestTmJob(ctx, space, repo.slug);
|
|
603
|
+
if (!job) {
|
|
604
|
+
say(`No threat models yet for ${repo.id}. Start one with: prism-cli security threat-model`);
|
|
605
|
+
return 0;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
if (pending)
|
|
609
|
+
say(`Pending: recorded by threat-model at ${pending.result.started_at} (commit ${(pending.result.commit || '').slice(0, 12)})`);
|
|
610
|
+
say(`Job: ${job.threatModelJobId} (${job.status}${job.createdAt ? `, started ${job.createdAt}` : ''})`);
|
|
611
|
+
if (!TERMINAL.has(job.status)) {
|
|
612
|
+
if (!input.wait) {
|
|
613
|
+
say(`The job is still ${job.status}. Re-run with --wait to wait for it, or check back later.`);
|
|
614
|
+
return 2;
|
|
615
|
+
}
|
|
616
|
+
const out = await pollUntilDone(ctx, tmPoller(space, job.threatModelJobId), job, timeout);
|
|
617
|
+
if (out.kind === 'interrupted') {
|
|
618
|
+
say('');
|
|
619
|
+
say(`Stopped waiting. ${threatsHint(job.threatModelJobId)}`);
|
|
620
|
+
return 130;
|
|
621
|
+
}
|
|
622
|
+
if (out.kind === 'timeout') {
|
|
623
|
+
say(`Timed out after ${timeout} min (still ${out.job.status}). ${threatsHint(job.threatModelJobId)}`);
|
|
624
|
+
return 2;
|
|
625
|
+
}
|
|
626
|
+
job = out.job;
|
|
627
|
+
}
|
|
628
|
+
const base = {
|
|
629
|
+
space, job, region: ctx.opts.region, account: id.account, now: deps.now(),
|
|
630
|
+
...(pending ? fromPending(pending.result) : { threatModelId: job.threatModelId, scopeDocs: [] }),
|
|
631
|
+
};
|
|
632
|
+
if (job.status !== 'COMPLETED') {
|
|
633
|
+
if (pending)
|
|
634
|
+
return saveFailed(ctx, !!input.json, toThreatModelResult({ ...base, threats: [] }), job, repo, space, pending.id);
|
|
635
|
+
say(`Error: job ${job.threatModelJobId} ended ${job.status}${job.reason ? `: ${redact(job.reason)}` : ''}`);
|
|
636
|
+
if (job.errorCode === 'CLIENT_ERROR')
|
|
637
|
+
for (const l of unknownsHint())
|
|
638
|
+
say(l);
|
|
639
|
+
return 1;
|
|
640
|
+
}
|
|
641
|
+
const threats = fetchThreats(ctx, space, job.threatModelJobId, repo.root);
|
|
642
|
+
finishThreats(ctx, !!input.json, toThreatModelResult({ ...base, threats }), threats, repo, job, min, pending?.id);
|
|
643
|
+
return 0;
|
|
644
|
+
}
|
|
645
|
+
catch (e) {
|
|
646
|
+
if (e instanceof Abort) {
|
|
647
|
+
say(`Error: ${redact(e.message)}`);
|
|
648
|
+
return 1;
|
|
649
|
+
}
|
|
650
|
+
throw e;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
//# sourceMappingURL=threat-model.js.map
|