Skip to content

Commit 853e874

Browse files
committed
feat: support audit-scoped template secrets
Signed-off-by: zebbern <185730623+zebbern@users.noreply.github.com>
1 parent 73357b4 commit 853e874

3 files changed

Lines changed: 121 additions & 5 deletions

File tree

scripts/__tests__/template-library-live-audit-utils.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
summarizeTemplateValidationLedgerFreshness,
1111
renderTemplateAuditMarkdown,
1212
parseTemplateAuditCliOptions,
13+
resolveTemplateAuditSecretMappings,
1314
shouldSkipTemplateValidation,
1415
summarizeNodeIoNode,
1516
upsertTemplateValidationLedger,
@@ -28,6 +29,43 @@ describe('template library live audit helpers', () => {
2829
expect(inputs['GitHub Repo Dependency CVE Triage']).not.toHaveProperty('manifestPaths');
2930
});
3031

32+
it('includes a bounded Discord report live fixture input', () => {
33+
const inputs = createTemplateLiveAuditInputs();
34+
35+
expect(inputs['Security Scan Discord Report']).toEqual({
36+
imageRef: 'alpine:3.18',
37+
});
38+
});
39+
40+
it('resolves audit secret mappings from explicit JSON and per-secret env variables', () => {
41+
const resolved = resolveTemplateAuditSecretMappings(
42+
['DISCORD_WEBHOOK_URL', 'API-TOKEN', 'MISSING_SECRET'],
43+
{
44+
TEMPLATE_AUDIT_SECRET_MAPPINGS: JSON.stringify({
45+
DISCORD_WEBHOOK_URL: 'https://discord.com/api/webhooks/example',
46+
}),
47+
TEMPLATE_AUDIT_SECRET_API_TOKEN: 'token-from-env',
48+
},
49+
);
50+
51+
expect(resolved).toEqual({
52+
secretMappings: {
53+
DISCORD_WEBHOOK_URL: 'https://discord.com/api/webhooks/example',
54+
'API-TOKEN': 'token-from-env',
55+
},
56+
providedSecretNames: ['DISCORD_WEBHOOK_URL', 'API-TOKEN'],
57+
missingSecretNames: ['MISSING_SECRET'],
58+
});
59+
});
60+
61+
it('rejects malformed audit secret JSON instead of silently skipping live validation', () => {
62+
expect(() =>
63+
resolveTemplateAuditSecretMappings(['DISCORD_WEBHOOK_URL'], {
64+
TEMPLATE_AUDIT_SECRET_MAPPINGS: '{not-json',
65+
}),
66+
).toThrow('TEMPLATE_AUDIT_SECRET_MAPPINGS must be a JSON object');
67+
});
68+
3169
it('parses cross-platform targeted audit CLI flags with environment fallbacks', () => {
3270
const options = parseTemplateAuditCliOptions(
3371
[

scripts/template-library-live-audit-utils.ts

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,12 @@ export interface TemplateAuditCliOptions {
111111

112112
export type TemplateLiveAuditInputs = Record<string, Record<string, unknown>>;
113113

114+
export interface TemplateAuditSecretResolution {
115+
secretMappings: Record<string, string>;
116+
providedSecretNames: string[];
117+
missingSecretNames: string[];
118+
}
119+
114120
export type TemplateValidationFreshnessStatus =
115121
| 'current'
116122
| 'missing'
@@ -174,12 +180,14 @@ export function createTemplateLiveAuditInputs(): TemplateLiveAuditInputs {
174180
},
175181
'Container Image CVE Triage': {
176182
imageRef: 'alpine:3.18',
177-
deploymentContext: 'Live audit fixture: small public Linux base image for bounded CVE triage.',
183+
deploymentContext:
184+
'Live audit fixture: small public Linux base image for bounded CVE triage.',
178185
authorizationNotes: 'Live audit fixture using a public container image.',
179186
},
180187
'Exposed Service CVE Mapper': {
181188
targets: ['scanme.nmap.org'],
182-
authorizationNotes: 'Live audit fixture: Nmap-provided scan target for bounded service checks.',
189+
authorizationNotes:
190+
'Live audit fixture: Nmap-provided scan target for bounded service checks.',
183191
},
184192
'GitHub Repo Dependency CVE Triage': {
185193
repositoryUrl: 'https://github.com/OWASP/NodeGoat',
@@ -189,7 +197,8 @@ export function createTemplateLiveAuditInputs(): TemplateLiveAuditInputs {
189197
},
190198
'NPM Dependency CVE Hunt': {
191199
packageSpecs: ['lodash@4.17.20', 'minimist@0.0.8', 'axios@0.21.1'],
192-
researchNotes: 'Live audit fixture using public npm packages with known historical advisories.',
200+
researchNotes:
201+
'Live audit fixture using public npm packages with known historical advisories.',
193202
},
194203
'Passive OSINT Subdomain Expansion': {
195204
domain: 'example.com',
@@ -235,6 +244,61 @@ export function createTemplateLiveAuditInputs(): TemplateLiveAuditInputs {
235244
outOfScopePaths: ['/logout', '/admin/delete'],
236245
scanIntensity: 'safe',
237246
},
247+
'Security Scan Discord Report': {
248+
imageRef: 'alpine:3.18',
249+
},
250+
};
251+
}
252+
253+
function normalizeAuditSecretEnvName(secretName: string): string {
254+
return `TEMPLATE_AUDIT_SECRET_${secretName.replace(/[^a-zA-Z0-9]+/g, '_').toUpperCase()}`;
255+
}
256+
257+
function parseSecretMappingsJson(value: string | undefined): Record<string, string> {
258+
if (!value?.trim()) return {};
259+
260+
try {
261+
const parsed = JSON.parse(value) as unknown;
262+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
263+
throw new Error('not-object');
264+
}
265+
266+
const mappings: Record<string, string> = {};
267+
for (const [key, rawValue] of Object.entries(parsed as Record<string, unknown>)) {
268+
if (typeof rawValue === 'string' && rawValue.trim().length > 0) {
269+
mappings[key] = rawValue;
270+
}
271+
}
272+
return mappings;
273+
} catch {
274+
throw new Error('TEMPLATE_AUDIT_SECRET_MAPPINGS must be a JSON object');
275+
}
276+
}
277+
278+
export function resolveTemplateAuditSecretMappings(
279+
requiredSecretNames: string[],
280+
env: Record<string, string | undefined> = process.env,
281+
): TemplateAuditSecretResolution {
282+
const jsonMappings = parseSecretMappingsJson(env.TEMPLATE_AUDIT_SECRET_MAPPINGS);
283+
const uniqueRequiredNames = Array.from(
284+
new Set(requiredSecretNames.map((name) => name.trim()).filter(Boolean)),
285+
);
286+
const secretMappings: Record<string, string> = {};
287+
288+
for (const secretName of uniqueRequiredNames) {
289+
const jsonValue = jsonMappings[secretName];
290+
const envValue = env[normalizeAuditSecretEnvName(secretName)];
291+
const value =
292+
typeof jsonValue === 'string' && jsonValue.trim().length > 0 ? jsonValue : envValue;
293+
if (typeof value === 'string' && value.trim().length > 0) {
294+
secretMappings[secretName] = value;
295+
}
296+
}
297+
298+
return {
299+
secretMappings,
300+
providedSecretNames: uniqueRequiredNames.filter((name) => Boolean(secretMappings[name])),
301+
missingSecretNames: uniqueRequiredNames.filter((name) => !secretMappings[name]),
238302
};
239303
}
240304

scripts/template-library-live-audit.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
renderTemplateValidationLedgerFreshness,
1212
renderTemplateAuditMarkdown,
1313
parseTemplateAuditCliOptions,
14+
resolveTemplateAuditSecretMappings,
1415
shouldSkipTemplateValidation,
1516
summarizeTemplateValidationLedgerFreshness,
1617
summarizeNodeIoNode,
@@ -312,7 +313,12 @@ function classifyTemplate(
312313
): AuditResult['classification'] {
313314
const runtimeInputs = getRuntimeInputs(seed ?? template);
314315
const requiredSecrets = getRequiredSecretNames(seed ?? template);
315-
if (LIVE_INPUTS[template.name] && requiredSecrets.length === 0) return 'live-run';
316+
const secretResolution = resolveTemplateAuditSecretMappings(requiredSecrets);
317+
const hasAllAuditSecrets =
318+
requiredSecrets.length > 0 && secretResolution.missingSecretNames.length === 0;
319+
const hasLiveInputs = Boolean(LIVE_INPUTS[template.name]);
320+
if (hasLiveInputs && (requiredSecrets.length === 0 || hasAllAuditSecrets)) return 'live-run';
321+
if (hasAllAuditSecrets && runtimeInputs.length === 0) return 'live-run';
316322
if (requiredSecrets.length > 0 && runtimeInputs.length > 0) return 'credential-gated';
317323
if (requiredSecrets.length > 0 && runtimeInputs.length === 0) return 'run-start-probe';
318324
return 'run-start-probe';
@@ -416,9 +422,13 @@ function analyzeRecommendation(
416422
}
417423

418424
if (requiredSecrets.length > 0) {
425+
const missingSecrets = resolveTemplateAuditSecretMappings(requiredSecrets).missingSecretNames;
419426
return {
420427
recommendation: 'review',
421-
rationale: `Credential-gated template requires: ${requiredSecrets.join(', ')}.`,
428+
rationale:
429+
missingSecrets.length > 0
430+
? `Credential-gated template requires explicit audit secret mappings for: ${missingSecrets.join(', ')}.`
431+
: `Credential-gated template requires: ${requiredSecrets.join(', ')}.`,
422432
};
423433
}
424434

@@ -573,6 +583,7 @@ async function auditTemplate(
573583
const classification = classifyTemplate(template, seed);
574584
const components = getComponents(source);
575585
const requiredSecrets = getRequiredSecretNames(source);
586+
const secretResolution = resolveTemplateAuditSecretMappings(requiredSecrets);
576587
const runtimeInputs = getRuntimeInputs(source);
577588
const validationFingerprint = createValidationFingerprint(template, seed, classification);
578589
const prefix = `${sanitizeFileName(template.name)}-${template.id.slice(0, 8)}`;
@@ -616,6 +627,9 @@ async function auditTemplate(
616627
method: 'POST',
617628
body: JSON.stringify({
618629
workflowName: `Template Live Audit - ${template.name} - ${new Date().toISOString()}`,
630+
...(requiredSecrets.length > 0 && secretResolution.missingSecretNames.length === 0
631+
? { secretMappings: secretResolution.secretMappings }
632+
: {}),
619633
}),
620634
});
621635
workflowId = created.workflow?.id;

0 commit comments

Comments
 (0)