@starci/hfs 5.0.4 → 5.0.6
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 +10 -0
- package/README.md +22 -2
- package/cache/app-families.d.mts +8 -5
- package/cache/app-families.mjs +11 -9
- package/cache/hfs-family.d.mts +2 -0
- package/cache/hfs-family.mjs +4 -4
- package/cache/run.d.mts +2 -0
- package/cache/run.mjs +2 -5
- package/cache/session.mjs +1 -1
- package/cache/test-family.d.mts +6 -4
- package/cache/test-family.mjs +22 -14
- package/cache/types-family.d.mts +5 -3
- package/cache/types-family.mjs +9 -8
- package/cache/wiring-family.d.mts +4 -2
- package/cache/wiring-family.mjs +5 -5
- package/ci/cli.d.mts +7 -1
- package/ci/cli.mjs +37 -6
- package/ci/github.d.mts +1 -0
- package/ci/secret.d.mts +10 -1
- package/ci/secret.mjs +26 -6
- package/ci/verify.d.mts +9 -1
- package/ci/verify.mjs +5 -3
- package/emit/contracts.d.mts +12 -9
- package/emit/contracts.mjs +29 -11
- package/lint/chunk.mjs +11 -2
- package/lint/run.d.mts +2 -1
- package/lint/run.mjs +26 -29
- package/package.json +1 -1
- package/release/candidate-ci.mjs +1 -1
- package/release/gate.d.mts +1 -0
- package/release/gate.mjs +76 -18
- package/release/index.d.mts +1 -1
- package/release/index.mjs +7 -3
- package/runtime/knowledge/grammars/index.yaml +11 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +6 -6
- package/runtime/knowledge/hfs/rules.yaml +2 -2
- package/runtime/modules/kernel/failure-codes.yaml +4 -4
- package/runtime/modules/schemas/application-stacks.schema.yaml +10 -7
- package/runtime/scripts/api/node/run-node.d.mts +29 -0
- package/runtime/scripts/api/node/run-node.mjs +19 -0
- package/runtime/scripts/gates/starcistacks-ci.mjs +3 -1
- package/runtime/scripts/gates/starcistacks-form.d.mts +1 -0
- package/runtime/scripts/gates/starcistacks.d.mts +1 -0
- package/runtime/scripts/gates/starcistacks.mjs +22 -10
- package/runtime/scripts/hfs/app-wiring.d.mts +8 -2
- package/runtime/scripts/hfs/app-wiring.mjs +27 -7
- package/runtime/scripts/hfs/architecture/contract-bounds.d.mts +3 -0
- package/runtime/scripts/hfs/architecture/contract-bounds.mjs +72 -0
- package/runtime/scripts/hfs/architecture/index.mjs +1 -1
- package/runtime/scripts/hfs/architecture/tiers.d.mts +2 -1
- package/runtime/scripts/hfs/architecture/tiers.mjs +45 -4
- package/runtime/scripts/hfs/ci-declaration.d.mts +47 -1
- package/runtime/scripts/hfs/ci-declaration.mjs +115 -3
- package/runtime/scripts/hfs/repo-identity.d.mts +5 -1
- package/runtime/scripts/hfs/repo-identity.mjs +15 -10
- package/runtime/scripts/hfs/rules/ci-custody.mjs +21 -32
- package/runtime/scripts/hfs/rules/contract.d.mts +11 -3
- package/runtime/scripts/hfs/rules/contract.mjs +50 -11
- package/runtime/scripts/hfs/rules/env-parity.mjs +15 -6
- package/runtime/scripts/hfs/secret-standard.d.mts +1 -0
- package/runtime/scripts/hfs/secret-standard.mjs +25 -4
- package/runtime/scripts/hfs/secret-store.d.mts +5 -0
- package/runtime/scripts/hfs/secret-store.mjs +31 -0
- package/src/main.d.mts +3 -1
- package/src/main.mjs +14 -11
- package/sync/ci-commands.mjs +2 -1
- package/sync/quality-services.mjs +3 -2
- package/sync/readme-badges.mjs +1 -1
- package/sync/sonar-key.d.mts +2 -3
- package/sync/sonar-key.mjs +7 -14
- package/templates/app/skeleton/.starcistacks/application-stacks.yaml +7 -3
- package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +7 -3
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import fs from 'node:fs';
|
|
9
9
|
import path from 'node:path';
|
|
10
10
|
import { createRequire } from 'node:module';
|
|
11
|
-
import { runNode } from '../api/node/run-node.mjs';
|
|
11
|
+
import { runNode, runHeavyNode } from '../api/node/run-node.mjs';
|
|
12
12
|
import { fileURLToPath } from 'node:url';
|
|
13
13
|
import { invocationDir } from '../lib/roots.mjs';
|
|
14
14
|
import { isMain } from '../lib/is-main.mjs';
|
|
@@ -174,7 +174,7 @@ function wiringApps(repo, files, prefix) {
|
|
|
174
174
|
});
|
|
175
175
|
return [...new Set([...declared, ...observed])];
|
|
176
176
|
}
|
|
177
|
-
function appWiringFinding({ repoRoot, beRoot, prefix, app, files, fixtures, timeout
|
|
177
|
+
function* appWiringFinding({ repoRoot, beRoot, prefix, app, files, fixtures, timeout }) {
|
|
178
178
|
const file = `${prefix}${modulePath(app)}`;
|
|
179
179
|
if (!/^[a-z][a-z0-9-]*$/.test(app) || !fs.existsSync(path.join(repoRoot, file)) || (files && !files.includes(file))) {
|
|
180
180
|
return refusal(file, 'app module is missing');
|
|
@@ -184,10 +184,9 @@ function appWiringFinding({ repoRoot, beRoot, prefix, app, files, fixtures, time
|
|
|
184
184
|
const args = [WORKER, '--worker', JSON.stringify({ repoRoot: beRoot, app, fixture: fixtureExists ? fixture : null, timeout })];
|
|
185
185
|
const budget = workerBudget(timeout);
|
|
186
186
|
const options = { cwd: beRoot, encoding: 'utf8', env: {}, windowsHide: true, timeout: budget, maxBuffer: 1024 * 1024 };
|
|
187
|
-
|
|
188
|
-
let result = spawnWorker();
|
|
187
|
+
let result = yield { args, options };
|
|
189
188
|
if (isTimeout(result))
|
|
190
|
-
result =
|
|
189
|
+
result = yield { args, options };
|
|
191
190
|
if (isTimeout(result))
|
|
192
191
|
return timedOut(file, budget);
|
|
193
192
|
let proof;
|
|
@@ -199,7 +198,7 @@ function appWiringFinding({ repoRoot, beRoot, prefix, app, files, fixtures, time
|
|
|
199
198
|
return refusal(file, SAFE_REASONS.has(proof?.reason) ? proof?.reason : WORKER_FAILURE);
|
|
200
199
|
return null;
|
|
201
200
|
}
|
|
202
|
-
|
|
201
|
+
function* wiringProgram({ repoRoot, repo = readJson(repoRoot, 'hfs.json'), files, fixtures = {}, timeout = DEFAULT_TIMEOUT }) {
|
|
203
202
|
if (repo?.edition !== 'lite' || repo?.profile === 'fe' || repo?.kind === 'fe')
|
|
204
203
|
return { ok: true, status: 'skipped', findings: [], apps: [] };
|
|
205
204
|
const appRoot = Boolean(repo.sides);
|
|
@@ -208,12 +207,33 @@ export function appWiring({ repoRoot, repo = readJson(repoRoot, 'hfs.json'), fil
|
|
|
208
207
|
const apps = wiringApps(repo, files, prefix);
|
|
209
208
|
const findings = [];
|
|
210
209
|
for (const app of apps) {
|
|
211
|
-
const finding = appWiringFinding({ repoRoot, beRoot, prefix, app, files, fixtures, timeout
|
|
210
|
+
const finding = yield* appWiringFinding({ repoRoot, beRoot, prefix, app, files, fixtures, timeout });
|
|
212
211
|
if (finding)
|
|
213
212
|
findings.push(finding);
|
|
214
213
|
}
|
|
215
214
|
return { ok: findings.length === 0, status: 'checked', apps, findings };
|
|
216
215
|
}
|
|
216
|
+
/** The synchronous finder and its existing worker doubles drive the same proof program as app check. */
|
|
217
|
+
export function appWiring(input) {
|
|
218
|
+
const program = wiringProgram(input);
|
|
219
|
+
let step = program.next();
|
|
220
|
+
while (!step.done) {
|
|
221
|
+
const { args, options } = step.value;
|
|
222
|
+
step = program.next(input.run ? input.run(process.execPath, args, options) : runNode(args, options));
|
|
223
|
+
}
|
|
224
|
+
return step.value;
|
|
225
|
+
}
|
|
226
|
+
/** The cache's asynchronous execution seam admits each boot attempt, with the same validation, timeout retry and result judgement. */
|
|
227
|
+
export async function appWiringAsync(input) {
|
|
228
|
+
const program = wiringProgram(input);
|
|
229
|
+
let step = program.next();
|
|
230
|
+
while (!step.done) {
|
|
231
|
+
const { args, options } = step.value;
|
|
232
|
+
const result = await runHeavyNode(input.execute, { kind: 'test', label: 'app check wiring boot proof', args, cwd: options.cwd, env: options.env, timeoutMs: options.timeout, maxBuffer: options.maxBuffer });
|
|
233
|
+
step = program.next(result);
|
|
234
|
+
}
|
|
235
|
+
return step.value;
|
|
236
|
+
}
|
|
217
237
|
export function appWiringMain(argv, { cwd = invocationDir(), stdout = (text) => process.stdout.write(text), stderr = (text) => process.stderr.write(text) } = {}) {
|
|
218
238
|
let directory = cwd, json = false;
|
|
219
239
|
for (let at = 0; at < argv.length; at += 1) {
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { TypeScriptApi } from './config.mjs';
|
|
2
|
+
/** True only for an unambiguous named export proven through actual declarations to a generated contract declaration. */
|
|
3
|
+
export declare function isBoundsExport(ts: TypeScriptApi, target: string, name: string, ownsGenerated: (file: string) => boolean, seen?: Set<string>): boolean;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
/** The generated input-bounds owner is contract data, not an operation. */
|
|
4
|
+
const BOUNDS_MODULE = /(?:^|\/)__generated__\/input-bounds\.ts$/;
|
|
5
|
+
/** Directly exported declarations with the requested name; multiple declarations are ambiguous. */
|
|
6
|
+
function declaredExports(ts, source, name) {
|
|
7
|
+
let count = 0;
|
|
8
|
+
for (const statement of source.statements) {
|
|
9
|
+
if (!ts.canHaveModifiers(statement) || !ts.getModifiers(statement)?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword))
|
|
10
|
+
continue;
|
|
11
|
+
if (ts.isVariableStatement(statement)) {
|
|
12
|
+
count += statement.declarationList.declarations.filter(declaration => ts.isIdentifier(declaration.name) && declaration.name.text === name).length;
|
|
13
|
+
}
|
|
14
|
+
else if ((ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement) || ts.isInterfaceDeclaration(statement) ||
|
|
15
|
+
ts.isTypeAliasDeclaration(statement) || ts.isEnumDeclaration(statement)) && statement.name?.text === name)
|
|
16
|
+
count += 1;
|
|
17
|
+
}
|
|
18
|
+
return count;
|
|
19
|
+
}
|
|
20
|
+
/** The concrete file a relative module specifier resolves to, in the same order as the rule's owner resolver. */
|
|
21
|
+
function readSource(ts, target) {
|
|
22
|
+
for (const file of [`${target}.ts`, `${target}/index.ts`, target]) {
|
|
23
|
+
let text;
|
|
24
|
+
try {
|
|
25
|
+
text = readFileSync(file, 'utf8');
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
|
|
31
|
+
if (source.parseDiagnostics.length)
|
|
32
|
+
return null;
|
|
33
|
+
return { file, source };
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
/** True only for an unambiguous named export proven through actual declarations to a generated contract declaration. */
|
|
38
|
+
export function isBoundsExport(ts, target, name, ownsGenerated, seen = new Set()) {
|
|
39
|
+
const found = readSource(ts, target);
|
|
40
|
+
if (!found)
|
|
41
|
+
return false;
|
|
42
|
+
const { file, source } = found;
|
|
43
|
+
const key = `${file}#${name}`;
|
|
44
|
+
if (seen.has(key) || seen.size >= 32)
|
|
45
|
+
return false;
|
|
46
|
+
const direct = declaredExports(ts, source, name);
|
|
47
|
+
const matches = [];
|
|
48
|
+
let wildcard = false;
|
|
49
|
+
for (const statement of source.statements) {
|
|
50
|
+
if (!ts.isExportDeclaration(statement))
|
|
51
|
+
continue;
|
|
52
|
+
const module = statement.moduleSpecifier && ts.isStringLiteralLike(statement.moduleSpecifier) ? statement.moduleSpecifier.text : null;
|
|
53
|
+
if (!statement.exportClause) {
|
|
54
|
+
wildcard = true;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (!ts.isNamedExports(statement.exportClause))
|
|
58
|
+
continue;
|
|
59
|
+
for (const item of statement.exportClause.elements)
|
|
60
|
+
if (item.name.text === name) {
|
|
61
|
+
matches.push({ origin: (item.propertyName ?? item.name).text, module });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (wildcard || direct + matches.length !== 1)
|
|
65
|
+
return false;
|
|
66
|
+
if (BOUNDS_MODULE.test(file.replaceAll('\\', '/')))
|
|
67
|
+
return ownsGenerated(file) && direct === 1 && matches.length === 0;
|
|
68
|
+
const match = matches[0];
|
|
69
|
+
if (direct || !match || !match.module?.startsWith('.'))
|
|
70
|
+
return false;
|
|
71
|
+
return isBoundsExport(ts, resolve(dirname(file), match.module), match.origin, ownsGenerated, new Set([...seen, key]));
|
|
72
|
+
}
|
|
@@ -243,7 +243,7 @@ function runHfsMachine(config, context, paths, injectedTypeScript, base, fast, v
|
|
|
243
243
|
return hfsChecks;
|
|
244
244
|
const graph = buildHfsGraph(config, hfsContext);
|
|
245
245
|
const input = { config, context: hfsContext, graph, base };
|
|
246
|
-
const runs = { tiers: () => checkTiers(graph), reachability: () => checkReachability(input), deadExports: () => checkDeadExports(input),
|
|
246
|
+
const runs = { tiers: () => checkTiers(graph, hfsContext.ts), reachability: () => checkReachability(input), deadExports: () => checkDeadExports(input),
|
|
247
247
|
requiredFiles: () => checkRequiredFiles(input), clones: () => checkClones(input), symbols: () => checkSymbols(input), docLanguage: () => checkDocLanguage(input) };
|
|
248
248
|
if (graph.profile === 'be')
|
|
249
249
|
for (const [name, [check]] of Object.entries(BACKEND_MACHINE))
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { TypeScriptApi } from './config.mjs';
|
|
1
2
|
import type { ArchitectureViolation, HfsGraph } from './hfs-graph.mjs';
|
|
2
3
|
/**
|
|
3
4
|
* HFS checks 1 and 2 (knowledge/hfs/slots.yaml `tiers`):
|
|
@@ -14,7 +15,7 @@ import type { ArchitectureViolation, HfsGraph } from './hfs-graph.mjs';
|
|
|
14
15
|
* Importing inside one owner is always allowed; the public-entry rule stays with owners.mjs (ARCH_OWNER_EXPORT_BYPASS).
|
|
15
16
|
*/
|
|
16
17
|
export declare const TIER_RULE_IDS: string[];
|
|
17
|
-
export declare function checkTiers(graph: HfsGraph): {
|
|
18
|
+
export declare function checkTiers(graph: HfsGraph, ts: TypeScriptApi): {
|
|
18
19
|
violations: ArchitectureViolation[];
|
|
19
20
|
coverage: {
|
|
20
21
|
tierDirection: number;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { byCodeUnit } from '../../lib/list.mjs';
|
|
2
|
+
import { dirname, relative } from 'node:path';
|
|
3
|
+
import { isBoundsExport } from './contract-bounds.mjs';
|
|
2
4
|
import { registryListImport, registrySlot } from './entity-registry.mjs';
|
|
3
5
|
/**
|
|
4
6
|
* HFS checks 1 and 2 (knowledge/hfs/slots.yaml `tiers`):
|
|
@@ -139,14 +141,53 @@ function reportReasonedTierViolation(graph, edge, answer, tierRule, counts, viol
|
|
|
139
141
|
message,
|
|
140
142
|
});
|
|
141
143
|
}
|
|
142
|
-
|
|
144
|
+
/** A public API entry may expose generated contract data without exposing operations to a hook. */
|
|
145
|
+
function contractMetadataEdge(graph, ts, edge, verdict) {
|
|
146
|
+
if (graph.profile !== 'fe' || verdict.reason !== 'tierDirection')
|
|
147
|
+
return false;
|
|
148
|
+
const from = graph.files.get(edge.from);
|
|
149
|
+
const to = graph.files.get(edge.to);
|
|
150
|
+
if (from?.tier !== 'hooks' || to?.tier !== 'transport' || to.slot !== 'fe.modules.api' || !to.owner || to.owner.slot !== 'fe.modules' || to.owner.bindings.capability !== 'api')
|
|
151
|
+
return false;
|
|
152
|
+
// The tier verdict precedes private-entry checks, so prove both app isolation and the owner entry here.
|
|
153
|
+
const fromApp = graph.resolver.classifyPath(edge.from).bindings?.app;
|
|
154
|
+
const toApp = graph.resolver.classifyPath(edge.to).bindings?.app;
|
|
155
|
+
if (!fromApp || fromApp !== toApp || edge.to !== `${to.owner.root}/index.ts`)
|
|
156
|
+
return false;
|
|
157
|
+
const declaration = edge.edge.declaration;
|
|
158
|
+
let names;
|
|
159
|
+
if (ts.isImportDeclaration(declaration)) {
|
|
160
|
+
const clause = declaration.importClause;
|
|
161
|
+
if (!clause || clause.name || !clause.namedBindings || !ts.isNamedImports(clause.namedBindings))
|
|
162
|
+
return false;
|
|
163
|
+
names = clause.namedBindings.elements.map(item => (item.propertyName ?? item.name).text);
|
|
164
|
+
}
|
|
165
|
+
else if (ts.isExportDeclaration(declaration)) {
|
|
166
|
+
if (!declaration.exportClause || !ts.isNamedExports(declaration.exportClause))
|
|
167
|
+
return false;
|
|
168
|
+
names = declaration.exportClause.elements.map(item => (item.propertyName ?? item.name).text);
|
|
169
|
+
}
|
|
170
|
+
else
|
|
171
|
+
return false;
|
|
172
|
+
const apiOwner = to.owner;
|
|
173
|
+
const dirnameOfEntry = dirname(to.abs);
|
|
174
|
+
const ownsGenerated = (file) => {
|
|
175
|
+
const rel = `${apiOwner.root}/${relative(dirnameOfEntry, file).replaceAll('\\', '/')}`;
|
|
176
|
+
const classified = graph.resolver.classifyPath(rel);
|
|
177
|
+
const owner = graph.resolver.ownerOf(rel);
|
|
178
|
+
return classified.status === 'owned' && classified.slot === 'fe.transport.generated' &&
|
|
179
|
+
owner?.slot === apiOwner.slot && owner.root === apiOwner.root;
|
|
180
|
+
};
|
|
181
|
+
return names.length > 0 && names.every(name => isBoundsExport(ts, to.abs, name, ownsGenerated));
|
|
182
|
+
}
|
|
183
|
+
function inspectTierEdge(graph, ts, edge, tierRule, counts, violations, coverage) {
|
|
143
184
|
const verdict = graph.resolver.importAllowed(edge.from, edge.to);
|
|
144
185
|
if (verdict.reason === 'unowned' || verdict.reason?.startsWith('slot')) {
|
|
145
186
|
coverage.unclassifiedEdges += 1;
|
|
146
187
|
return;
|
|
147
188
|
}
|
|
148
189
|
coverage.edgesChecked += 1;
|
|
149
|
-
if (verdict.reason === 'tierDirection' && registryListImport(graph.resolver, edge))
|
|
190
|
+
if (verdict.reason === 'tierDirection' && (registryListImport(graph.resolver, edge) || contractMetadataEdge(graph, ts, edge, verdict)))
|
|
150
191
|
return;
|
|
151
192
|
reportReasonedTierViolation(graph, edge, verdict, tierRule, counts, violations);
|
|
152
193
|
}
|
|
@@ -197,14 +238,14 @@ function addOwnerCycleViolations(adjacency, witness, violations) {
|
|
|
197
238
|
}
|
|
198
239
|
return components.length;
|
|
199
240
|
}
|
|
200
|
-
export function checkTiers(graph) {
|
|
241
|
+
export function checkTiers(graph, ts) {
|
|
201
242
|
const { resolver, profile } = graph;
|
|
202
243
|
const violations = [];
|
|
203
244
|
const tierRule = profile === 'be' ? 'BE_TIER_DIRECTION' : 'FE_TIER_DIRECTION';
|
|
204
245
|
const coverage = { edgesChecked: 0, unclassifiedEdges: 0 };
|
|
205
246
|
const counts = { tierDirection: 0, layerOrder: 0, crossApp: 0 };
|
|
206
247
|
for (const edge of graph.edges)
|
|
207
|
-
inspectTierEdge(graph, edge, tierRule, counts, violations, coverage);
|
|
248
|
+
inspectTierEdge(graph, ts, edge, tierRule, counts, violations, coverage);
|
|
208
249
|
// Owner cycles: nodes are owner units, edges the imports between two distinct units.
|
|
209
250
|
const { adjacency, witness } = ownerAdjacency(graph, resolver);
|
|
210
251
|
const cycles = addOwnerCycleViolations(adjacency, witness, violations);
|
|
@@ -45,11 +45,57 @@ export interface ValidCiDeclaration extends CiDeclaration {
|
|
|
45
45
|
}
|
|
46
46
|
/** True when readCiDeclaration found no problem in `declaration`. */
|
|
47
47
|
export declare const isValidCiDeclaration: (declaration: CiDeclaration) => declaration is ValidCiDeclaration;
|
|
48
|
+
/** A private endpoint uses an explicitly linked secret copy; public endpoints use the existing CI variable selector. */
|
|
49
|
+
export declare function serviceHostSecret(service: ParsedDoc): string | null;
|
|
50
|
+
/** Public credential selectors that ordinary child processes have no authority to inherit. */
|
|
51
|
+
export declare function serviceCredentialNames(doc: ParsedDoc): string[];
|
|
52
|
+
/** A project service's retrievable credential, and the explicitly linked CI execution copy. No value is held here. */
|
|
53
|
+
export interface ServiceBinding {
|
|
54
|
+
service: string;
|
|
55
|
+
credential: string;
|
|
56
|
+
name: string;
|
|
57
|
+
repository: string;
|
|
58
|
+
projectKey: string;
|
|
59
|
+
environment: string;
|
|
60
|
+
member: string;
|
|
61
|
+
key: string;
|
|
62
|
+
path: string;
|
|
63
|
+
}
|
|
64
|
+
export interface BindingProblem {
|
|
65
|
+
at: string;
|
|
66
|
+
message: string;
|
|
67
|
+
}
|
|
68
|
+
/** An application runtime declaration or deploy grant selects runtime authority regardless of its CI name. */
|
|
69
|
+
export declare function runtimeGranted(doc: ParsedDoc, environment: string, member: string, key: string): boolean;
|
|
70
|
+
/** Read an actual member path, retaining the legacy dotenv suffix without guessing a plaintext twin. */
|
|
71
|
+
export declare function serviceMemberPath(value: unknown): {
|
|
72
|
+
environment: string;
|
|
73
|
+
member: string;
|
|
74
|
+
path: string;
|
|
75
|
+
} | null;
|
|
76
|
+
/** The selected project's public credential reference, independent of CI: used for a sealed private endpoint too. */
|
|
77
|
+
export declare function projectServiceCredential(doc: ParsedDoc, serviceId: string, credentialId: string, repository: string): ServiceBinding | null;
|
|
78
|
+
/**
|
|
79
|
+
* Positive tooling classification: the selected product project, one explicit CI link, its own declared environment and exact
|
|
80
|
+
* member/key. Names alone, a sealed key alone, or credentials of a host or another project confer no tooling authority.
|
|
81
|
+
*/
|
|
82
|
+
export declare function serviceBindings(doc: ParsedDoc, repository: string): {
|
|
83
|
+
bindings: ServiceBinding[];
|
|
84
|
+
problems: BindingProblem[];
|
|
85
|
+
};
|
|
86
|
+
/** A sealed row belongs to this exact positive binding; a same-named key elsewhere remains unrelated custody. */
|
|
87
|
+
export declare function boundSealedKey(binding: ServiceBinding, sealed: {
|
|
88
|
+
env?: string;
|
|
89
|
+
member?: string;
|
|
90
|
+
key: string;
|
|
91
|
+
file?: string;
|
|
92
|
+
}): boolean;
|
|
48
93
|
/** Each CI name belongs to one class and scope; declarations contain names and public configuration only. */
|
|
49
94
|
export declare function readCiDeclaration(doc: ParsedDoc): CiDeclaration;
|
|
50
95
|
/** Missing quality blocks preserve the existing rendered defaults; disabled services remain explicitly disabled. */
|
|
51
|
-
export declare function qualityServices(doc: ParsedDoc, { example }?: {
|
|
96
|
+
export declare function qualityServices(doc: ParsedDoc, { example, repository }?: {
|
|
52
97
|
example?: boolean;
|
|
98
|
+
repository?: string | undefined;
|
|
53
99
|
}): ParsedDoc;
|
|
54
100
|
/** Read only encrypted envelope key names, never decrypting or returning any sealed value. */
|
|
55
101
|
export declare function sealedKeyNames(root: string, { fs }: {
|
|
@@ -5,6 +5,114 @@ import { parseYaml } from '../../engine/yaml.mjs';
|
|
|
5
5
|
/** True when readCiDeclaration found no problem in `declaration`. */
|
|
6
6
|
export const isValidCiDeclaration = (declaration) => declaration.problems.length === 0;
|
|
7
7
|
const nameOf = (item) => typeof item === 'string' ? item : item?.name;
|
|
8
|
+
/** A private endpoint uses an explicitly linked secret copy; public endpoints use the existing CI variable selector. */
|
|
9
|
+
export function serviceHostSecret(service) {
|
|
10
|
+
const name = service?.ci?.hostVar || 'SONAR_HOST_URL';
|
|
11
|
+
return service?.host?.fromCredential && list(service?.ci?.secrets).some((link) => link?.name === name && link?.credential === service.host.fromCredential) ? name : null;
|
|
12
|
+
}
|
|
13
|
+
/** Public credential selectors that ordinary child processes have no authority to inherit. */
|
|
14
|
+
export function serviceCredentialNames(doc) {
|
|
15
|
+
return [...new Set(Object.values(doc?.services ?? {}).flatMap((service) => [service?.ci?.token,
|
|
16
|
+
...(service?.host?.fromCredential ? [service?.ci?.hostVar] : []), ...list(service?.ci?.secrets).map((entry) => entry?.name),
|
|
17
|
+
...list(service?.credentials).flatMap((credential) => [credential?.env, credential?.key])]).filter((name) => typeof name === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/u.test(name)))];
|
|
18
|
+
}
|
|
19
|
+
const OPERATOR_CREDENTIAL = /(?:^|-)(?:admin|operator|provision|provisioning|global)(?:-|$)/iu;
|
|
20
|
+
/** An application runtime declaration or deploy grant selects runtime authority regardless of its CI name. */
|
|
21
|
+
export function runtimeGranted(doc, environment, member, key) {
|
|
22
|
+
const spec = doc?.environments?.[environment];
|
|
23
|
+
if (list(spec?.secrets).some((entry) => (entry?.member ?? /^secrets\/([a-z0-9-]+)/u.exec(entry?.where ?? '')?.[1] ?? entry?.name) === member
|
|
24
|
+
&& (entry.key === key || (entry.key === undefined && (entry.keys === undefined || list(entry.keys).includes(key))))))
|
|
25
|
+
return true;
|
|
26
|
+
return list(spec?.deploy?.secrets?.grants).some((grant) => typeof grant?.service === 'string'
|
|
27
|
+
&& list(grant.from).some((source) => source?.member === member && (source.keys === undefined || list(source.keys).includes(key))));
|
|
28
|
+
}
|
|
29
|
+
/** Read an actual member path, retaining the legacy dotenv suffix without guessing a plaintext twin. */
|
|
30
|
+
export function serviceMemberPath(value) {
|
|
31
|
+
if (typeof value !== 'string')
|
|
32
|
+
return null;
|
|
33
|
+
const match = /^\.starcistacks\/([a-z][a-z0-9-]*)\/secrets\/([a-z][a-z0-9-]*)\.enc(?:\.yaml)?$/u.exec(value);
|
|
34
|
+
return match ? { environment: match[1], member: match[2], path: value } : null;
|
|
35
|
+
}
|
|
36
|
+
/** The selected project's public credential reference, independent of CI: used for a sealed private endpoint too. */
|
|
37
|
+
export function projectServiceCredential(doc, serviceId, credentialId, repository) {
|
|
38
|
+
const service = doc?.services?.[serviceId], projects = list(service?.projects);
|
|
39
|
+
const selected = projects.filter((project) => project?.repository === repository);
|
|
40
|
+
const credentials = list(service?.credentials).filter((item) => item?.id === credentialId);
|
|
41
|
+
const credential = credentials.length === 1 ? credentials[0] : null, member = serviceMemberPath(credential?.custody?.path);
|
|
42
|
+
if (selected.length !== 1 || typeof selected[0]?.key !== 'string' || !selected[0].key || credential?.project !== selected[0].key
|
|
43
|
+
|| projects.filter((project) => project?.key === credential?.project).length !== 1
|
|
44
|
+
|| !member || credential.custody.repository !== repository || !Object.hasOwn(doc?.environments ?? {}, member.environment)
|
|
45
|
+
|| typeof credential.key !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/u.test(credential.key)
|
|
46
|
+
|| OPERATOR_CREDENTIAL.test(credentialId)
|
|
47
|
+
|| runtimeGranted(doc, member.environment, member.member, credential.key))
|
|
48
|
+
return null;
|
|
49
|
+
// A tooling member carries one authority: another project's or the operator's credential cannot share its envelope.
|
|
50
|
+
if (list(service.credentials).some((item) => item !== credential && item?.custody?.repository === repository
|
|
51
|
+
&& item?.custody?.path === member.path && (item?.project !== credential.project || OPERATOR_CREDENTIAL.test(String(item?.id)))))
|
|
52
|
+
return null;
|
|
53
|
+
return { service: serviceId, credential: credentialId, name: '', repository, projectKey: selected[0].key,
|
|
54
|
+
environment: member.environment, member: member.member, key: credential.key, path: member.path };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Positive tooling classification: the selected product project, one explicit CI link, its own declared environment and exact
|
|
58
|
+
* member/key. Names alone, a sealed key alone, or credentials of a host or another project confer no tooling authority.
|
|
59
|
+
*/
|
|
60
|
+
export function serviceBindings(doc, repository) {
|
|
61
|
+
const bindings = [], problems = [];
|
|
62
|
+
const bad = (at, message) => { problems.push({ at, message }); };
|
|
63
|
+
for (const [id, service] of Object.entries(doc?.services ?? {})) {
|
|
64
|
+
const links = list(service?.ci?.secrets);
|
|
65
|
+
const at = `services.${id}`;
|
|
66
|
+
if (id === 'codecov' && service?.auth === 'oidc' && (service.ci?.token || links.length || list(service.credentials).length))
|
|
67
|
+
bad(at, 'OIDC Codecov declares no stored token, credential or CI secret copy');
|
|
68
|
+
if (id === 'sonar' && service?.mode && service.mode !== 'disabled' && service.ci?.wiring === 'required'
|
|
69
|
+
&& (!links.length || typeof service.ci.token !== 'string'))
|
|
70
|
+
bad(at, 'required product Sonar declares its analysis credential and CI binding to a sonar.enc.yaml member');
|
|
71
|
+
if (service?.host?.fromCredential && service.ci?.wiring === 'required' && !serviceHostSecret(service))
|
|
72
|
+
bad(`${at}.host.fromCredential`, 'a required private endpoint has an explicit credential-to-CI secret copy selected by ci.hostVar');
|
|
73
|
+
if (!links.length)
|
|
74
|
+
continue;
|
|
75
|
+
if (service.ci.token && !links.some((link) => link?.name === service.ci.token)) {
|
|
76
|
+
bad(`${at}.ci.token`, 'ci.token must select an explicit credential copy');
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
for (const link of links) {
|
|
80
|
+
const where = `${at}.ci.secrets`;
|
|
81
|
+
if (service?.mode === 'disabled' || service?.auth !== 'token') {
|
|
82
|
+
bad(where, 'credential copies require an enabled token-authenticated service');
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const references = list(service.credentials).filter((credential) => credential?.id === link?.credential);
|
|
86
|
+
const other = references.length === 1 && list(service.projects).find((project) => project?.key === references[0]?.project);
|
|
87
|
+
if (other && other.repository !== repository)
|
|
88
|
+
continue;
|
|
89
|
+
const binding = typeof link?.credential === 'string' ? projectServiceCredential(doc, id, link.credential, repository) : null;
|
|
90
|
+
if (!binding || typeof link?.name !== 'string' || !/^[A-Z][A-Z0-9_]*$/u.test(link.name)
|
|
91
|
+
|| links.filter((item) => item?.name === link.name).length !== 1) {
|
|
92
|
+
bad(where, 'each unique CI copy selects one project credential in its own declared environment/member/key, separate from operator and app runtime authority');
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (id === 'sonar' && link.name === service.ci.token && binding.path !== `.starcistacks/${binding.environment}/secrets/sonar.enc.yaml`) {
|
|
96
|
+
bad(where, 'Sonar analysis custody is the product environment\'s sonar.enc.yaml member');
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
bindings.push({ ...binding, name: link.name });
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
for (const binding of [...bindings]) {
|
|
103
|
+
if (bindings.filter((item) => item.name === binding.name).length > 1)
|
|
104
|
+
bad('services', `CI secret ${binding.name} has ambiguous service custody`);
|
|
105
|
+
}
|
|
106
|
+
const sonar = doc?.services?.sonar;
|
|
107
|
+
if (sonar?.mode && sonar.mode !== 'disabled' && sonar.ci?.wiring === 'required'
|
|
108
|
+
&& !bindings.some((binding) => binding.service === 'sonar' && binding.name === sonar.ci.token))
|
|
109
|
+
bad('services.sonar', 'required Sonar analysis has no credential binding for the selected product project');
|
|
110
|
+
return { bindings: bindings.filter((binding) => bindings.filter((item) => item.name === binding.name).length === 1), problems };
|
|
111
|
+
}
|
|
112
|
+
/** A sealed row belongs to this exact positive binding; a same-named key elsewhere remains unrelated custody. */
|
|
113
|
+
export function boundSealedKey(binding, sealed) {
|
|
114
|
+
return binding.environment === sealed.env && binding.member === sealed.member && binding.key === sealed.key && binding.path === sealed.file;
|
|
115
|
+
}
|
|
8
116
|
/** A public CI variable declares either a manifest value or an owner-supplied value, never both. */
|
|
9
117
|
function variableProblems(item, at, invalid) {
|
|
10
118
|
const hasValue = item != null && Object.hasOwn(item, 'value');
|
|
@@ -44,14 +152,18 @@ export function readCiDeclaration(doc) {
|
|
|
44
152
|
return { secrets, vars, dependabotSecrets, problems };
|
|
45
153
|
}
|
|
46
154
|
/** Missing quality blocks preserve the existing rendered defaults; disabled services remain explicitly disabled. */
|
|
47
|
-
export function qualityServices(doc, { example = false } = {}) {
|
|
155
|
+
export function qualityServices(doc, { example = false, repository } = {}) {
|
|
48
156
|
const services = doc?.services ?? {}, sonar = services.sonar, codecov = services.codecov ?? {};
|
|
49
157
|
const dependabot = services.dependabot ?? {}, audit = services.audit ?? {};
|
|
50
158
|
return {
|
|
51
159
|
sonar: !sonar || sonar.mode === 'disabled' ? null : {
|
|
52
160
|
provider: sonar.provider, mode: sonar.mode, host: sonar.host?.public || sonar.host?.local || null,
|
|
161
|
+
hostCredential: sonar.host?.fromCredential ?? null,
|
|
162
|
+
hostSecret: serviceHostSecret(sonar),
|
|
53
163
|
organization: sonar.organization ?? null,
|
|
54
|
-
organizationVar: sonar.ci?.organizationVar ?? (sonar.provider === 'sonarcloud' && (example || list(sonar.runtimeSecrets).length > 0) && !list(sonar.credentials).length ? 'SONAR_ORGANIZATION' : null),
|
|
164
|
+
organizationVar: sonar.ci?.organizationVar ?? (sonar.provider === 'sonarcloud' && (example || list(sonar.runtimeSecrets).length > 0) && !list(sonar.credentials).length ? 'SONAR_ORGANIZATION' : null),
|
|
165
|
+
projectKey: (repository ? list(sonar.projects).find((project) => project?.repository === repository) : sonar.projects?.[0])?.key ?? null,
|
|
166
|
+
credentials: sonar.credentials ?? [], projects: sonar.projects ?? [], auth: sonar.auth, ci: sonar.ci ?? {},
|
|
55
167
|
qualityGate: sonar.qualityGate ?? (example ? 'starci-quality-lite' : 'starci-quality'), wiring: sonar.ci?.wiring ?? 'required',
|
|
56
168
|
token: sonar.ci?.token || nameOf(sonar.ci?.secrets?.[0]) || 'SONAR_TOKEN', hostVar: sonar.ci?.hostVar || 'SONAR_HOST_URL',
|
|
57
169
|
},
|
|
@@ -115,7 +227,7 @@ export function ciNamesOfServices(doc) {
|
|
|
115
227
|
if (ci.token)
|
|
116
228
|
out.push({ service, name: ci.token, kind: 'secret' });
|
|
117
229
|
if (ci.hostVar)
|
|
118
|
-
out.push({ service, name: ci.hostVar, kind: 'var' });
|
|
230
|
+
out.push({ service, name: ci.hostVar, kind: entry?.host?.fromCredential ? 'secret' : 'var' });
|
|
119
231
|
if (ci.organizationVar)
|
|
120
232
|
out.push({ service, name: ci.organizationVar, kind: 'var' });
|
|
121
233
|
for (const item of list(ci.secrets))
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/** Identity queries need repository configuration, never age identities or selected service connection credentials. */
|
|
2
|
+
export declare function identityQueryEnvironment(parent: NodeJS.ProcessEnv, privateNames?: readonly string[]): NodeJS.ProcessEnv;
|
|
1
3
|
/** What git says about a folder. */
|
|
2
4
|
export interface RepoIdentity {
|
|
3
5
|
inWorkTree: boolean;
|
|
@@ -10,6 +12,8 @@ export interface RepoIdentity {
|
|
|
10
12
|
* name outside Git, else the folder name. A folder inside a larger repository (a nested product, an example) is named by
|
|
11
13
|
* its own folder.
|
|
12
14
|
*/
|
|
13
|
-
export declare function repositoryName(root: string
|
|
15
|
+
export declare function repositoryName(root: string, { env }?: {
|
|
16
|
+
env?: NodeJS.ProcessEnv;
|
|
17
|
+
}): string;
|
|
14
18
|
/** The folder whose siblings are this repository's sibling repositories: the main checkout for a worktree, else root. */
|
|
15
19
|
export declare function repositoryHome(root: string): string;
|
|
@@ -5,10 +5,15 @@ import fs from 'node:fs';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { revParseQuery } from '../api/git/rev-parse-query.mjs';
|
|
7
7
|
import { remote as gitRemote } from '../api/git/remote.mjs';
|
|
8
|
-
import { gitOutputOf } from '../lib/git.mjs';
|
|
8
|
+
import { gitOutputOf, withoutGitLocalEnv } from '../lib/git.mjs';
|
|
9
9
|
import { trimTrailingSlashes } from './trailing-slashes.mjs';
|
|
10
10
|
/** One git call (a scripts/api/git call file) in `root`: its trimmed stdout; throws unless git exits 0. */
|
|
11
|
-
const git = (call, root, args) => gitOutputOf(call(args, { cwd: root })).trim(); // gitOutputOf threw unless git exited 0, whose stdout is text
|
|
11
|
+
const git = (call, root, args, env) => gitOutputOf(call(args, { cwd: root, env: identityQueryEnvironment(env) })).trim(); // gitOutputOf threw unless git exited 0, whose stdout is text
|
|
12
|
+
/** Identity queries need repository configuration, never age identities or selected service connection credentials. */
|
|
13
|
+
export function identityQueryEnvironment(parent, privateNames = []) {
|
|
14
|
+
const selected = new Set(privateNames.map((name) => name.toUpperCase()));
|
|
15
|
+
return Object.fromEntries(Object.entries(withoutGitLocalEnv(parent)).filter(([name]) => !selected.has(name.toUpperCase()) && !/^(?:SOPS_AGE_KEY(?:_FILE)?|SONAR_.*)$/iu.test(name)));
|
|
16
|
+
}
|
|
12
17
|
const real = (p) => { try {
|
|
13
18
|
return fs.realpathSync(p);
|
|
14
19
|
}
|
|
@@ -26,20 +31,20 @@ catch {
|
|
|
26
31
|
return null;
|
|
27
32
|
} };
|
|
28
33
|
/** { repositoryRoot, inWorkTree, home } for `root`: home is the main checkout folder when root is a repository top level. */
|
|
29
|
-
function identityOf(root) {
|
|
34
|
+
function identityOf(root, env = process.env) {
|
|
30
35
|
const state = stateOf(root);
|
|
31
36
|
if (state === null)
|
|
32
|
-
return askGit(root);
|
|
37
|
+
return askGit(root, env);
|
|
33
38
|
if (!known.has(state))
|
|
34
|
-
known.set(state, askGit(root));
|
|
39
|
+
known.set(state, askGit(root, env));
|
|
35
40
|
return known.get(state);
|
|
36
41
|
}
|
|
37
|
-
function askGit(root) {
|
|
42
|
+
function askGit(root, env) {
|
|
38
43
|
let inWorkTree = false, repositoryRoot = false, home = null;
|
|
39
44
|
try {
|
|
40
45
|
// Identity comes from Git only when root IS a repository (or worktree) top level, never a folder inside one.
|
|
41
46
|
// One git call answers both questions: the top level on the first line, the common git dir on the second.
|
|
42
|
-
const [top, commonDir] = git(revParseQuery, root, ['--show-toplevel', '--git-common-dir']).split(String.fromCodePoint(10)).map((line) => line.trim());
|
|
47
|
+
const [top, commonDir] = git(revParseQuery, root, ['--show-toplevel', '--git-common-dir'], env).split(String.fromCodePoint(10)).map((line) => line.trim());
|
|
43
48
|
repositoryRoot = real(top) === real(root);
|
|
44
49
|
inWorkTree = true;
|
|
45
50
|
const common = path.resolve(root, commonDir);
|
|
@@ -55,11 +60,11 @@ function askGit(root) {
|
|
|
55
60
|
* name outside Git, else the folder name. A folder inside a larger repository (a nested product, an example) is named by
|
|
56
61
|
* its own folder.
|
|
57
62
|
*/
|
|
58
|
-
export function repositoryName(root) {
|
|
59
|
-
const { inWorkTree, repositoryRoot, home } = identityOf(root);
|
|
63
|
+
export function repositoryName(root, { env = process.env } = {}) {
|
|
64
|
+
const { inWorkTree, repositoryRoot, home } = identityOf(root, env);
|
|
60
65
|
if (repositoryRoot) {
|
|
61
66
|
try {
|
|
62
|
-
const remote = trimTrailingSlashes(git(gitRemote, root, ['get-url', 'origin'])).replace(GIT_SUFFIX, '');
|
|
67
|
+
const remote = trimTrailingSlashes(git(gitRemote, root, ['get-url', 'origin'], env)).replace(GIT_SUFFIX, '');
|
|
63
68
|
const name = remote.split(/[/:]/u).pop();
|
|
64
69
|
if (name)
|
|
65
70
|
return name;
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import { list } from '../../lib/list.mjs';
|
|
4
4
|
import { findStackDeclaration, STACK_ROOT } from '../../lib/stack-declaration.mjs';
|
|
5
|
-
import { readCiDeclaration, sealedKeyNames, workflowReferences, ciNamesOfServices } from '../ci-declaration.mjs';
|
|
5
|
+
import { readCiDeclaration, sealedKeyNames, workflowReferences, ciNamesOfServices, serviceBindings, boundSealedKey, runtimeGranted, serviceCredentialNames } from '../ci-declaration.mjs';
|
|
6
|
+
import { repositoryName, identityQueryEnvironment } from '../repo-identity.mjs';
|
|
6
7
|
import { found } from './read.mjs';
|
|
7
8
|
const DECLARATION = `${STACK_ROOT}/application-stacks.yaml`;
|
|
8
9
|
const named = (entry) => typeof entry.name === 'string' && /^[A-Z][A-Z0-9_]*$/.test(entry.name);
|
|
@@ -10,38 +11,18 @@ const identity = (kind, name) => `${kind}:${name}`;
|
|
|
10
11
|
const atCi = (section) => `${DECLARATION}#ci.${section}`;
|
|
11
12
|
/** The variables the managed workflows themselves read; an app declares none of them. STARCI_DEPLOY_PAUSED is the brake of the automatic deploy, set per GitHub environment by the owner. */
|
|
12
13
|
const MANAGED_VARIABLES = ['STARCI_DEPLOY_PAUSED'];
|
|
13
|
-
/** A CI
|
|
14
|
-
function sealedFindings(ci, sealed) {
|
|
14
|
+
/** A sealed CI copy has exact service/project custody; every other sealed CI key remains refused. */
|
|
15
|
+
function sealedFindings(ci, sealed, bindings) {
|
|
15
16
|
const names = new Set([...ci.secrets, ...ci.dependabotSecrets].filter(named).map((entry) => entry.name));
|
|
16
|
-
|
|
17
|
+
const copies = bindings.filter((binding) => names.has(binding.name));
|
|
18
|
+
return sealed.filter((entry) => (names.has(entry.key) || copies.some((binding) => binding.key === entry.key))
|
|
19
|
+
&& !copies.some((binding) => boundSealedKey(binding, entry))).map(({ key, file }) => found('CI_SECRET_SEALED', file, `${key} is a CI secret sealed in ${file} without an exact project service binding; app runtime keys stay in their members and tooling copies name services.<id>.ci.secrets[].credential`));
|
|
17
20
|
}
|
|
18
|
-
/**
|
|
19
|
-
function sourceRuntimeFindings(source, grant, environment, { sealed, names, findings, seen }) {
|
|
20
|
-
for (const entry of sealed) {
|
|
21
|
-
if (entry.env !== environment || entry.member !== source?.member || !names.has(entry.key))
|
|
22
|
-
continue;
|
|
23
|
-
if (source.keys !== undefined && !list(source.keys).includes(entry.key))
|
|
24
|
-
continue;
|
|
25
|
-
const id = `${entry.file}:${grant.service}:${entry.key}`;
|
|
26
|
-
if (seen.has(id))
|
|
27
|
-
continue;
|
|
28
|
-
seen.add(id);
|
|
29
|
-
findings.push(found('APP_SECRET_IN_CI', entry.file, `${entry.key} is read by the ${grant.service} container at runtime through the ${environment} deploy grant; keep it in the .enc and drop it from ci`));
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
/** Only keys actually selected by a grant, in that grant's environment and member, are runtime consumers. */
|
|
21
|
+
/** Runtime declarations and grants select exact environment/member/key consumers; an unselected key is unrelated. */
|
|
33
22
|
function runtimeFindings(doc, ci, sealed) {
|
|
34
23
|
const names = new Set([...ci.secrets, ...ci.vars].filter(named).map((entry) => entry.name));
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
for (const grant of list(spec?.deploy?.secrets?.grants)) {
|
|
38
|
-
if (typeof grant?.service !== 'string')
|
|
39
|
-
continue;
|
|
40
|
-
for (const source of list(grant.from))
|
|
41
|
-
sourceRuntimeFindings(source, grant, environment, { sealed, names, findings, seen });
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
return findings;
|
|
24
|
+
return sealed.filter((entry) => names.has(entry.key) && runtimeGranted(doc, entry.env, entry.member, entry.key))
|
|
25
|
+
.map((entry) => found('APP_SECRET_IN_CI', entry.file, `${entry.key} is selected by an app runtime declaration or deploy grant in ${entry.env}; keep it sealed and drop it from ci`));
|
|
45
26
|
}
|
|
46
27
|
/** Workflow reads are class-sensitive; legacy lists remain readable, but an absent ci block always signals migration once per name. */
|
|
47
28
|
function workflowFindings(doc, declared, references) {
|
|
@@ -50,7 +31,7 @@ function workflowFindings(doc, declared, references) {
|
|
|
50
31
|
if (hasCi) {
|
|
51
32
|
for (const entry of ciNamesOfServices(doc).filter(named)) {
|
|
52
33
|
const legacy = list(doc.services?.[entry.service]?.ci?.[`${entry.kind}s`]);
|
|
53
|
-
if (legacy.some((item) => (typeof item === 'string' ? item : item?.name) === entry.name))
|
|
34
|
+
if (legacy.some((item) => (typeof item === 'string' ? item : !item?.credential ? item?.name : null) === entry.name))
|
|
54
35
|
allowed.add(identity(`${entry.kind}s`, entry.name));
|
|
55
36
|
}
|
|
56
37
|
}
|
|
@@ -82,7 +63,11 @@ function driftFindings(doc, ci, references) {
|
|
|
82
63
|
}
|
|
83
64
|
const names = new Set(declared.map(({ kind, name }) => identity(kind, name)));
|
|
84
65
|
for (const [service, entry] of Object.entries(doc.services ?? {})) {
|
|
85
|
-
for (const
|
|
66
|
+
for (const binding of list(entry?.ci?.secrets)) {
|
|
67
|
+
if (typeof binding?.credential === 'string' && !names.has(identity('secrets', binding.name)))
|
|
68
|
+
findings.push(found('CI_DECLARATION_DRIFT', atCi('secrets'), `services.${service}.ci.secrets names ${binding.name}, which is not declared in ci.secrets`));
|
|
69
|
+
}
|
|
70
|
+
for (const [field, kind] of [['token', 'secrets'], ['hostVar', entry?.host?.fromCredential ? 'secrets' : 'vars']]) {
|
|
86
71
|
const name = entry?.ci?.[field];
|
|
87
72
|
if (typeof name !== 'string' || names.has(identity(kind, name)))
|
|
88
73
|
continue;
|
|
@@ -99,7 +84,11 @@ export function ciCustodyFindings({ repoRoot, repo, isRoot }) {
|
|
|
99
84
|
if (!doc)
|
|
100
85
|
return [];
|
|
101
86
|
const ci = readCiDeclaration(doc);
|
|
87
|
+
const selected = serviceBindings(doc, repositoryName(repoRoot, { env: identityQueryEnvironment(process.env, serviceCredentialNames(doc)) }));
|
|
102
88
|
const sealed = sealedKeyNames(repoRoot, { fs });
|
|
103
89
|
const references = workflowReferences(repoRoot, { fs });
|
|
104
|
-
|
|
90
|
+
const missing = selected.bindings.filter((binding) => !sealed.some((entry) => boundSealedKey(binding, entry)))
|
|
91
|
+
.map((binding) => found('CI_SECRET_SEALED', binding.path, 'the selected service credential member/key is not sealed in this product custody'));
|
|
92
|
+
return [...selected.problems.map((problem) => found('CI_SECRET_SEALED', `${DECLARATION}#${problem.at}`, problem.message)), ...missing,
|
|
93
|
+
...sealedFindings(ci, sealed, selected.bindings), ...runtimeFindings(doc, ci, sealed), ...driftFindings(doc, ci, references)];
|
|
105
94
|
}
|