@monster-orange/den 0.0.0-stage → 0.1.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/CHANGELOG.md +67 -0
- package/LICENSE +21 -0
- package/README.md +166 -2
- package/bin/den +61 -0
- package/docs/distributions.md +256 -0
- package/make/demo.mk +82 -0
- package/make/den.mk +80 -0
- package/make/journeys.mk +57 -0
- package/make/local_dev.mk +154 -0
- package/make/security.mk +59 -0
- package/make/test.mk +405 -0
- package/package.json +104 -4
- package/scripts/auth-configure.ts +415 -0
- package/scripts/heal-config.ts +92 -0
- package/scripts/jsonl-playwright-reporter.js +70 -0
- package/scripts/manifest-read.cjs +91 -0
- package/scripts/setup-1password.ts +570 -0
- package/scripts/setup-secrets.ts +149 -0
- package/scripts/view-results.js +156 -0
- package/service/install.sh +112 -0
- package/src/pillars/api/fixtures/index.ts +128 -0
- package/src/pillars/api/fixtures/openapi-validator.ts +111 -0
- package/src/pillars/api/global-setup.ts +103 -0
- package/src/pillars/api/playwright.config.ts +105 -0
- package/src/pillars/api/schemas/example-api.json +110 -0
- package/src/pillars/api/tests/health/health.spec.ts +19 -0
- package/src/pillars/api/tests/health/manifest.yml +6 -0
- package/src/pillars/browser/fixtures/index.ts +232 -0
- package/src/pillars/browser/fixtures/package.json +10 -0
- package/src/pillars/browser/global-setup.ts +140 -0
- package/src/pillars/browser/global-teardown.ts +48 -0
- package/src/pillars/browser/playwright.config.ts +212 -0
- package/src/pillars/browser/setup.ts +219 -0
- package/src/pillars/journeys/app-config.ts +237 -0
- package/src/pillars/journeys/discover.ts +160 -0
- package/src/pillars/journeys/hooks/deny-shell.cjs +44 -0
- package/src/pillars/journeys/observability/events.ts +288 -0
- package/src/pillars/journeys/observability/sdk-mapper.ts +306 -0
- package/src/pillars/journeys/report/friction.ts +304 -0
- package/src/pillars/journeys/report/junit.ts +105 -0
- package/src/pillars/journeys/report/render.ts +273 -0
- package/src/pillars/journeys/report/results.ts +323 -0
- package/src/pillars/journeys/run.ts +673 -0
- package/src/pillars/journeys/runner.ts +1025 -0
- package/src/pillars/journeys/sandbox.ts +406 -0
- package/src/pillars/journeys/spec/loader.ts +152 -0
- package/src/pillars/journeys/spec/schema.ts +208 -0
- package/src/pillars/journeys/teardown.ts +98 -0
- package/src/pillars/journeys/tools/browser-session.ts +219 -0
- package/src/pillars/journeys/tools/browser-tool.ts +914 -0
- package/src/pillars/journeys/tools/http-tool.ts +564 -0
- package/src/pillars/journeys/tools/ledger.ts +278 -0
- package/src/pillars/journeys/tools/snapshot-store.ts +117 -0
- package/src/pillars/journeys/verify/assertions.ts +502 -0
- package/src/pillars/journeys/verify/verdict.ts +71 -0
- package/src/pillars/perf/build-report.ts +36 -0
- package/src/pillars/perf/k6.config.js +112 -0
- package/src/pillars/perf/run.ts +345 -0
- package/src/pillars/perf/scenarios/load-profile.js +97 -0
- package/src/pillars/perf/staging.ts +73 -0
- package/src/pillars/security/discover.ts +108 -0
- package/src/pillars/security/findings.ts +151 -0
- package/src/pillars/security/plan.ts +355 -0
- package/src/pillars/security/run.ts +729 -0
- package/src/pillars/smoke/admin-probe.ts +68 -0
- package/src/pillars/smoke/base-url.ts +56 -0
- package/src/pillars/smoke/cleanup.ts +8 -0
- package/src/pillars/smoke/discover.ts +237 -0
- package/src/pillars/smoke/ensure-smoke-teardown.ts +40 -0
- package/src/pillars/smoke/fixture-manager.ts +217 -0
- package/src/pillars/smoke/fixtures.ts +468 -0
- package/src/pillars/smoke/global-setup-all.ts +262 -0
- package/src/pillars/smoke/global-setup-global.ts +262 -0
- package/src/pillars/smoke/global-setup.ts +273 -0
- package/src/pillars/smoke/global-teardown-all.ts +8 -0
- package/src/pillars/smoke/global-teardown-global.ts +8 -0
- package/src/pillars/smoke/global-teardown.ts +139 -0
- package/src/pillars/smoke/playwright-all.config.ts +147 -0
- package/src/pillars/smoke/playwright-global.config.ts +141 -0
- package/src/pillars/smoke/playwright.config.ts +162 -0
- package/src/pillars/smoke/setup-env.ts +132 -0
- package/src/pillars/smoke/setup-global.ts +126 -0
- package/src/pillars/smoke/setup.ts +115 -0
- package/src/pillars/smoke/smoke-repo.ts +16 -0
- package/src/pillars/smoke/staging.ts +49 -0
- package/src/pillars/suites/run.ts +147 -0
- package/src/scripts/configure-aws-profiles.ts +114 -0
- package/src/scripts/register-repos.ts +307 -0
- package/src/scripts/setup-default-workspace.ts +203 -0
- package/src/shared/applications.ts +138 -0
- package/src/shared/auth/api-token.ts +416 -0
- package/src/shared/auth/auth0-helper.ts +284 -0
- package/src/shared/auth/headless-login.ts +265 -0
- package/src/shared/auth/jwt-helper.ts +186 -0
- package/src/shared/auth/sudo-vault.ts +69 -0
- package/src/shared/auth/test-accounts.ts +98 -0
- package/src/shared/auth/token-injection.ts +184 -0
- package/src/shared/auth/token-providers/auth0.ts +80 -0
- package/src/shared/auth/token-providers/index.ts +182 -0
- package/src/shared/auth/token-refresh.ts +273 -0
- package/src/shared/banner.ts +15 -0
- package/src/shared/brand.ts +79 -0
- package/src/shared/cli-tabs.ts +131 -0
- package/src/shared/constants.ts +128 -0
- package/src/shared/den-config.ts +566 -0
- package/src/shared/dev-metrics/emit.ts +354 -0
- package/src/shared/edge/config.ts +229 -0
- package/src/shared/edge/render.ts +221 -0
- package/src/shared/edge/runtime.ts +268 -0
- package/src/shared/edge/types.ts +62 -0
- package/src/shared/engine.ts +67 -0
- package/src/shared/environment/aws-profiles.ts +49 -0
- package/src/shared/environment/env-selector.ts +161 -0
- package/src/shared/environment/playwright-env.ts +82 -0
- package/src/shared/environment-manager/catalog.ts +77 -0
- package/src/shared/environment-manager/clone-guard.ts +163 -0
- package/src/shared/environment-manager/dag.ts +286 -0
- package/src/shared/environment-manager/default-workspace.ts +182 -0
- package/src/shared/environment-manager/derivation/alias.ts +90 -0
- package/src/shared/environment-manager/derivation/cache.ts +97 -0
- package/src/shared/environment-manager/derivation/derive-graph.ts +421 -0
- package/src/shared/environment-manager/derivation/derived-up.ts +354 -0
- package/src/shared/environment-manager/derivation/load-alias.ts +11 -0
- package/src/shared/environment-manager/derivation/rules-compose.ts +42 -0
- package/src/shared/environment-manager/derivation/rules-events.ts +50 -0
- package/src/shared/environment-manager/derivation/rules-http.ts +247 -0
- package/src/shared/environment-manager/derivation/rules-infra.ts +64 -0
- package/src/shared/environment-manager/derivation/rules-readiness.ts +96 -0
- package/src/shared/environment-manager/derivation/scan.ts +160 -0
- package/src/shared/environment-manager/derivation/text.ts +40 -0
- package/src/shared/environment-manager/derivation/types.ts +69 -0
- package/src/shared/environment-manager/health.ts +589 -0
- package/src/shared/environment-manager/index.ts +6 -0
- package/src/shared/environment-manager/load-applications.ts +78 -0
- package/src/shared/environment-manager/log-tail.ts +212 -0
- package/src/shared/environment-manager/make-targets.ts +78 -0
- package/src/shared/environment-manager/opctl.ts +334 -0
- package/src/shared/environment-manager/readiness.ts +93 -0
- package/src/shared/environment-manager/register-local.ts +72 -0
- package/src/shared/environment-manager/secrets-check.ts +260 -0
- package/src/shared/environment-manager/service-env.ts +110 -0
- package/src/shared/environment-manager/service-runner.ts +1588 -0
- package/src/shared/environment-manager/up-plan.ts +302 -0
- package/src/shared/environment-manager/workspace-manager.ts +905 -0
- package/src/shared/environment-manager/workspaces.ts +860 -0
- package/src/shared/manifest/deps.ts +27 -0
- package/src/shared/manifest/migrate-manifest.ts +101 -0
- package/src/shared/manifest/read-manifest.ts +160 -0
- package/src/shared/manifest/schema.ts +299 -0
- package/src/shared/manifest/service-links.ts +93 -0
- package/src/shared/manifest/validate-manifest.ts +140 -0
- package/src/shared/manifest/workspace-manifests.ts +92 -0
- package/src/shared/open-url.ts +122 -0
- package/src/shared/org.ts +44 -0
- package/src/shared/paths.ts +178 -0
- package/src/shared/perf/optional-fields.ts +105 -0
- package/src/shared/perf/report-normalizer.ts +404 -0
- package/src/shared/perf/report-paths.ts +19 -0
- package/src/shared/perf/report-store.ts +110 -0
- package/src/shared/perf/report-types.ts +70 -0
- package/src/shared/perf/test-paths.ts +25 -0
- package/src/shared/process/log-rotation.ts +147 -0
- package/src/shared/process/spawn-streaming.ts +229 -0
- package/src/shared/product.ts +92 -0
- package/src/shared/profile.ts +141 -0
- package/src/shared/repo-kind.ts +32 -0
- package/src/shared/reporting/jsonl-reporter.ts +131 -0
- package/src/shared/reporting/slack-reporter.ts +340 -0
- package/src/shared/results/result-reader.ts +266 -0
- package/src/shared/results/result-writer.ts +168 -0
- package/src/shared/secrets/doppler.ts +126 -0
- package/src/shared/secrets/env.ts +48 -0
- package/src/shared/secrets/index.ts +197 -0
- package/src/shared/secrets/keys.ts +33 -0
- package/src/shared/secrets/onepassword.ts +75 -0
- package/src/shared/secrets/types.ts +52 -0
- package/src/shared/service-url.ts +119 -0
- package/src/shared/smoke/branch-resolver.ts +141 -0
- package/src/shared/smoke/git-remote.ts +70 -0
- package/src/shared/smoke/repo-cloner.ts +151 -0
- package/src/shared/stacks/config.ts +137 -0
- package/src/shared/stacks/manifest-commands.ts +88 -0
- package/src/shared/stacks/render.ts +253 -0
- package/src/shared/stacks/runtime.ts +334 -0
- package/src/shared/stacks/types.ts +53 -0
- package/src/shared/terminal/tabs.ts +212 -0
- package/src/shared/test-discovery/module-scanner.ts +569 -0
- package/src/shared/types/index.ts +678 -0
- package/src/shared/ui-marker.ts +19 -0
- package/src/ui/cli/applications-command.ts +116 -0
- package/src/ui/cli/config-command.ts +68 -0
- package/src/ui/cli/create-main.ts +9 -0
- package/src/ui/cli/create.ts +360 -0
- package/src/ui/cli/edge-command.ts +91 -0
- package/src/ui/cli/flag-parser.ts +246 -0
- package/src/ui/cli/graph-command.ts +524 -0
- package/src/ui/cli/health-display.ts +63 -0
- package/src/ui/cli/index.ts +3656 -0
- package/src/ui/cli/init.ts +855 -0
- package/src/ui/cli/invocation-target.ts +175 -0
- package/src/ui/cli/logs-command.ts +104 -0
- package/src/ui/cli/module-selection.ts +31 -0
- package/src/ui/cli/prompts.ts +767 -0
- package/src/ui/cli/results-viewer.ts +436 -0
- package/src/ui/cli/secrets-preflight.ts +91 -0
- package/src/ui/cli/stack-command.ts +144 -0
- package/src/ui/cli/stalled-setup-recovery.ts +129 -0
- package/src/ui/cli/test-runner.ts +1312 -0
- package/src/ui/cli/web-command.ts +1073 -0
- package/src/ui/server/changelog-parser.ts +94 -0
- package/src/ui/server/daemon-reaper.ts +129 -0
- package/src/ui/server/editor-manager.ts +301 -0
- package/src/ui/server/github-service.ts +223 -0
- package/src/ui/server/http-helpers.ts +98 -0
- package/src/ui/server/index.ts +3236 -0
- package/src/ui/server/log-history.ts +452 -0
- package/src/ui/server/perf-report.ts +42 -0
- package/src/ui/server/public/environments.html +494 -0
- package/src/ui/server/public/favicon.ico +0 -0
- package/src/ui/server/public/header.js +1371 -0
- package/src/ui/server/public/index.html +2595 -0
- package/src/ui/server/public/local-dev.html +7405 -0
- package/src/ui/server/public/logs.html +1489 -0
- package/src/ui/server/public/perf-report.js +359 -0
- package/src/ui/server/public/release.html +2111 -0
- package/src/ui/server/public/services.html +1098 -0
- package/src/ui/server/public/workspace-picker.js +69 -0
- package/src/ui/server/release-deploy-session.ts +246 -0
- package/src/ui/server/release-service.ts +1125 -0
- package/src/ui/server/sudo-store.ts +111 -0
- package/src/ui/server/workspace-routes.ts +2154 -0
|
@@ -0,0 +1,729 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Security CLI entrypoint (security pillar).
|
|
3
|
+
*
|
|
4
|
+
* npx tsx src/pillars/security/run.ts --env=<env> [--service=<repo/name>]
|
|
5
|
+
* [--mode=<baseline|api|full|all>] [--dry-run]
|
|
6
|
+
*
|
|
7
|
+
* Runs OWASP ZAP (docker) against our OWN services declared in their
|
|
8
|
+
* .den.yml `security:` blocks (or den's held manifests in
|
|
9
|
+
* manifests/). Conventions mirror the journeys pillar:
|
|
10
|
+
*
|
|
11
|
+
* - `--env` is REQUIRED and exactly one per invocation (the make targets
|
|
12
|
+
* add the command-line-origin guard on top). `production` additionally
|
|
13
|
+
* needs DEN_ALLOW_PROD=1.
|
|
14
|
+
* - Every target host must be on the owned-host allowlist
|
|
15
|
+
* (config/den.yml `security.owned_hosts`, default: none), in every
|
|
16
|
+
* env.
|
|
17
|
+
* - Mode policy lives in plan.ts (MODE_CEILING): production is passive
|
|
18
|
+
* `baseline` only and no manifest can widen that. `--mode` defaults to
|
|
19
|
+
* `baseline`; `all` runs every mode the service allows in the env. An
|
|
20
|
+
* explicit mode above the env's ceiling is REFUSED, never downgraded.
|
|
21
|
+
* - Production never injects credentials (unauthenticated passive scan),
|
|
22
|
+
* even if the manifest lists `auth:` roles.
|
|
23
|
+
* - `--dry-run` prints each docker command and exits 0 without minting
|
|
24
|
+
* tokens, creating directories, or starting anything.
|
|
25
|
+
* - Sequential in v1. Exit codes: 0 no High alerts / 1 infra or usage error
|
|
26
|
+
* / 2 at least one scan found a High alert / 3 nothing was scanned.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import * as fs from "fs";
|
|
30
|
+
import * as path from "path";
|
|
31
|
+
import { spawn } from "child_process";
|
|
32
|
+
import type {
|
|
33
|
+
Environment,
|
|
34
|
+
SecurityMode,
|
|
35
|
+
TestResult,
|
|
36
|
+
} from "../../shared/types/index.js";
|
|
37
|
+
import { getDistRoot } from "../../shared/paths.js";
|
|
38
|
+
import { readSecurityAutoTargets } from "../../shared/den-config.js";
|
|
39
|
+
import { listServicesWithManifests } from "../../shared/manifest/workspace-manifests.js";
|
|
40
|
+
import {
|
|
41
|
+
autoSecurityTargets,
|
|
42
|
+
discoverSecurityTargets,
|
|
43
|
+
securityTargetId,
|
|
44
|
+
type DiscoveredSecurityTarget,
|
|
45
|
+
} from "./discover.js";
|
|
46
|
+
import {
|
|
47
|
+
latestSummaryPath,
|
|
48
|
+
newFindings,
|
|
49
|
+
parseZapReport,
|
|
50
|
+
type Finding,
|
|
51
|
+
type ScanSummary,
|
|
52
|
+
type TargetResult,
|
|
53
|
+
} from "./findings.js";
|
|
54
|
+
import {
|
|
55
|
+
AUTH_HEADER_ENV,
|
|
56
|
+
allowedModes,
|
|
57
|
+
buildZapPlan,
|
|
58
|
+
MODE_CEILING,
|
|
59
|
+
resolveOpenapi,
|
|
60
|
+
resolveTargets,
|
|
61
|
+
scanFailed,
|
|
62
|
+
summarizeZapReport,
|
|
63
|
+
type AlertCounts,
|
|
64
|
+
ownedHostSuffixes,
|
|
65
|
+
type SecurityEnvConfigLike,
|
|
66
|
+
type ZapPlan,
|
|
67
|
+
} from "./plan.js";
|
|
68
|
+
|
|
69
|
+
import { envKey, product } from "../../shared/product.js";
|
|
70
|
+
// ── Exit codes ───────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
export const EXIT_CLEAN = 0;
|
|
73
|
+
export const EXIT_INFRA_ERROR = 1;
|
|
74
|
+
export const EXIT_FINDINGS = 2;
|
|
75
|
+
/** Nothing was scanned — loud and distinct, never silent green. */
|
|
76
|
+
export const EXIT_NO_TARGETS = 3;
|
|
77
|
+
|
|
78
|
+
// ── CLI args ─────────────────────────────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
const KNOWN_ENVIRONMENTS: readonly Environment[] = [
|
|
81
|
+
"local",
|
|
82
|
+
"dev",
|
|
83
|
+
"sandbox",
|
|
84
|
+
"staging",
|
|
85
|
+
"production",
|
|
86
|
+
];
|
|
87
|
+
const KNOWN_MODES: readonly string[] = ["baseline", "api", "full", "all"];
|
|
88
|
+
|
|
89
|
+
const USAGE =
|
|
90
|
+
"Usage: run.ts --env=<env> [--service=<name>] " +
|
|
91
|
+
"[--mode=<baseline|api|full|all>] [--dry-run]";
|
|
92
|
+
|
|
93
|
+
/** Operator/usage mistake — maps to {@link EXIT_INFRA_ERROR}. */
|
|
94
|
+
export class CliUsageError extends Error {
|
|
95
|
+
constructor(message: string) {
|
|
96
|
+
super(message);
|
|
97
|
+
this.name = "CliUsageError";
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export interface ParsedCliArgs {
|
|
102
|
+
env: Environment;
|
|
103
|
+
service?: string;
|
|
104
|
+
mode: SecurityMode | "all";
|
|
105
|
+
dryRun: boolean;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Parse the CLI args. `--env` is required exactly once; unknown arguments
|
|
110
|
+
* are rejected loudly rather than ignored.
|
|
111
|
+
*/
|
|
112
|
+
export function parseCliArgs(argv: readonly string[]): ParsedCliArgs {
|
|
113
|
+
const envValues: string[] = [];
|
|
114
|
+
let service: string | undefined;
|
|
115
|
+
let mode = "baseline";
|
|
116
|
+
let dryRun = false;
|
|
117
|
+
|
|
118
|
+
for (const arg of argv) {
|
|
119
|
+
if (arg.startsWith("--env=")) {
|
|
120
|
+
envValues.push(arg.slice("--env=".length));
|
|
121
|
+
} else if (arg.startsWith("--service=")) {
|
|
122
|
+
service = arg.slice("--service=".length);
|
|
123
|
+
} else if (arg.startsWith("--mode=")) {
|
|
124
|
+
mode = arg.slice("--mode=".length);
|
|
125
|
+
} else if (arg === "--dry-run") {
|
|
126
|
+
dryRun = true;
|
|
127
|
+
} else {
|
|
128
|
+
throw new CliUsageError(`Unrecognized argument "${arg}". ${USAGE}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (envValues.length === 0) {
|
|
133
|
+
throw new CliUsageError(
|
|
134
|
+
`--env is required (exactly one environment per invocation). ${USAGE}`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
if (envValues.length > 1) {
|
|
138
|
+
throw new CliUsageError(
|
|
139
|
+
"Exactly one --env per invocation — scans never fan out across " +
|
|
140
|
+
`environments (got: ${envValues.join(", ")}).`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
const env = envValues[0];
|
|
144
|
+
if (!(KNOWN_ENVIRONMENTS as readonly string[]).includes(env)) {
|
|
145
|
+
throw new CliUsageError(
|
|
146
|
+
`Unknown environment "${env}". Known: ${KNOWN_ENVIRONMENTS.join(", ")}.`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
if (!KNOWN_MODES.includes(mode)) {
|
|
150
|
+
throw new CliUsageError(
|
|
151
|
+
`Unknown mode "${mode}". Known: ${KNOWN_MODES.join(", ")}.`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const parsed: ParsedCliArgs = {
|
|
156
|
+
env: env as Environment,
|
|
157
|
+
mode: mode as SecurityMode | "all",
|
|
158
|
+
dryRun,
|
|
159
|
+
};
|
|
160
|
+
if (service !== undefined) parsed.service = service;
|
|
161
|
+
return parsed;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// ── Injectable dependencies ──────────────────────────────────────────────────
|
|
165
|
+
|
|
166
|
+
export interface ZapRunResult {
|
|
167
|
+
exitCode: number;
|
|
168
|
+
timedOut: boolean;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export interface EntrypointDeps {
|
|
172
|
+
discoverTargets: () => DiscoveredSecurityTarget[];
|
|
173
|
+
/** Owned-host allowlist (config/den.yml + DEN_SECURITY_EXTRA_HOSTS). */
|
|
174
|
+
ownedHosts: () => string[];
|
|
175
|
+
loadEnvironment: (env: Environment) => Promise<SecurityEnvConfigLike>;
|
|
176
|
+
/** Bearer token for an auth role (never called in dry-run or production). */
|
|
177
|
+
getToken: (
|
|
178
|
+
role: "admin" | "user" | "beta",
|
|
179
|
+
env: Environment,
|
|
180
|
+
) => Promise<string>;
|
|
181
|
+
/** Execute the plan; `childEnv` carries any credential (never argv). */
|
|
182
|
+
runZap: (plan: ZapPlan, childEnv: NodeJS.ProcessEnv) => Promise<ZapRunResult>;
|
|
183
|
+
mkdirp: (dir: string) => void;
|
|
184
|
+
readFileIfExists: (filePath: string) => string | undefined;
|
|
185
|
+
writeFile: (filePath: string, contents: string) => void;
|
|
186
|
+
writeResult: (row: TestResult) => Promise<void>;
|
|
187
|
+
/** Root under which reports/security/<env>/<run>/ is created. */
|
|
188
|
+
reportsRoot: string;
|
|
189
|
+
now: () => Date;
|
|
190
|
+
log: (message: string) => void;
|
|
191
|
+
logError: (message: string) => void;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Production wiring: real docker, real token minting, real result writer. */
|
|
195
|
+
export function defaultEntrypointDeps(): EntrypointDeps {
|
|
196
|
+
return {
|
|
197
|
+
discoverTargets: () => {
|
|
198
|
+
const declared = discoverSecurityTargets();
|
|
199
|
+
return readSecurityAutoTargets()
|
|
200
|
+
? [
|
|
201
|
+
...declared,
|
|
202
|
+
...autoSecurityTargets(listServicesWithManifests(), declared),
|
|
203
|
+
]
|
|
204
|
+
: declared;
|
|
205
|
+
},
|
|
206
|
+
ownedHosts: () => ownedHostSuffixes(),
|
|
207
|
+
loadEnvironment: async (env) => {
|
|
208
|
+
const { envSelector } =
|
|
209
|
+
await import("../../shared/environment/env-selector.js");
|
|
210
|
+
return envSelector.loadEnvironment(env);
|
|
211
|
+
},
|
|
212
|
+
getToken: async (role, env) => {
|
|
213
|
+
const { getApiToken } = await import("../../shared/auth/api-token.js");
|
|
214
|
+
return getApiToken(role, env);
|
|
215
|
+
},
|
|
216
|
+
runZap: (plan, childEnv) =>
|
|
217
|
+
new Promise<ZapRunResult>((resolve, reject) => {
|
|
218
|
+
const child = spawn(plan.command, plan.args, {
|
|
219
|
+
env: childEnv,
|
|
220
|
+
stdio: "inherit",
|
|
221
|
+
});
|
|
222
|
+
let timedOut = false;
|
|
223
|
+
const timer = setTimeout(() => {
|
|
224
|
+
timedOut = true;
|
|
225
|
+
child.kill("SIGTERM");
|
|
226
|
+
}, plan.timeoutMs);
|
|
227
|
+
child.on("error", (error) => {
|
|
228
|
+
clearTimeout(timer);
|
|
229
|
+
reject(error);
|
|
230
|
+
});
|
|
231
|
+
child.on("close", (code) => {
|
|
232
|
+
clearTimeout(timer);
|
|
233
|
+
resolve({ exitCode: code ?? -1, timedOut });
|
|
234
|
+
});
|
|
235
|
+
}),
|
|
236
|
+
mkdirp: (dir) => {
|
|
237
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
238
|
+
},
|
|
239
|
+
readFileIfExists: (filePath) =>
|
|
240
|
+
fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : undefined,
|
|
241
|
+
writeFile: (filePath, contents) => fs.writeFileSync(filePath, contents),
|
|
242
|
+
writeResult: async (row) => {
|
|
243
|
+
const { resultWriter } =
|
|
244
|
+
await import("../../shared/results/result-writer.js");
|
|
245
|
+
await resultWriter.writeResult(row);
|
|
246
|
+
},
|
|
247
|
+
reportsRoot: path.join(getDistRoot(), "reports", "security"),
|
|
248
|
+
now: () => new Date(),
|
|
249
|
+
log: (message) => console.log(message),
|
|
250
|
+
logError: (message) => console.error(message),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── Per-scan execution ───────────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
type ScanStatus = "clean" | "findings" | "infra";
|
|
257
|
+
|
|
258
|
+
interface ScanOutcome {
|
|
259
|
+
status: ScanStatus;
|
|
260
|
+
/** null when the scan itself failed. */
|
|
261
|
+
findings: Finding[] | null;
|
|
262
|
+
error?: string;
|
|
263
|
+
reportDir: string;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** ZAP exits 0/1/2 when the scan completed (1/2 = its own fail/warn). */
|
|
267
|
+
const ZAP_COMPLETED_EXIT_CODES: readonly number[] = [0, 1, 2];
|
|
268
|
+
|
|
269
|
+
/** One planned scan: a (service, mode, target, role) combination. */
|
|
270
|
+
interface ScanUnit {
|
|
271
|
+
target: DiscoveredSecurityTarget;
|
|
272
|
+
mode: SecurityMode;
|
|
273
|
+
baseUrl: string;
|
|
274
|
+
/** Undefined = unauthenticated. */
|
|
275
|
+
role?: "admin" | "user" | "beta";
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function scanLabel(unit: ScanUnit): string {
|
|
279
|
+
return (
|
|
280
|
+
`${securityTargetId(unit.target)} ${unit.mode} ${unit.baseUrl}` +
|
|
281
|
+
(unit.role ? ` as ${unit.role}` : "") +
|
|
282
|
+
(unit.target.auto ? " (auto: health URL)" : "")
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Replace any occurrence of `secret` in a persisted report file. */
|
|
287
|
+
function redactFile(
|
|
288
|
+
filePath: string,
|
|
289
|
+
secret: string | undefined,
|
|
290
|
+
deps: EntrypointDeps,
|
|
291
|
+
): void {
|
|
292
|
+
if (secret === undefined || secret.length === 0) return;
|
|
293
|
+
const contents = deps.readFileIfExists(filePath);
|
|
294
|
+
if (contents !== undefined && contents.includes(secret)) {
|
|
295
|
+
deps.writeFile(filePath, contents.split(secret).join("[REDACTED]"));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function executeScan(
|
|
300
|
+
unit: ScanUnit,
|
|
301
|
+
args: ParsedCliArgs,
|
|
302
|
+
runDir: string,
|
|
303
|
+
index: number,
|
|
304
|
+
deps: EntrypointDeps,
|
|
305
|
+
): Promise<ScanOutcome> {
|
|
306
|
+
const started = deps.now();
|
|
307
|
+
const reportDir = path.join(
|
|
308
|
+
runDir,
|
|
309
|
+
`${String(index).padStart(2, "0")}-${unit.target.repoName}-` +
|
|
310
|
+
`${unit.target.serviceName}-${unit.mode}${unit.role ? `-${unit.role}` : ""}`,
|
|
311
|
+
);
|
|
312
|
+
deps.log(`▶ scan ${scanLabel(unit)} — env=${args.env}`);
|
|
313
|
+
|
|
314
|
+
let token: string | undefined;
|
|
315
|
+
let counts: AlertCounts | undefined;
|
|
316
|
+
let parsed: Finding[] | null = null;
|
|
317
|
+
let status: ScanStatus = "infra";
|
|
318
|
+
let error: string | undefined;
|
|
319
|
+
|
|
320
|
+
try {
|
|
321
|
+
deps.mkdirp(reportDir);
|
|
322
|
+
if (unit.role !== undefined) {
|
|
323
|
+
token = await deps.getToken(unit.role, args.env);
|
|
324
|
+
}
|
|
325
|
+
const spec = resolveOpenapi(
|
|
326
|
+
unit.target.config.openapi,
|
|
327
|
+
unit.baseUrl,
|
|
328
|
+
unit.target.repoPath,
|
|
329
|
+
);
|
|
330
|
+
const plan = buildZapPlan({
|
|
331
|
+
mode: unit.mode,
|
|
332
|
+
target: unit.baseUrl,
|
|
333
|
+
reportDir,
|
|
334
|
+
...(spec.file !== undefined ? { openapiFile: spec.file } : {}),
|
|
335
|
+
...(spec.url !== undefined ? { openapiUrl: spec.url } : {}),
|
|
336
|
+
...(unit.target.config.exclude !== undefined
|
|
337
|
+
? { exclude: unit.target.config.exclude }
|
|
338
|
+
: {}),
|
|
339
|
+
authenticated: token !== undefined,
|
|
340
|
+
});
|
|
341
|
+
const childEnv: NodeJS.ProcessEnv = { ...process.env };
|
|
342
|
+
if (token !== undefined) childEnv[AUTH_HEADER_ENV] = `Bearer ${token}`;
|
|
343
|
+
|
|
344
|
+
const result = await deps.runZap(plan, childEnv);
|
|
345
|
+
if (result.timedOut) {
|
|
346
|
+
throw new Error(`ZAP exceeded the ${plan.timeoutMs / 60_000}m cap.`);
|
|
347
|
+
}
|
|
348
|
+
if (!ZAP_COMPLETED_EXIT_CODES.includes(result.exitCode)) {
|
|
349
|
+
throw new Error(`ZAP exited with code ${result.exitCode}.`);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Belt and suspenders: ZAP shouldn't echo request headers into reports,
|
|
353
|
+
// but a credential must never persist if it ever does.
|
|
354
|
+
const jsonPath = path.join(reportDir, "report.json");
|
|
355
|
+
const raw = deps.readFileIfExists(jsonPath);
|
|
356
|
+
if (raw === undefined) throw new Error("ZAP wrote no report.json.");
|
|
357
|
+
const report: unknown = JSON.parse(raw);
|
|
358
|
+
counts = summarizeZapReport(report);
|
|
359
|
+
parsed = parseZapReport(report);
|
|
360
|
+
redactFile(jsonPath, token, deps);
|
|
361
|
+
redactFile(path.join(reportDir, "report.html"), token, deps);
|
|
362
|
+
status = scanFailed(counts) ? "findings" : "clean";
|
|
363
|
+
} catch (caught) {
|
|
364
|
+
error = caught instanceof Error ? caught.message : String(caught);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const duration = deps.now().getTime() - started.getTime();
|
|
368
|
+
const row: TestResult = {
|
|
369
|
+
timestamp: started.toISOString(),
|
|
370
|
+
pillar: "security",
|
|
371
|
+
environment: args.env,
|
|
372
|
+
test: `${securityTargetId(unit.target)}:${unit.mode}${unit.role ? `:${unit.role}` : ""}`,
|
|
373
|
+
status: status === "clean" ? "passed" : "failed",
|
|
374
|
+
duration,
|
|
375
|
+
user: process.env.USER ?? "unknown",
|
|
376
|
+
metadata: {
|
|
377
|
+
mode: unit.mode,
|
|
378
|
+
target: unit.baseUrl,
|
|
379
|
+
authenticated: unit.role !== undefined,
|
|
380
|
+
...(unit.role !== undefined ? { role: unit.role } : {}),
|
|
381
|
+
report_dir: reportDir,
|
|
382
|
+
...(counts !== undefined ? { alerts: counts } : {}),
|
|
383
|
+
...(status === "infra" ? { failure_kind: "infra_error" } : {}),
|
|
384
|
+
},
|
|
385
|
+
};
|
|
386
|
+
if (error !== undefined) row.error = error;
|
|
387
|
+
try {
|
|
388
|
+
await deps.writeResult(row);
|
|
389
|
+
} catch (writeError) {
|
|
390
|
+
deps.logError(
|
|
391
|
+
`Failed to persist result row: ` +
|
|
392
|
+
`${writeError instanceof Error ? writeError.message : String(writeError)}`,
|
|
393
|
+
);
|
|
394
|
+
status = "infra";
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
deps.log(
|
|
398
|
+
status === "clean"
|
|
399
|
+
? `✔ ${scanLabel(unit)} — no High alerts`
|
|
400
|
+
: status === "findings"
|
|
401
|
+
? `✖ ${scanLabel(unit)} — ${counts?.high ?? 0} High, ` +
|
|
402
|
+
`${counts?.medium ?? 0} Medium alert(s); report: ${reportDir}`
|
|
403
|
+
: `✖ ${scanLabel(unit)} — infra error: ${error ?? "unknown"}`,
|
|
404
|
+
);
|
|
405
|
+
return {
|
|
406
|
+
status,
|
|
407
|
+
findings: status === "infra" ? null : parsed,
|
|
408
|
+
...(error !== undefined ? { error } : {}),
|
|
409
|
+
reportDir,
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** The id a scan unit has in a run summary (one row per URL and role). */
|
|
414
|
+
function unitId(unit: ScanUnit): string {
|
|
415
|
+
return securityTargetId(unit.target) + (unit.role ? ` as ${unit.role}` : "");
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Write each mode's run summary and point <env>/latest-<mode>.json at it,
|
|
420
|
+
* marking the findings the previous run of the same row did not have.
|
|
421
|
+
*/
|
|
422
|
+
function writeRunSummaries(
|
|
423
|
+
units: ScanUnit[],
|
|
424
|
+
outcomes: ScanOutcome[],
|
|
425
|
+
env: Environment,
|
|
426
|
+
runDir: string,
|
|
427
|
+
startedAt: Date,
|
|
428
|
+
deps: EntrypointDeps,
|
|
429
|
+
): void {
|
|
430
|
+
const modes = [...new Set(units.map((u) => u.mode))];
|
|
431
|
+
for (const mode of modes) {
|
|
432
|
+
const latest = latestSummaryPath(deps.reportsRoot, env, mode);
|
|
433
|
+
let previous: ScanSummary | null = null;
|
|
434
|
+
try {
|
|
435
|
+
const raw = deps.readFileIfExists(latest);
|
|
436
|
+
previous = raw === undefined ? null : (JSON.parse(raw) as ScanSummary);
|
|
437
|
+
} catch {
|
|
438
|
+
previous = null;
|
|
439
|
+
}
|
|
440
|
+
const results: TargetResult[] = [];
|
|
441
|
+
units.forEach((unit, i) => {
|
|
442
|
+
if (unit.mode !== mode) return;
|
|
443
|
+
const o = outcomes[i];
|
|
444
|
+
const id = unitId(unit);
|
|
445
|
+
const prev = previous?.results.find(
|
|
446
|
+
(r) => r.id === id && r.url === unit.baseUrl,
|
|
447
|
+
)?.findings;
|
|
448
|
+
results.push({
|
|
449
|
+
id,
|
|
450
|
+
repo: unit.target.repoName,
|
|
451
|
+
service: unit.target.serviceName,
|
|
452
|
+
org: "",
|
|
453
|
+
url: unit.baseUrl,
|
|
454
|
+
findings: o.findings,
|
|
455
|
+
...(o.error !== undefined ? { error: o.error } : {}),
|
|
456
|
+
...(o.findings ? { newSince: newFindings(o.findings, prev) } : {}),
|
|
457
|
+
...(o.findings
|
|
458
|
+
? {
|
|
459
|
+
report: path.relative(
|
|
460
|
+
deps.reportsRoot,
|
|
461
|
+
path.join(o.reportDir, "report.html"),
|
|
462
|
+
),
|
|
463
|
+
}
|
|
464
|
+
: {}),
|
|
465
|
+
});
|
|
466
|
+
});
|
|
467
|
+
const summary: ScanSummary = {
|
|
468
|
+
id: `${path.basename(runDir)}-${env}-${mode}`,
|
|
469
|
+
env,
|
|
470
|
+
mode,
|
|
471
|
+
startedAt: startedAt.toISOString(),
|
|
472
|
+
finishedAt: deps.now().toISOString(),
|
|
473
|
+
results,
|
|
474
|
+
};
|
|
475
|
+
const body = JSON.stringify(summary, null, 2);
|
|
476
|
+
try {
|
|
477
|
+
deps.writeFile(path.join(runDir, `summary-${mode}.json`), body);
|
|
478
|
+
deps.writeFile(latest, body);
|
|
479
|
+
} catch (error) {
|
|
480
|
+
deps.logError(
|
|
481
|
+
`Failed to write the run summary: ` +
|
|
482
|
+
`${error instanceof Error ? error.message : String(error)}`,
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// ── CLI orchestration ────────────────────────────────────────────────────────
|
|
489
|
+
|
|
490
|
+
/** Filter discovered targets by --service ("<repo>/<name>" or bare name). */
|
|
491
|
+
function filterTargets(
|
|
492
|
+
targets: DiscoveredSecurityTarget[],
|
|
493
|
+
service: string,
|
|
494
|
+
): DiscoveredSecurityTarget[] {
|
|
495
|
+
return service.includes("/")
|
|
496
|
+
? targets.filter((t) => securityTargetId(t) === service)
|
|
497
|
+
: targets.filter((t) => t.serviceName === service);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Run the security pillar once against one environment. Returns the
|
|
502
|
+
* process exit code (see the EXIT_* constants).
|
|
503
|
+
*/
|
|
504
|
+
export async function runSecurityCli(
|
|
505
|
+
argv: readonly string[],
|
|
506
|
+
deps: EntrypointDeps = defaultEntrypointDeps(),
|
|
507
|
+
): Promise<number> {
|
|
508
|
+
let args: ParsedCliArgs;
|
|
509
|
+
try {
|
|
510
|
+
args = parseCliArgs(argv);
|
|
511
|
+
} catch (error) {
|
|
512
|
+
deps.logError(error instanceof Error ? error.message : String(error));
|
|
513
|
+
return EXIT_INFRA_ERROR;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const ceiling = MODE_CEILING[args.env];
|
|
517
|
+
if (ceiling.length === 0) {
|
|
518
|
+
deps.logError(`Security scans are not supported for env "${args.env}".`);
|
|
519
|
+
return EXIT_INFRA_ERROR;
|
|
520
|
+
}
|
|
521
|
+
if (args.env === "production" && process.env.DEN_ALLOW_PROD !== "1") {
|
|
522
|
+
deps.logError(
|
|
523
|
+
`Refusing to scan production without ${envKey("ALLOW_PROD")}=1. ` +
|
|
524
|
+
"Production scans are passive (baseline) only — set it to confirm " +
|
|
525
|
+
"an authorized run.",
|
|
526
|
+
);
|
|
527
|
+
return EXIT_INFRA_ERROR;
|
|
528
|
+
}
|
|
529
|
+
if (args.mode !== "all" && !ceiling.includes(args.mode)) {
|
|
530
|
+
deps.logError(
|
|
531
|
+
`Mode "${args.mode}" is not permitted in ${args.env} ` +
|
|
532
|
+
`(allowed: ${ceiling.join(", ")}). Refusing rather than downgrading.`,
|
|
533
|
+
);
|
|
534
|
+
return EXIT_INFRA_ERROR;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
let targets: DiscoveredSecurityTarget[];
|
|
538
|
+
try {
|
|
539
|
+
targets = deps.discoverTargets();
|
|
540
|
+
} catch (error) {
|
|
541
|
+
deps.logError(
|
|
542
|
+
`Security target discovery failed: ` +
|
|
543
|
+
`${error instanceof Error ? error.message : String(error)}`,
|
|
544
|
+
);
|
|
545
|
+
return EXIT_INFRA_ERROR;
|
|
546
|
+
}
|
|
547
|
+
if (args.service !== undefined) {
|
|
548
|
+
targets = filterTargets(targets, args.service);
|
|
549
|
+
}
|
|
550
|
+
if (targets.length === 0) {
|
|
551
|
+
deps.logError(
|
|
552
|
+
"No security targets discovered" +
|
|
553
|
+
(args.service ? ` matching "${args.service}"` : "") +
|
|
554
|
+
". Add the repo to the active workspace and check the security: " +
|
|
555
|
+
`block in its ${product().manifestYml} (or manifests/<repo>${product().manifestYml}) — ` +
|
|
556
|
+
"zero targets is never a pass.",
|
|
557
|
+
);
|
|
558
|
+
return EXIT_NO_TARGETS;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
let envConfig: SecurityEnvConfigLike;
|
|
562
|
+
try {
|
|
563
|
+
envConfig = await deps.loadEnvironment(args.env);
|
|
564
|
+
} catch (error) {
|
|
565
|
+
deps.logError(
|
|
566
|
+
`Failed to load environment "${args.env}": ` +
|
|
567
|
+
`${error instanceof Error ? error.message : String(error)}`,
|
|
568
|
+
);
|
|
569
|
+
return EXIT_INFRA_ERROR;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Expand to concrete scans. Target/host resolution errors are config
|
|
573
|
+
// mistakes (e.g. a non-owned host) and abort the whole run loudly.
|
|
574
|
+
const units: ScanUnit[] = [];
|
|
575
|
+
try {
|
|
576
|
+
const suffixes = deps.ownedHosts();
|
|
577
|
+
for (const target of targets) {
|
|
578
|
+
// A target that declares no host for THIS env (e.g. only staging/prod)
|
|
579
|
+
// is simply not in scope here — skip it, never abort the batch.
|
|
580
|
+
const inEnv =
|
|
581
|
+
(target.config.hosts?.[args.env]?.length ?? 0) > 0 ||
|
|
582
|
+
target.config.baseUrlKey !== undefined;
|
|
583
|
+
if (!inEnv) {
|
|
584
|
+
deps.log(
|
|
585
|
+
`↷ skipping ${securityTargetId(target)}: not deployed in ${args.env}`,
|
|
586
|
+
);
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
const modes = allowedModes(target.config, args.env).filter(
|
|
590
|
+
(m) => args.mode === "all" || m === args.mode,
|
|
591
|
+
);
|
|
592
|
+
if (modes.length === 0) {
|
|
593
|
+
deps.log(
|
|
594
|
+
`↷ skipping ${securityTargetId(target)}: mode "${args.mode}" is ` +
|
|
595
|
+
`not allowed for it in ${args.env}`,
|
|
596
|
+
);
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
const baseUrls = resolveTargets(
|
|
600
|
+
target.config,
|
|
601
|
+
args.env,
|
|
602
|
+
envConfig,
|
|
603
|
+
suffixes,
|
|
604
|
+
);
|
|
605
|
+
// Production: unauthenticated passive scan only (see module header).
|
|
606
|
+
const roles =
|
|
607
|
+
args.env === "production" || !target.config.auth?.length
|
|
608
|
+
? [undefined]
|
|
609
|
+
: target.config.auth;
|
|
610
|
+
for (const mode of modes) {
|
|
611
|
+
if (mode === "api" && target.config.openapi === undefined) {
|
|
612
|
+
deps.log(
|
|
613
|
+
`↷ skipping ${securityTargetId(target)} api: no openapi declared`,
|
|
614
|
+
);
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
for (const baseUrl of baseUrls) {
|
|
618
|
+
for (const role of roles) {
|
|
619
|
+
units.push({
|
|
620
|
+
target,
|
|
621
|
+
mode,
|
|
622
|
+
baseUrl,
|
|
623
|
+
...(role !== undefined ? { role } : {}),
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
} catch (error) {
|
|
630
|
+
deps.logError(
|
|
631
|
+
`Security plan refused: ${error instanceof Error ? error.message : String(error)}`,
|
|
632
|
+
);
|
|
633
|
+
return EXIT_INFRA_ERROR;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
if (units.length === 0) {
|
|
637
|
+
// An explicitly-named service with nothing to run here (not deployed in
|
|
638
|
+
// this env, or every mode filtered out) is a benign skip — exit clean so a
|
|
639
|
+
// per-service batch does not abort on it. "All targets" resolving to
|
|
640
|
+
// nothing stays loud (never silent green).
|
|
641
|
+
if (args.service !== undefined) {
|
|
642
|
+
deps.log(
|
|
643
|
+
`↷ ${args.service}: nothing to scan in env "${args.env}" — skipped.`,
|
|
644
|
+
);
|
|
645
|
+
return EXIT_CLEAN;
|
|
646
|
+
}
|
|
647
|
+
deps.logError(
|
|
648
|
+
`0 scans runnable for env "${args.env}" — nothing ran (never silent green).`,
|
|
649
|
+
);
|
|
650
|
+
return EXIT_NO_TARGETS;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
if (args.dryRun) {
|
|
654
|
+
for (const unit of units) {
|
|
655
|
+
try {
|
|
656
|
+
const spec = resolveOpenapi(
|
|
657
|
+
unit.target.config.openapi,
|
|
658
|
+
unit.baseUrl,
|
|
659
|
+
unit.target.repoPath,
|
|
660
|
+
);
|
|
661
|
+
const plan = buildZapPlan({
|
|
662
|
+
mode: unit.mode,
|
|
663
|
+
target: unit.baseUrl,
|
|
664
|
+
reportDir: "<report-dir>",
|
|
665
|
+
...(spec.file !== undefined ? { openapiFile: spec.file } : {}),
|
|
666
|
+
...(spec.url !== undefined ? { openapiUrl: spec.url } : {}),
|
|
667
|
+
...(unit.target.config.exclude !== undefined
|
|
668
|
+
? { exclude: unit.target.config.exclude }
|
|
669
|
+
: {}),
|
|
670
|
+
authenticated: unit.role !== undefined,
|
|
671
|
+
});
|
|
672
|
+
deps.log(
|
|
673
|
+
`[dry-run] ${scanLabel(unit)}\n ${plan.command} ${plan.args.join(" ")}`,
|
|
674
|
+
);
|
|
675
|
+
} catch (error) {
|
|
676
|
+
deps.logError(
|
|
677
|
+
`[dry-run] ${scanLabel(unit)} unrunnable: ` +
|
|
678
|
+
`${error instanceof Error ? error.message : String(error)}`,
|
|
679
|
+
);
|
|
680
|
+
return EXIT_INFRA_ERROR;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
deps.log(`[dry-run] ${units.length} scan(s) planned — nothing executed.`);
|
|
684
|
+
return EXIT_CLEAN;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const startedAt = deps.now();
|
|
688
|
+
const stamp = startedAt.toISOString().replace(/[:.]/g, "-");
|
|
689
|
+
const runDir = path.join(deps.reportsRoot, args.env, stamp);
|
|
690
|
+
const outcomes: ScanOutcome[] = [];
|
|
691
|
+
for (const [index, unit] of units.entries()) {
|
|
692
|
+
outcomes.push(await executeScan(unit, args, runDir, index, deps));
|
|
693
|
+
}
|
|
694
|
+
writeRunSummaries(units, outcomes, args.env, runDir, startedAt, deps);
|
|
695
|
+
const statuses = outcomes.map((o) => o.status);
|
|
696
|
+
|
|
697
|
+
const infra = statuses.filter((s) => s === "infra").length;
|
|
698
|
+
const findings = statuses.filter((s) => s === "findings").length;
|
|
699
|
+
deps.log(
|
|
700
|
+
`security: ${statuses.length - infra - findings} clean, ${findings} ` +
|
|
701
|
+
`with High alerts, ${infra} infra error(s) — env=${args.env}`,
|
|
702
|
+
);
|
|
703
|
+
|
|
704
|
+
// Infra errors dominate: the result set is unreliable, not merely red.
|
|
705
|
+
if (infra > 0) return EXIT_INFRA_ERROR;
|
|
706
|
+
if (findings > 0) return EXIT_FINDINGS;
|
|
707
|
+
return EXIT_CLEAN;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// ── Main-module gate ─────────────────────────────────────────────────────────
|
|
711
|
+
|
|
712
|
+
// No import.meta here: ts-jest compiles this module to CJS for the unit
|
|
713
|
+
// tests, where import.meta is a syntax error (same constraint as journeys).
|
|
714
|
+
const isMainModule = /[\\/]pillars[\\/]security[\\/]run\.(ts|js)$/.test(
|
|
715
|
+
process.argv[1] ?? "",
|
|
716
|
+
);
|
|
717
|
+
|
|
718
|
+
if (isMainModule) {
|
|
719
|
+
runSecurityCli(process.argv.slice(2))
|
|
720
|
+
.then((exitCode) => {
|
|
721
|
+
process.exitCode = exitCode;
|
|
722
|
+
})
|
|
723
|
+
.catch((error: unknown) => {
|
|
724
|
+
console.error(
|
|
725
|
+
error instanceof Error ? (error.stack ?? error.message) : String(error),
|
|
726
|
+
);
|
|
727
|
+
process.exitCode = EXIT_INFRA_ERROR;
|
|
728
|
+
});
|
|
729
|
+
}
|