compose and authority fixes. finish sprints.

This commit is contained in:
master
2026-02-17 21:59:47 +02:00
parent fb46a927ad
commit 49cdebe2f1
187 changed files with 23189 additions and 1439 deletions

View File

@@ -29,7 +29,7 @@ test.describe('Workflow: Navigation Sidebar', () => {
// Verify nav links exist (at least some expected labels)
const navText = await nav.first().innerText();
const expectedSections = ['Security', 'Policy', 'Operations'];
const expectedSections = ['Security', 'Evidence', 'Operations', 'Settings'];
for (const section of expectedSections) {
expect(navText.toLowerCase()).toContain(section.toLowerCase());
}

View File

@@ -0,0 +1,443 @@
import fs from 'node:fs';
import path from 'node:path';
import { test, expect } from '../fixtures/auth.fixture';
import { assertPageHasContent, navigateAndWait } from '../helpers/nav.helper';
type RouteTarget = {
path: string;
name: string;
};
type ControlMeta = {
tag: string;
type: string;
role: string;
label: string;
ariaLabel: string;
disabled: boolean;
visible: boolean;
};
type RouteSweepResult = {
route: string;
name: string;
candidates: number;
clicked: number;
skipped: number;
reasons: SweepReason[];
skippedByReason: Record<SkipReason, number>;
failures: string[];
angularErrors: string[];
};
type SweepReason = 'no-controls' | 'guarded' | 'occluded' | 'error-state' | 'clicked';
type SkipReason = 'hidden' | 'disabled' | 'submit' | 'destructive' | 'unlabeled' | 'unsupported' | 'occluded';
const RAW_ROUTES: RouteTarget[] = [
{ path: '/', name: 'Control Plane' },
{ path: '/approvals', name: 'Approvals' },
{ path: '/releases', name: 'Releases' },
{ path: '/deployments', name: 'Deployments' },
{ path: '/security', name: 'Security Overview' },
{ path: '/security/overview', name: 'Security Overview Detail' },
{ path: '/security/findings', name: 'Security Findings' },
{ path: '/security/vulnerabilities', name: 'Security Vulnerabilities' },
{ path: '/security/vex', name: 'Security VEX' },
{ path: '/policy', name: 'Policy' },
{ path: '/policy/packs', name: 'Policy Packs' },
{ path: '/policy/governance', name: 'Policy Governance' },
{ path: '/policy/exceptions', name: 'Policy Exceptions' },
{ path: '/operations', name: 'Operations' },
{ path: '/operations/orchestrator', name: 'Operations Orchestrator' },
{ path: '/operations/scheduler', name: 'Operations Scheduler' },
{ path: '/evidence', name: 'Evidence' },
{ path: '/evidence-packs', name: 'Evidence Packs' },
{ path: '/settings', name: 'Settings' },
{ path: '/console/profile', name: 'Profile' },
{ path: '/admin/trust', name: 'Trust Admin' },
{ path: '/admin/vex-hub', name: 'VEX Hub Admin' },
{ path: '/integrations', name: 'Integration Hub' },
{ path: '/findings', name: 'Findings' },
{ path: '/triage', name: 'Triage Canvas' },
{ path: '/environments', name: 'Environments' },
{ path: '/home', name: 'Home Dashboard Legacy' },
{ path: '/dashboard/sources', name: 'Sources Dashboard' },
{ path: '/console/status', name: 'Console Status' },
{ path: '/console/admin', name: 'Console Admin' },
{ path: '/console/configuration', name: 'Configuration' },
{ path: '/orchestrator', name: 'Orchestrator Legacy' },
{ path: '/orchestrator/jobs', name: 'Orchestrator Jobs' },
{ path: '/orchestrator/quotas', name: 'Orchestrator Quotas' },
{ path: '/release-orchestrator', name: 'Release Orchestrator' },
{ path: '/policy-studio/packs', name: 'Policy Studio Packs' },
{ path: '/concelier/trivy-db-settings', name: 'Trivy DB Settings' },
{ path: '/risk', name: 'Risk Dashboard' },
{ path: '/graph', name: 'Graph Explorer' },
{ path: '/lineage', name: 'Lineage' },
{ path: '/reachability', name: 'Reachability Center' },
{ path: '/timeline', name: 'Timeline' },
{ path: '/evidence-thread', name: 'Evidence Thread' },
{ path: '/vulnerabilities', name: 'Vulnerability Explorer' },
{ path: '/vulnerabilities/triage', name: 'Vulnerability Triage' },
{ path: '/triage/inbox', name: 'Triage Inbox' },
{ path: '/triage/artifacts', name: 'Triage Artifacts' },
{ path: '/triage/quiet-lane', name: 'Quiet Lane' },
{ path: '/triage/ai-recommendations', name: 'AI Recommendations' },
{ path: '/notify', name: 'Notify Panel' },
{ path: '/admin/notifications', name: 'Admin Notifications' },
{ path: '/ops/feeds', name: 'Feed Mirror' },
{ path: '/ops/signals', name: 'Signals Dashboard' },
{ path: '/ops/packs', name: 'Pack Registry Browser' },
{ path: '/admin/policy/governance', name: 'Policy Governance Admin' },
{ path: '/admin/policy/simulation', name: 'Policy Simulation Admin' },
{ path: '/scheduler', name: 'Scheduler' },
{ path: '/exceptions', name: 'Exceptions' },
{ path: '/admin/registries', name: 'Registry Admin' },
{ path: '/admin/issuers', name: 'Issuer Trust' },
{ path: '/ops/scanner', name: 'Scanner Ops' },
{ path: '/ops/offline-kit', name: 'Offline Kit' },
{ path: '/ops/aoc', name: 'AOC Compliance' },
{ path: '/admin/audit', name: 'Audit Log' },
{ path: '/welcome', name: 'Welcome Page' },
{ path: '/ops/quotas', name: 'Quota Dashboard' },
{ path: '/ops/orchestrator/dead-letter', name: 'Dead Letter Queue' },
{ path: '/ops/orchestrator/slo', name: 'SLO Burn Rate' },
{ path: '/ops/health', name: 'Platform Health' },
{ path: '/ops/doctor', name: 'Doctor Diagnostics' },
{ path: '/ops/agents', name: 'Agent Fleet' },
{ path: '/analyze/unknowns', name: 'Unknowns Tracking' },
{ path: '/analyze/patch-map', name: 'Patch Map Explorer' },
{ path: '/ops/binary-index', name: 'Binary Index Ops' },
{ path: '/settings/determinization-config', name: 'Determinization Config' },
{ path: '/sbom-sources', name: 'SBOM Sources' },
{ path: '/sbom/diff', name: 'SBOM Diff' },
{ path: '/deploy/diff', name: 'Deploy Diff' },
{ path: '/vex/timeline', name: 'VEX Timeline' },
{ path: '/workspace/dev', name: 'Developer Workspace' },
{ path: '/workspace/audit', name: 'Auditor Workspace' },
{ path: '/ai/autofix', name: 'AI Autofix' },
{ path: '/ai/chat', name: 'AI Chat' },
{ path: '/ai/chips', name: 'AI Chips' },
{ path: '/ai-runs', name: 'AI Runs' },
{ path: '/change-trace', name: 'Change Trace' },
{ path: '/aoc/verify', name: 'AOC Verification' },
{ path: '/audit/reasons', name: 'Audit Reasons' },
{ path: '/triage/audit-bundles', name: 'Triage Audit Bundles' },
{ path: '/setup', name: 'Setup' },
{ path: '/setup/wizard', name: 'Setup Wizard' },
];
const ROUTES: RouteTarget[] = RAW_ROUTES.filter(
(route, index, all) => all.findIndex((candidate) => candidate.path === route.path) === index
);
const DESTRUCTIVE_LABEL_PATTERN = /\b(delete|remove|purge|destroy|drop|wipe|revoke|terminate|logout|sign out|clear all)\b/i;
const KNOWN_NO_CONTROL_ROUTES = new Set<string>([
'/security/vulnerabilities',
'/policy/governance',
'/evidence',
'/console/profile',
'/triage/inbox',
'/operations',
'/operations/orchestrator',
'/orchestrator',
'/orchestrator/jobs',
'/orchestrator/quotas',
'/admin/audit',
'/workspace/dev',
'/workspace/audit',
'/sbom/diff',
'/deploy/diff',
'/vex/timeline',
'/admin/registries',
'/admin/issuers',
'/admin/policy/governance',
]);
const SWEEP_THRESHOLDS = {
minRouteCoverage: 0.75,
minActionCoverage: 0.40,
maxSkipRatio: 0.75,
maxErrorStateRoutes: 0,
maxUnreviewedNoControlRoutes: 0,
} as const;
const GUARDED_SKIP_REASONS: SkipReason[] = ['hidden', 'disabled', 'submit', 'destructive', 'unlabeled', 'unsupported'];
function addReason(result: RouteSweepResult, reason: SweepReason): void {
if (!result.reasons.includes(reason)) {
result.reasons.push(reason);
}
}
async function describeControl(handle: import('@playwright/test').ElementHandle<SVGElement | HTMLElement>): Promise<ControlMeta | null> {
return handle.evaluate((node) => {
const element = node as HTMLElement;
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
const tag = element.tagName.toLowerCase();
const type = (element.getAttribute('type') ?? '').toLowerCase();
const role = (element.getAttribute('role') ?? '').toLowerCase();
const label = (element.innerText ?? element.textContent ?? '').replace(/\s+/g, ' ').trim();
const ariaLabel = (element.getAttribute('aria-label') ?? element.getAttribute('title') ?? '').replace(/\s+/g, ' ').trim();
const disabled =
element.hasAttribute('disabled') ||
element.getAttribute('aria-disabled') === 'true' ||
(element as HTMLButtonElement).disabled === true;
const visible =
rect.width > 0 &&
rect.height > 0 &&
style.display !== 'none' &&
style.visibility !== 'hidden' &&
style.opacity !== '0';
if (tag !== 'button' && role !== 'button') {
return null;
}
return {
tag,
type,
role,
label,
ariaLabel,
disabled,
visible,
};
});
}
test.describe('Workflow: Exhaustive Button Sweep', () => {
test('clicks visible non-destructive controls on all routes', async ({ authenticatedPage: page }, testInfo) => {
test.setTimeout(45 * 60 * 1000);
const consoleErrors: string[] = [];
const pageErrors: string[] = [];
const results: RouteSweepResult[] = [];
page.on('console', (message) => {
if (message.type() === 'error') {
consoleErrors.push(message.text());
}
});
page.on('pageerror', (error) => {
pageErrors.push(error.message);
});
page.on('dialog', (dialog) => {
void dialog.dismiss();
});
for (const route of ROUTES) {
const routeResult: RouteSweepResult = {
route: route.path,
name: route.name,
candidates: 0,
clicked: 0,
skipped: 0,
reasons: [],
skippedByReason: {
hidden: 0,
disabled: 0,
submit: 0,
destructive: 0,
unlabeled: 0,
unsupported: 0,
occluded: 0,
},
failures: [],
angularErrors: [],
};
const consoleStart = consoleErrors.length;
const pageErrorStart = pageErrors.length;
try {
await navigateAndWait(page, route.path, { timeout: 40_000 });
await page.waitForTimeout(1200);
await assertPageHasContent(page);
} catch (error) {
routeResult.failures.push(`route_load_failed: ${String(error)}`);
addReason(routeResult, 'error-state');
results.push(routeResult);
continue;
}
const root = (await page.locator('#main-content').count()) > 0 ? '#main-content' : 'body';
const controls = await page.locator(`${root} button, ${root} [role="button"]`).elementHandles();
routeResult.candidates = controls.length;
if (controls.length === 0) {
addReason(routeResult, 'no-controls');
}
for (const control of controls) {
let meta: ControlMeta | null = null;
try {
meta = await describeControl(control);
} catch {
routeResult.skipped += 1;
routeResult.skippedByReason.unsupported += 1;
continue;
}
if (!meta) {
routeResult.skipped += 1;
routeResult.skippedByReason.unsupported += 1;
continue;
}
const label = `${meta.label} ${meta.ariaLabel}`.trim();
let skipReason: SkipReason | null = null;
if (!meta.visible) {
skipReason = 'hidden';
} else if (meta.disabled) {
skipReason = 'disabled';
} else if (meta.type === 'submit') {
skipReason = 'submit';
} else if (!label) {
skipReason = 'unlabeled';
} else if (DESTRUCTIVE_LABEL_PATTERN.test(label)) {
skipReason = 'destructive';
}
if (skipReason) {
routeResult.skipped += 1;
routeResult.skippedByReason[skipReason] += 1;
continue;
}
try {
await control.scrollIntoViewIfNeeded();
await control.click({ timeout: 3_000 });
routeResult.clicked += 1;
await page.waitForTimeout(250);
if (!page.url().includes(route.path)) {
await navigateAndWait(page, route.path, { timeout: 30_000 });
await page.waitForTimeout(500);
}
await page.keyboard.press('Escape').catch(() => undefined);
} catch (error) {
const errorText = String(error);
if (errorText.includes('intercepts pointer events')) {
routeResult.skipped += 1;
routeResult.skippedByReason.occluded += 1;
await page.keyboard.press('Escape').catch(() => undefined);
continue;
}
routeResult.failures.push(`click_failed "${label}": ${errorText}`);
await page.keyboard.press('Escape').catch(() => undefined);
if (!page.url().includes(route.path)) {
await navigateAndWait(page, route.path, { timeout: 30_000 }).catch(() => undefined);
}
}
}
const routeConsoleErrors = consoleErrors.slice(consoleStart);
const routePageErrors = pageErrors.slice(pageErrorStart);
routeResult.angularErrors = [...routeConsoleErrors, ...routePageErrors].filter((error) =>
/NG0\d{3,4}/.test(error)
);
if (routeResult.clicked > 0) {
addReason(routeResult, 'clicked');
}
if (routeResult.skippedByReason.occluded > 0) {
addReason(routeResult, 'occluded');
}
const guardedSkips = GUARDED_SKIP_REASONS.reduce((sum, key) => sum + routeResult.skippedByReason[key], 0);
if (guardedSkips > 0) {
addReason(routeResult, 'guarded');
}
if (routeResult.angularErrors.length > 0 || routeResult.failures.length > 0) {
addReason(routeResult, 'error-state');
}
results.push(routeResult);
}
const totalCandidates = results.reduce((sum, item) => sum + item.candidates, 0);
const totalClicked = results.reduce((sum, item) => sum + item.clicked, 0);
const totalSkipped = results.reduce((sum, item) => sum + item.skipped, 0);
const totalFailures = results.reduce((sum, item) => sum + item.failures.length, 0);
const actionableRoutes = results.filter((item) => item.candidates > 0).length;
const clickedRoutes = results.filter((item) => item.clicked > 0).length;
const routeCoverage = actionableRoutes > 0 ? clickedRoutes / actionableRoutes : 1;
const actionCoverage = totalCandidates > 0 ? totalClicked / totalCandidates : 1;
const skipRatio = totalCandidates > 0 ? totalSkipped / totalCandidates : 0;
const zeroCandidateRoutes = results.filter((item) => item.candidates === 0);
const reviewedNoControlRoutes = zeroCandidateRoutes
.filter((item) => KNOWN_NO_CONTROL_ROUTES.has(item.route))
.map((item) => item.route);
const unreviewedNoControlRoutes = zeroCandidateRoutes
.filter((item) => !KNOWN_NO_CONTROL_ROUTES.has(item.route))
.map((item) => item.route);
const errorStateRoutes = results
.filter((item) => item.reasons.includes('error-state'))
.map((item) => item.route);
const failedChecks: string[] = [];
if (routeCoverage < SWEEP_THRESHOLDS.minRouteCoverage) {
failedChecks.push(
`routeCoverage ${routeCoverage.toFixed(3)} below threshold ${SWEEP_THRESHOLDS.minRouteCoverage.toFixed(3)}`
);
}
if (actionCoverage < SWEEP_THRESHOLDS.minActionCoverage) {
failedChecks.push(
`actionCoverage ${actionCoverage.toFixed(3)} below threshold ${SWEEP_THRESHOLDS.minActionCoverage.toFixed(3)}`
);
}
if (skipRatio > SWEEP_THRESHOLDS.maxSkipRatio) {
failedChecks.push(`skipRatio ${skipRatio.toFixed(3)} above threshold ${SWEEP_THRESHOLDS.maxSkipRatio.toFixed(3)}`);
}
if (errorStateRoutes.length > SWEEP_THRESHOLDS.maxErrorStateRoutes) {
failedChecks.push(
`errorStateRoutes ${errorStateRoutes.length} exceeds threshold ${SWEEP_THRESHOLDS.maxErrorStateRoutes}: ${errorStateRoutes.join(
', '
)}`
);
}
if (unreviewedNoControlRoutes.length > SWEEP_THRESHOLDS.maxUnreviewedNoControlRoutes) {
failedChecks.push(
`unreviewedNoControlRoutes ${unreviewedNoControlRoutes.length} exceeds threshold ${SWEEP_THRESHOLDS.maxUnreviewedNoControlRoutes}: ${unreviewedNoControlRoutes.join(
', '
)}`
);
}
const report = {
generatedAtUtc: new Date().toISOString(),
totalRoutes: results.length,
totalCandidates,
totalClicked,
totalSkipped,
totalFailures,
coverage: {
actionableRoutes,
clickedRoutes,
routeCoverage,
actionCoverage,
skipRatio,
zeroCandidateRoutes: zeroCandidateRoutes.length,
reviewedNoControlRoutes,
unreviewedNoControlRoutes,
errorStateRoutes,
thresholds: SWEEP_THRESHOLDS,
failedChecks,
},
routes: results,
};
const outputDirectory = path.resolve(process.cwd(), 'test-results');
fs.mkdirSync(outputDirectory, { recursive: true });
const outputFile = path.join(outputDirectory, 'exhaustive-button-sweep-report.json');
fs.writeFileSync(outputFile, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
await testInfo.attach('exhaustive-button-sweep-report', { path: outputFile, contentType: 'application/json' });
expect(failedChecks, `Coverage gates failed. See ${outputFile}`).toEqual([]);
});
});

View File

@@ -0,0 +1,32 @@
import { expect, test } from '../fixtures/auth.fixture';
test.describe('Workflow: Settings Policy Actions', () => {
test('all policy settings actions navigate to working policy workflows', async ({
authenticatedPage: page,
}) => {
const actions = [
{ label: '+ Create Baseline', expectedPath: '/policy/packs' },
{ label: 'Edit Rules', expectedPath: '/policy/governance' },
{ label: 'Run Simulation', expectedPath: '/policy/simulation' },
{ label: 'Configure Workflow', expectedPath: '/policy/exceptions' },
] as const;
for (const action of actions) {
await page.goto('/settings/policy', {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
const trigger = page.locator(`a:has-text("${action.label}")`).first();
await expect(trigger).toBeVisible({ timeout: 10_000 });
await trigger.click();
await expect
.poll(() => new URL(page.url()).pathname, {
timeout: 15_000,
message: `Expected action "${action.label}" to navigate to ${action.expectedPath}.`,
})
.toContain(action.expectedPath);
}
});
});

View File

@@ -0,0 +1,30 @@
import { expect, test } from '@playwright/test';
test.use({ ignoreHTTPSErrors: true });
test.describe('Workflow: Welcome Sign-In', () => {
test('upgrades insecure HTTP welcome page to HTTPS when Sign In is clicked', async ({
page,
}) => {
await page.goto('http://stella-ops.local/welcome', {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
const signIn = page
.locator(
'button:has-text("Sign in"), button:has-text("Sign In"), a:has-text("Sign in"), a:has-text("Sign In")'
)
.first();
await expect(signIn).toBeVisible({ timeout: 10_000 });
await signIn.click();
await expect
.poll(() => page.url(), {
timeout: 15_000,
message: 'Expected Sign In to navigate from insecure HTTP to secure HTTPS context.',
})
.toContain('https://stella-ops.local');
});
});

View File

@@ -17,7 +17,8 @@ export default defineConfig({
['json', { outputFile: 'e2e-results.json' }],
],
use: {
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'http://stella-ops.local',
baseURL: process.env.PLAYWRIGHT_BASE_URL ?? 'https://stella-ops.local',
ignoreHTTPSErrors: true,
trace: 'retain-on-failure',
screenshot: 'only-on-failure',
video: 'retain-on-failure',

View File

@@ -1,122 +1,122 @@
{
"/envsettings.json": {
"target": "http://127.1.0.5:80",
"target": "http://127.1.0.1:80",
"secure": false
},
"/platform": {
"target": "http://127.1.0.5:80",
"target": "http://127.1.0.1:80",
"secure": false
},
"/api": {
"target": "http://127.1.0.5:80",
"target": "http://127.1.0.1:80",
"secure": false
},
"/authority": {
"target": "http://127.1.0.5:80",
"target": "http://127.1.0.1:80",
"secure": false
},
"/console": {
"target": "http://127.1.0.5:80",
"target": "http://127.1.0.1:80",
"secure": false
},
"/connect": {
"target": "http://127.1.0.5:80",
"target": "http://127.1.0.1:80",
"secure": false
},
"/.well-known": {
"target": "http://127.1.0.5:80",
"target": "http://127.1.0.1:80",
"secure": false
},
"/jwks": {
"target": "http://127.1.0.5:80",
"target": "http://127.1.0.1:80",
"secure": false
},
"/scanner": {
"target": "http://127.1.0.5:80",
"target": "http://127.1.0.1:80",
"secure": false
},
"/policyGateway": {
"target": "http://127.1.0.5:80",
"target": "http://127.1.0.1:80",
"secure": false
},
"/policyEngine": {
"target": "http://127.1.0.5:80",
"target": "http://127.1.0.1:80",
"secure": false
},
"/concelier": {
"target": "http://127.1.0.5:80",
"target": "http://127.1.0.1:80",
"secure": false
},
"/attestor": {
"target": "http://127.1.0.5:80",
"target": "http://127.1.0.1:80",
"secure": false
},
"/gateway": {
"target": "http://127.1.0.5:80",
"target": "http://127.1.0.1:80",
"secure": false
},
"/notify": {
"target": "http://127.1.0.5:80",
"target": "http://127.1.0.1:80",
"secure": false
},
"/scheduler": {
"target": "http://127.1.0.5:80",
"target": "http://127.1.0.1:80",
"secure": false
},
"/signals": {
"target": "http://127.1.0.5:80",
"target": "http://127.1.0.1:80",
"secure": false
},
"/excititor": {
"target": "http://127.1.0.5:80",
"target": "http://127.1.0.1:80",
"secure": false
},
"/findingsLedger": {
"target": "http://127.1.0.5:80",
"target": "http://127.1.0.1:80",
"secure": false
},
"/vexhub": {
"target": "http://127.1.0.5:80",
"target": "http://127.1.0.1:80",
"secure": false
},
"/vexlens": {
"target": "http://127.1.0.5:80",
"target": "http://127.1.0.1:80",
"secure": false
},
"/orchestrator": {
"target": "http://127.1.0.5:80",
"target": "http://127.1.0.1:80",
"secure": false
},
"/graph": {
"target": "http://127.1.0.5:80",
"target": "http://127.1.0.1:80",
"secure": false
},
"/doctor": {
"target": "http://127.1.0.5:80",
"target": "http://127.1.0.1:80",
"secure": false
},
"/integrations": {
"target": "http://127.1.0.5:80",
"target": "http://127.1.0.1:80",
"secure": false
},
"/replay": {
"target": "http://127.1.0.5:80",
"target": "http://127.1.0.1:80",
"secure": false
},
"/exportcenter": {
"target": "http://127.1.0.5:80",
"target": "http://127.1.0.1:80",
"secure": false
},
"/healthz": {
"target": "http://127.1.0.5:80",
"target": "http://127.1.0.1:80",
"secure": false
},
"/policy": {
"target": "http://127.1.0.5:80",
"target": "http://127.1.0.1:80",
"secure": false
},
"/v1": {
"target": "http://127.1.0.5:80",
"target": "http://127.1.0.1:80",
"secure": false
}
}

View File

@@ -224,7 +224,7 @@ import { AnalyticsHttpClient, MockAnalyticsClient } from './core/api/analytics.c
import { FEED_MIRROR_API, FEED_MIRROR_API_BASE_URL, FeedMirrorHttpClient } from './core/api/feed-mirror.client';
import { ATTESTATION_CHAIN_API, AttestationChainHttpClient } from './core/api/attestation-chain.client';
import { CONSOLE_SEARCH_API, ConsoleSearchHttpClient } from './core/api/console-search.client';
import { POLICY_GOVERNANCE_API, HttpPolicyGovernanceApi } from './core/api/policy-governance.client';
import { POLICY_GOVERNANCE_API, MockPolicyGovernanceApi } from './core/api/policy-governance.client';
import { POLICY_GATES_API, POLICY_GATES_API_BASE_URL, PolicyGatesHttpClient } from './core/api/policy-gates.client';
import { RELEASE_API, ReleaseHttpClient } from './core/api/release.client';
import { TRIAGE_EVIDENCE_API, TriageEvidenceHttpClient } from './core/api/triage-evidence.client';
@@ -860,10 +860,10 @@ export const appConfig: ApplicationConfig = {
useExisting: ConsoleSearchHttpClient,
},
// Policy Governance API
HttpPolicyGovernanceApi,
MockPolicyGovernanceApi,
{
provide: POLICY_GOVERNANCE_API,
useExisting: HttpPolicyGovernanceApi,
useExisting: MockPolicyGovernanceApi,
},
// Policy Gates API (Policy Gateway backend)
{

View File

@@ -128,6 +128,9 @@ export interface ResolutionEvidence {
/** Confidence in the fix determination */
fixConfidence?: number;
/** Confidence score (legacy contract shape, 0.0-1.0) */
confidence?: number;
/** Reference to detailed evidence record */
evidenceRecordRef?: string;
@@ -137,8 +140,20 @@ export interface ResolutionEvidence {
/** Upstream commit that introduced the fix */
patchCommit?: string;
/** SHA-256 hash of the patch (legacy contract shape) */
patchHash?: string;
/** Functions that changed (for function-level evidence) */
changedFunctions?: FunctionChangeInfo[];
/** Legacy list of matched fingerprint identifiers */
matchedFingerprintIds?: string[];
/** Legacy human-readable function diff summary */
functionDiffSummary?: string;
/** Hybrid source-symbol-binary diff evidence chain */
hybridDiff?: HybridDiffEvidence;
}
/**
@@ -173,6 +188,112 @@ export interface FunctionChangeInfo {
patchedDisasm?: string[];
}
/**
* Hybrid evidence bundle linking semantic edit scripts, symbol maps,
* symbol patch plans, and normalized patch manifests.
*/
export interface HybridDiffEvidence {
/** Digest of semantic edit script */
semanticEditScriptDigest?: string;
/** Digest of old symbol map */
oldSymbolMapDigest?: string;
/** Digest of new symbol map */
newSymbolMapDigest?: string;
/** Digest of symbol patch plan */
symbolPatchPlanDigest?: string;
/** Digest of patch manifest */
patchManifestDigest?: string;
/** Semantic edit script artifact */
semanticEditScript?: SemanticEditScriptArtifact;
/** Symbol patch plan artifact */
symbolPatchPlan?: SymbolPatchPlanArtifact;
/** Patch manifest artifact */
patchManifest?: PatchManifestArtifact;
}
/**
* Semantic edit script contract.
*/
export interface SemanticEditScriptArtifact {
schemaVersion?: string;
sourceTreeDigest?: string;
edits?: SemanticEditRecord[];
}
/**
* Single semantic edit.
*/
export interface SemanticEditRecord {
stableId?: string;
editType?: string;
nodeKind?: string;
nodePath?: string;
anchor?: string;
}
/**
* Symbol patch plan contract.
*/
export interface SymbolPatchPlanArtifact {
schemaVersion?: string;
buildIdBefore?: string;
buildIdAfter?: string;
editsDigest?: string;
symbolMapDigestBefore?: string;
symbolMapDigestAfter?: string;
changes?: SymbolPatchChange[];
}
/**
* Single symbol change within a patch plan.
*/
export interface SymbolPatchChange {
symbol: string;
changeType: 'added' | 'removed' | 'modified' | 'moved' | string;
astAnchors?: string[];
preHash?: string;
postHash?: string;
deltaRef?: string;
}
/**
* Patch manifest contract.
*/
export interface PatchManifestArtifact {
schemaVersion?: string;
buildId?: string;
normalizationRecipeId?: string;
totalDeltaBytes?: number;
patches?: SymbolPatchArtifact[];
}
/**
* Per-symbol patch entry.
*/
export interface SymbolPatchArtifact {
symbol: string;
addressRange?: string;
deltaDigest?: string;
pre?: PatchSizeHash;
post?: PatchSizeHash;
deltaSizeBytes?: number;
}
/**
* Size and hash tuple for a patch boundary.
*/
export interface PatchSizeHash {
size: number;
hash: string;
}
/**
* Batch request for resolving multiple vulnerabilities.
*/
@@ -266,3 +387,4 @@ export interface VulnResolutionSummary {
/** Whether detailed evidence is available */
hasEvidence: boolean;
}

View File

@@ -1,6 +1,7 @@
import { inject } from '@angular/core';
import { CanMatchFn, Router } from '@angular/router';
import { AuthorityAuthService } from './authority-auth.service';
import { AuthSessionStore } from './auth-session.store';
import { StellaOpsScopes, type StellaOpsScope } from './scopes';
@@ -10,9 +11,23 @@ import { StellaOpsScopes, type StellaOpsScope } from './scopes';
*/
export const requireAuthGuard: CanMatchFn = () => {
const auth = inject(AuthSessionStore);
const authorityAuth = inject(AuthorityAuthService);
const router = inject(Router);
const isAuthenticated = auth.isAuthenticated();
return isAuthenticated ? true : router.createUrlTree(['/welcome']);
if (isAuthenticated) {
return true;
}
// On hard reload, AuthSessionStore keeps only lightweight metadata in
// sessionStorage. Give silent refresh one chance before redirecting.
if (auth.subjectHint()) {
return authorityAuth
.trySilentRefresh()
.then((restored) => (restored && auth.isAuthenticated()) ? true : router.createUrlTree(['/welcome']))
.catch(() => router.createUrlTree(['/welcome']));
}
return router.createUrlTree(['/welcome']);
};
/**

View File

@@ -76,6 +76,10 @@ export class AuthorityAuthService {
}
async beginLogin(returnUrl?: string): Promise<void> {
if (!this.ensureSecureContextForAuth()) {
return;
}
const authority = this.config.authority;
const pkce = await createPkcePair();
const state = crypto.randomUUID ? crypto.randomUUID() : createRandomId();
@@ -113,6 +117,10 @@ export class AuthorityAuthService {
* @returns `true` if session was restored, `false` otherwise.
*/
async trySilentRefresh(): Promise<boolean> {
if (!this.ensureSecureContextForAuth({ redirectOnHttp: false })) {
this.sessionStore.setStatus('unauthenticated');
return false;
}
// Already authenticated — nothing to do.
if (this.sessionStore.session()) {
return true;
@@ -697,6 +705,34 @@ export class AuthorityAuthService {
return null;
}
private ensureSecureContextForAuth(
options: { redirectOnHttp?: boolean } = {}
): boolean {
if (typeof window === 'undefined') {
return true;
}
const hasWebCrypto =
typeof crypto !== 'undefined' &&
typeof crypto.getRandomValues === 'function' &&
typeof crypto.subtle !== 'undefined';
if (window.isSecureContext && hasWebCrypto) {
return true;
}
const shouldRedirect = options.redirectOnHttp ?? true;
if (window.location.protocol === 'http:' && shouldRedirect) {
const secureUrl = new URL(window.location.href);
secureUrl.protocol = 'https:';
window.location.assign(secureUrl.toString());
return false;
}
this.lastError = 'dpop_generation_failed';
return false;
}
private buildAuthorizeUrl(
authority: AuthorityConfig,
options: {

View File

@@ -74,7 +74,7 @@ const LEGACY_ROUTE_MAP: Record<string, string> = {
'admin/issuers': '/settings/trust/issuers',
'admin/notifications': '/settings/notifications',
'admin/audit': '/evidence/audit',
'admin/policy/governance': '/settings/policy/governance',
'admin/policy/governance': '/policy/governance',
'concelier/trivy-db-settings': '/settings/security-data/trivy',
// Integrations -> Settings

View File

@@ -119,7 +119,7 @@ function parseScopes(accessToken: string): string[] {
const parts = accessToken.split('.');
if (parts.length !== 3) return [];
const payload = JSON.parse(atob(parts[1]));
const payload = JSON.parse(decodeBase64Url(parts[1]));
const scopeStr = payload.scope ?? payload.scp ?? '';
if (Array.isArray(scopeStr)) {
@@ -132,6 +132,13 @@ function parseScopes(accessToken: string): string[] {
}
}
function decodeBase64Url(value: string): string {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
const paddingLength = (4 - (normalized.length % 4)) % 4;
const padded = normalized + '='.repeat(paddingLength);
return atob(padded);
}
/**
* Check scope inheritance per RBAC contract.
* See docs/contracts/web-gateway-tenant-rbac.md

View File

@@ -1,5 +1,6 @@
import { Component, ChangeDetectionStrategy, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
import { catchError, of } from 'rxjs';
import { APPROVAL_API } from '../../core/api/approval.client';
@@ -11,7 +12,7 @@ import type { ApprovalRequest, ApprovalStatus } from '../../core/api/approval.mo
*/
@Component({
selector: 'app-approvals-inbox',
imports: [CommonModule, RouterLink],
imports: [CommonModule, RouterLink, FormsModule],
template: `
<div class="approvals">
<header class="approvals__header">
@@ -26,20 +27,43 @@ import type { ApprovalRequest, ApprovalStatus } from '../../core/api/approval.mo
<!-- Filters -->
<div class="approvals__filters">
<select class="filter-select" (change)="onStatusFilter($event)">
<option value="pending">Pending</option>
<option value="approved">Approved</option>
<option value="rejected">Rejected</option>
<option value="">All</option>
</select>
<select class="filter-select">
<option value="">All Environments</option>
<option value="dev">Dev</option>
<option value="qa">QA</option>
<option value="staging">Staging</option>
<option value="prod">Prod</option>
</select>
<input type="text" class="filter-search" placeholder="Search..." />
<div class="filter-group">
<span class="filter-group__label">Status</span>
<div class="filter-chips">
@for (status of statusOptions; track status.value) {
<button type="button"
class="filter-chip"
[class.filter-chip--active]="currentStatusFilter === status.value"
(click)="onStatusChipClick(status.value)">
{{ status.label }}
</button>
}
</div>
</div>
<div class="filter-group filter-group--env" [class.filter-group--visible]="currentStatusFilter !== null">
<span class="filter-group__label">Environment</span>
<div class="filter-chips">
@for (env of environmentOptions; track env.value) {
<button type="button"
class="filter-chip"
[class.filter-chip--active]="currentEnvironmentFilter === env.value"
(click)="onEnvironmentFilter(env.value)">
{{ env.label }}
</button>
}
</div>
</div>
<div class="filter-search-wrapper">
<svg class="filter-search-icon" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
<input type="text" class="filter-search" placeholder="Search approvals..." [(ngModel)]="searchQuery" (ngModelChange)="onSearchChange()" />
@if (searchQuery) {
<button type="button" class="filter-search-clear" (click)="clearSearch()">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 6 6 18M6 6l12 12"/></svg>
</button>
}
</div>
</div>
@if (loading()) {
@@ -130,18 +154,115 @@ import type { ApprovalRequest, ApprovalStatus } from '../../core/api/approval.mo
flex-wrap: wrap;
}
.filter-select,
.filter-group {
display: flex;
align-items: center;
gap: 0.5rem;
}
.filter-group__label {
font-size: 0.75rem;
font-weight: var(--font-weight-semibold);
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
white-space: nowrap;
}
.filter-chips {
display: flex;
gap: 0.375rem;
flex-wrap: wrap;
}
.filter-chip {
padding: 0.375rem 0.75rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-2xl);
background: var(--color-surface-primary);
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
cursor: pointer;
transition: all 0.15s ease;
}
.filter-chip:hover {
border-color: var(--color-brand-primary);
color: var(--color-brand-primary);
}
.filter-chip--active {
background: var(--color-brand-primary);
border-color: var(--color-brand-primary);
color: white;
}
.filter-chip--active:hover {
background: var(--color-brand-primary-hover);
border-color: var(--color-brand-primary-hover);
color: white;
}
.filter-group--env {
max-height: 0;
opacity: 0;
overflow: hidden;
transition: max-height 0.3s ease, opacity 0.25s ease, margin 0.3s ease;
margin-top: 0;
}
.filter-group--env.filter-group--visible {
max-height: 60px;
opacity: 1;
margin-top: 0;
}
.filter-search-wrapper {
position: relative;
flex: 1;
min-width: 200px;
}
.filter-search-icon {
position: absolute;
left: 0.75rem;
top: 50%;
transform: translateY(-50%);
color: var(--color-text-muted);
pointer-events: none;
}
.filter-search {
padding: 0.5rem 0.75rem;
width: 100%;
padding: 0.5rem 2rem 0.5rem 2.25rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
font-size: 0.875rem;
background: var(--color-surface-primary);
}
.filter-search {
flex: 1;
min-width: 200px;
.filter-search:focus {
outline: none;
border-color: var(--color-brand-primary);
}
.filter-search-clear {
position: absolute;
right: 0.5rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--color-text-muted);
cursor: pointer;
padding: 0.25rem;
display: flex;
align-items: center;
}
.filter-search-clear:hover {
color: var(--color-text-primary);
}
.loading-banner {
@@ -324,15 +445,46 @@ export class ApprovalsInboxComponent implements OnInit {
readonly loading = signal(true);
readonly error = signal<string | null>(null);
readonly approvals = signal<ApprovalRequest[]>([]);
private currentStatusFilter: ApprovalStatus[] = ['pending'];
readonly statusOptions = [
{ value: '', label: 'All' },
{ value: 'pending', label: 'Pending' },
{ value: 'approved', label: 'Approved' },
{ value: 'rejected', label: 'Rejected' },
];
readonly environmentOptions = [
{ value: '', label: 'All' },
{ value: 'dev', label: 'Dev' },
{ value: 'qa', label: 'QA' },
{ value: 'staging', label: 'Staging' },
{ value: 'prod', label: 'Prod' },
];
currentStatusFilter: string = 'pending';
currentEnvironmentFilter: string = '';
searchQuery: string = '';
ngOnInit(): void {
this.loadApprovals();
}
onStatusFilter(event: Event): void {
const value = (event.target as HTMLSelectElement).value;
this.currentStatusFilter = value ? [value as ApprovalStatus] : [];
onStatusChipClick(value: string): void {
this.currentStatusFilter = value;
this.loadApprovals();
}
onEnvironmentFilter(value: string): void {
this.currentEnvironmentFilter = value;
this.loadApprovals();
}
onSearchChange(): void {
this.loadApprovals();
}
clearSearch(): void {
this.searchQuery = '';
this.loadApprovals();
}
@@ -365,9 +517,13 @@ export class ApprovalsInboxComponent implements OnInit {
private loadApprovals(): void {
this.loading.set(true);
this.error.set(null);
const filter = this.currentStatusFilter.length
? { statuses: this.currentStatusFilter }
: {};
const filter: any = {};
if (this.currentStatusFilter) {
filter.statuses = [this.currentStatusFilter];
}
if (this.currentEnvironmentFilter) {
filter.environment = this.currentEnvironmentFilter;
}
this.api.listApprovals(filter).pipe(
catchError(() => {
this.error.set('Failed to load approvals. The backend may be unavailable.');

View File

@@ -39,7 +39,7 @@ import {
Replay All Retryable ({{ stats()?.stats?.retryable || 0 }})
</button>
<button class="btn btn-icon" (click)="refreshData()" [disabled]="loading()">
<span class="icon" [class.spinning]="loading()">refresh</span>
<span class="icon" [class.spinning]="refreshing()">refresh</span>
</button>
</div>
</header>
@@ -722,6 +722,7 @@ export class DeadLetterDashboardComponent implements OnInit, OnDestroy {
private readonly destroy$ = new Subject<void>();
readonly loading = signal(false);
readonly refreshing = signal(false);
readonly stats = signal<DeadLetterStatsSummary | null>(null);
readonly entries = signal<DeadLetterEntrySummary[]>([]);
readonly selectedEntries = signal<string[]>([]);
@@ -756,10 +757,11 @@ export class DeadLetterDashboardComponent implements OnInit, OnDestroy {
}
refreshData(): void {
this.loadData();
this.refreshing.set(true);
this.loadData(true);
}
private loadData(): void {
private loadData(isManualRefresh = false): void {
this.loading.set(true);
forkJoin({
@@ -772,9 +774,11 @@ export class DeadLetterDashboardComponent implements OnInit, OnDestroy {
this.stats.set(data.stats);
this.entries.set(data.entries.items);
this.loading.set(false);
if (isManualRefresh) this.refreshing.set(false);
},
error: () => {
this.loading.set(false);
if (isManualRefresh) this.refreshing.set(false);
},
});
}

View File

@@ -139,8 +139,8 @@ interface DashboardStats {
}
.stat-card {
background: rgba(30, 41, 59, 0.6);
border: 1px solid var(--color-text-primary);
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
padding: 1.25rem;
display: flex;
@@ -157,7 +157,7 @@ interface DashboardStats {
.stat-value {
font-size: 1.5rem;
font-weight: var(--font-weight-semibold);
color: var(--color-border-primary);
color: var(--color-text-heading);
}
.dashboard-grid {
@@ -167,8 +167,8 @@ interface DashboardStats {
}
.dashboard-card {
background: rgba(30, 41, 59, 0.4);
border: 1px solid var(--color-text-primary);
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-lg);
padding: 1.5rem;
}
@@ -176,7 +176,7 @@ interface DashboardStats {
.dashboard-card h3 {
font-size: 1rem;
font-weight: var(--font-weight-semibold);
color: var(--color-border-primary);
color: var(--color-text-heading);
margin: 0 0 1rem;
}
@@ -188,14 +188,14 @@ interface DashboardStats {
.mode-status {
padding: 1rem;
background: rgba(74, 222, 128, 0.1);
border: 1px solid rgba(74, 222, 128, 0.3);
background: var(--color-status-success-bg);
border: 1px solid var(--color-status-success-border);
border-radius: var(--radius-lg);
}
.mode-status.offline {
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.3);
background: var(--color-status-error-bg);
border-color: var(--color-status-error-border);
}
.mode-indicator {
@@ -247,7 +247,7 @@ interface DashboardStats {
align-items: center;
gap: 0.75rem;
padding: 0.75rem;
background: rgba(15, 23, 42, 0.5);
background: var(--color-surface-secondary);
border-radius: var(--radius-md);
}
@@ -267,7 +267,7 @@ interface DashboardStats {
.feature-name {
font-size: 0.875rem;
color: var(--color-border-primary);
color: var(--color-text-heading);
}
.feature-status {
@@ -310,7 +310,7 @@ interface DashboardStats {
.activity-message {
font-size: 0.875rem;
color: var(--color-border-primary);
color: var(--color-text-heading);
}
.activity-time {
@@ -331,19 +331,19 @@ interface DashboardStats {
}
.btn--primary {
background: var(--color-status-info);
background: var(--color-brand-primary);
color: var(--color-text-heading);
}
.btn--secondary {
background: var(--color-text-primary);
color: var(--color-border-primary);
background: var(--color-surface-secondary);
color: var(--color-text-primary);
}
.spinner {
width: 14px;
height: 14px;
border: 2px solid rgba(15, 23, 42, 0.3);
border: 2px solid var(--color-border-primary);
border-top-color: var(--color-text-heading);
border-radius: var(--radius-full);
animation: spin 0.8s linear infinite;

View File

@@ -236,7 +236,9 @@ export class IncidentTimelineComponent implements OnInit {
return this.incidents().filter((incident) => {
if (severity !== 'all' && incident.severity !== severity) return false;
if (state !== 'all' && incident.state !== state) return false;
if (query && !incident.title.toLowerCase().includes(query) && !incident.description.toLowerCase().includes(query)) {
const title = incident.title ?? '';
const description = incident.description ?? '';
if (query && !title.toLowerCase().includes(query) && !description.toLowerCase().includes(query)) {
return false;
}
return true;
@@ -251,7 +253,14 @@ export class IncidentTimelineComponent implements OnInit {
this.loading.set(true);
this.healthClient.getIncidents(this.hoursBack, this.includeResolved).subscribe({
next: (response) => {
this.incidents.set(response.incidents);
const normalized = (response.incidents ?? []).map((incident) => ({
...incident,
title: incident.title ?? 'Untitled incident',
description: incident.description ?? '',
affectedServices: Array.isArray(incident.affectedServices) ? incident.affectedServices : [],
correlatedEvents: Array.isArray(incident.correlatedEvents) ? incident.correlatedEvents : [],
}));
this.incidents.set(normalized);
this.loading.set(false);
},
error: () => this.loading.set(false),

View File

@@ -39,7 +39,7 @@ import { Subject, interval, takeUntil, switchMap, startWith, forkJoin } from 'rx
(click)="refresh()"
class="px-3 py-2 text-sm border rounded-md hover:bg-gray-50 flex items-center gap-2"
>
<span [class.animate-spin]="loading()">↻</span>
<span [class.animate-spin]="refreshing()">↻</span>
Refresh
</button>
</div>
@@ -503,6 +503,7 @@ export class PlatformHealthDashboardComponent implements OnInit, OnDestroy {
dependencyGraph = signal<DependencyGraph | null>(null);
incidents = signal<Incident[]>([]);
loading = signal(false);
refreshing = signal(false);
loadError = signal<string | null>(null);
groupBy = signal<'state' | 'none'>('state');
@@ -575,6 +576,7 @@ export class PlatformHealthDashboardComponent implements OnInit, OnDestroy {
refresh(): void {
this.loading.set(true);
this.refreshing.set(true);
this.loadError.set(null);
forkJoin({
summary: this.healthClient.getSummary(),
@@ -587,10 +589,12 @@ export class PlatformHealthDashboardComponent implements OnInit, OnDestroy {
this.incidents.set(incidents.incidents ?? []);
this.loadError.set(null);
this.loading.set(false);
this.refreshing.set(false);
},
error: () => {
this.loadError.set('Unable to load platform health data. Try refreshing.');
this.loading.set(false);
this.refreshing.set(false);
},
});
}

View File

@@ -69,6 +69,7 @@ import {
}
</div>
<textarea
id="schema-playground-editor"
[(ngModel)]="schemaContent"
class="editor"
placeholder="Paste or type your risk profile schema here..."
@@ -946,7 +947,62 @@ export class SchemaPlaygroundComponent {
}
protected goToLine(path?: string): void {
// In a real implementation, would scroll editor to the line containing the error
console.log('Navigate to path:', path);
if (!path) {
return;
}
const editor = document.getElementById('schema-playground-editor') as HTMLTextAreaElement | null;
if (!editor) {
return;
}
const directLine = this.extractLineNumber(path);
const inferredLine = directLine ?? this.findLineByPath(path);
if (!inferredLine) {
return;
}
const lines = this.schemaContent.split('\n');
const targetIndex = Math.max(0, Math.min(inferredLine - 1, lines.length - 1));
const startOffset = lines.slice(0, targetIndex).reduce((sum, line) => sum + line.length + 1, 0);
const endOffset = startOffset + lines[targetIndex].length;
editor.focus();
editor.selectionStart = startOffset;
editor.selectionEnd = endOffset;
editor.scrollTop = targetIndex * 24;
}
private extractLineNumber(path: string): number | null {
const linePattern = /(?:line\s*|:)(\d+)/i;
const match = path.match(linePattern);
if (!match) {
return null;
}
const line = Number.parseInt(match[1], 10);
return Number.isFinite(line) && line > 0 ? line : null;
}
private findLineByPath(path: string): number | null {
const normalized = path.trim();
if (!normalized) {
return null;
}
const key = normalized
.split(/[.[\]]+/)
.map((part) => part.trim())
.find((part) => part.length > 0 && !/^\d+$/.test(part));
if (!key) {
return null;
}
const lines = this.schemaContent.split('\n');
const index = lines.findIndex((line) =>
line.includes(`"${key}"`) || line.includes(`${key}:`)
);
return index >= 0 ? index + 1 : null;
}
}

View File

@@ -715,11 +715,24 @@ export class SealedModeOverridesComponent implements OnInit {
if (!hours) return;
const durationHours = parseInt(hours, 10);
if (isNaN(durationHours) || durationHours < 1) return;
if (isNaN(durationHours) || durationHours < 1 || durationHours > 168) return;
// In real app, would call API to extend
console.log(`Extending override ${override.id} by ${durationHours} hours`);
this.loadOverrides();
this.creating.set(true);
this.api
.createSealedModeOverride(
{
type: override.type,
target: override.target,
reason: `Extension for ${override.id}: ${override.reason}`,
durationHours,
},
{ tenantId: 'acme-tenant' }
)
.pipe(finalize(() => this.creating.set(false)))
.subscribe({
next: () => this.loadOverrides(),
error: (err) => console.error('Failed to extend override:', err),
});
}
protected revokeOverride(override: SealedModeOverride): void {

View File

@@ -107,7 +107,7 @@ import {
type="button"
class="source-tab"
[class.source-tab--active]="artifactSource() === 'sbom'"
(click)="artifactSource.set('sbom')"
(click)="setArtifactSource('sbom')"
>
SBOMs
</button>
@@ -115,7 +115,7 @@ import {
type="button"
class="source-tab"
[class.source-tab--active]="artifactSource() === 'image'"
(click)="artifactSource.set('image')"
(click)="setArtifactSource('image')"
>
Images
</button>
@@ -123,7 +123,7 @@ import {
type="button"
class="source-tab"
[class.source-tab--active]="artifactSource() === 'repository'"
(click)="artifactSource.set('repository')"
(click)="setArtifactSource('repository')"
>
Repositories
</button>
@@ -1129,6 +1129,7 @@ export class BatchEvaluationComponent implements OnInit, OnDestroy {
readonly selectedArtifacts = signal<BatchEvaluationArtifact[]>([]);
readonly tags = signal<string[]>([]);
readonly currentBatch = signal<BatchEvaluationResult | undefined>(undefined);
readonly allHistoryEntries = signal<BatchEvaluationHistoryEntry[]>([]);
readonly historyEntries = signal<BatchEvaluationHistoryEntry[]>([]);
readonly filteredArtifacts = signal<BatchEvaluationArtifact[]>([]);
@@ -1194,6 +1195,11 @@ export class BatchEvaluationComponent implements OnInit, OnDestroy {
this.updateFilteredArtifacts(input.value);
}
setArtifactSource(source: 'sbom' | 'image' | 'repository'): void {
this.artifactSource.set(source);
this.updateFilteredArtifacts();
}
isArtifactSelected(artifactId: string): boolean {
return this.selectedArtifacts().some(a => a.artifactId === artifactId);
}
@@ -1402,14 +1408,26 @@ export class BatchEvaluationComponent implements OnInit, OnDestroy {
}
exportResults(): void {
console.log('Exporting results:', this.currentBatch());
// Would trigger download of results
const current = this.currentBatch();
if (!current) {
return;
}
const payload = JSON.stringify(current, null, 2);
const blob = new Blob([payload], { type: 'application/json;charset=utf-8' });
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = objectUrl;
anchor.download = `${current.batchId}.json`;
anchor.click();
URL.revokeObjectURL(objectUrl);
}
startNewEvaluation(): void {
this.currentBatch.set(undefined);
this.selectedArtifacts.set([]);
this.tags.set([]);
this.activeTab.set('new');
}
loadHistory(): void {
@@ -1457,6 +1475,7 @@ export class BatchEvaluationComponent implements OnInit, OnDestroy {
},
];
this.allHistoryEntries.set(mockHistory);
this.historyEntries.set(mockHistory);
}
@@ -1464,12 +1483,45 @@ export class BatchEvaluationComponent implements OnInit, OnDestroy {
const select = event.target as HTMLSelectElement;
const status = select.value;
// Would filter history entries by status
console.log('Filter by status:', status);
const allEntries = this.allHistoryEntries();
if (!status) {
this.historyEntries.set(allEntries);
return;
}
this.historyEntries.set(allEntries.filter((entry) => entry.status === status));
}
loadBatchDetails(batchId: string): void {
console.log('Loading batch details:', batchId);
// Would load full batch result and switch to results view
this.api.getBatchEvaluation(batchId, { tenantId: 'default' }).subscribe({
next: (batch) => {
this.currentBatch.set(batch);
this.activeTab.set('new');
},
error: () => {
const historyEntry = this.allHistoryEntries().find((entry) => entry.batchId === batchId);
if (!historyEntry) {
return;
}
this.currentBatch.set({
batchId: historyEntry.batchId,
status: historyEntry.status,
policyPackId: historyEntry.policyPackId,
policyVersion: historyEntry.policyVersion,
totalArtifacts: historyEntry.totalArtifacts,
completedArtifacts: historyEntry.passed + historyEntry.failed + historyEntry.blocked,
failedArtifacts: historyEntry.failed,
passedArtifacts: historyEntry.passed,
warnedArtifacts: 0,
blockedArtifacts: historyEntry.blocked,
results: [],
startedAt: historyEntry.startedAt,
completedAt: historyEntry.completedAt,
tags: historyEntry.tags ? [...historyEntry.tags] : undefined,
});
this.activeTab.set('new');
},
});
}
}

View File

@@ -1239,32 +1239,153 @@ export class ConflictDetectionComponent implements OnInit {
}
selectSuggestion(conflict: PolicyConflict, suggestion: ResolutionSuggestion): void {
// In a real implementation, this would update the conflict's selectedResolution
console.log('Selected suggestion:', suggestion.id, 'for conflict:', conflict.id);
this.updateConflict(conflict.id, current => ({
...current,
selectedResolution: suggestion.id,
}));
}
applyResolution(conflict: PolicyConflict): void {
console.log('Applying resolution for conflict:', conflict.id);
// Would call API to apply the resolution
const selectedSuggestion = conflict.suggestions.find(
suggestion => suggestion.id === conflict.selectedResolution
);
if (!selectedSuggestion) {
return;
}
const resolvedValue =
selectedSuggestion.suggestedValue ??
(selectedSuggestion.action === 'use_source'
? conflict.sourceValue
: selectedSuggestion.action === 'use_target'
? conflict.targetValue
: conflict.targetValue);
this.updateConflict(conflict.id, current => ({
...current,
isResolved: true,
resolvedAt: new Date().toISOString(),
resolvedBy: 'current-user',
resolvedValue,
}));
}
openManualResolution(conflict: PolicyConflict): void {
console.log('Opening manual resolution for conflict:', conflict.id);
// Would open a modal for manual resolution input
const candidate = prompt(
'Enter resolved JSON value for this conflict:',
JSON.stringify(conflict.targetValue, null, 2)
);
if (candidate === null) {
return;
}
let parsedValue: unknown = candidate;
if (candidate.trim().length > 0) {
try {
parsedValue = JSON.parse(candidate);
} catch {
// Keep raw string when the value is not valid JSON.
}
}
this.updateConflict(conflict.id, current => ({
...current,
selectedResolution: 'manual',
isResolved: true,
resolvedAt: new Date().toISOString(),
resolvedBy: 'current-user',
resolvedValue: parsedValue,
}));
}
skipConflict(conflict: PolicyConflict): void {
console.log('Skipping conflict:', conflict.id);
this.toggleExpand(conflict.id);
if (this.isExpanded(conflict.id)) {
this.toggleExpand(conflict.id);
}
}
reopenConflict(conflict: PolicyConflict): void {
console.log('Reopening conflict:', conflict.id);
// Would call API to reopen the conflict
this.updateConflict(conflict.id, current => ({
...current,
isResolved: false,
resolvedAt: undefined,
resolvedBy: undefined,
resolvedValue: undefined,
}));
}
autoResolveAll(): void {
console.log('Auto-resolving all conflicts');
// Would call API to auto-resolve all auto-resolvable conflicts
const currentResult = this.detectionResult();
if (!currentResult) {
return;
}
const updatedConflicts = currentResult.conflicts.map((conflict) => {
if (conflict.isResolved || conflict.suggestions.length === 0) {
return conflict;
}
const bestSuggestion = [...conflict.suggestions].sort(
(left, right) => right.confidence - left.confidence
)[0];
const resolvedValue =
bestSuggestion.suggestedValue ??
(bestSuggestion.action === 'use_source' ? conflict.sourceValue : conflict.targetValue);
return {
...conflict,
selectedResolution: bestSuggestion.id,
isResolved: true,
resolvedAt: new Date().toISOString(),
resolvedBy: 'auto-resolver',
resolvedValue,
};
});
this.setConflicts(updatedConflicts);
}
private updateConflict(
conflictId: string,
updater: (conflict: PolicyConflict) => PolicyConflict
): void {
const currentResult = this.detectionResult();
if (!currentResult) {
return;
}
const updatedConflicts = currentResult.conflicts.map((conflict) =>
conflict.id === conflictId ? updater(conflict) : conflict
);
this.setConflicts(updatedConflicts);
}
private setConflicts(conflicts: readonly PolicyConflict[]): void {
const currentResult = this.detectionResult();
if (!currentResult) {
return;
}
const unresolved = conflicts.filter((conflict) => !conflict.isResolved);
const autoResolvableCount = unresolved.filter(
(conflict) => conflict.suggestions.length > 0
).length;
const manualResolutionRequired = unresolved.filter(
(conflict) => conflict.suggestions.length === 0
).length;
this.detectionResult.set({
...currentResult,
conflicts: [...conflicts],
totalConflicts: conflicts.length,
criticalCount: conflicts.filter((conflict) => conflict.severity === 'critical').length,
highCount: conflicts.filter((conflict) => conflict.severity === 'high').length,
mediumCount: conflicts.filter((conflict) => conflict.severity === 'medium').length,
lowCount: conflicts.filter((conflict) => conflict.severity === 'low').length,
autoResolvableCount,
manualResolutionRequired,
});
this.applyFilters();
}
}

View File

@@ -1,6 +1,7 @@
import { CommonModule } from '@angular/common';
import { Component, ChangeDetectionStrategy, inject, signal, Input, OnChanges, SimpleChanges } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { Router } from '@angular/router';
import { finalize } from 'rxjs/operators';
@@ -631,6 +632,7 @@ import {
export class PromotionGateComponent implements OnChanges {
private readonly api = inject(POLICY_SIMULATION_API);
private readonly fb = inject(FormBuilder);
private readonly router = inject(Router);
@Input() policyPackId = 'policy-pack-001';
@Input() policyVersion = 2;
@@ -688,8 +690,13 @@ export class PromotionGateComponent implements OnChanges {
}
onPromote(): void {
// Would trigger actual promotion logic
console.log('Promoting policy to', this.targetEnvironment);
void this.router.navigate(['/policy/packs'], {
queryParams: {
promotedPack: this.policyPackId,
promotedVersion: this.policyVersion,
targetEnvironment: this.targetEnvironment,
},
});
}
formatStatus(status: GateCheckStatus): string {

View File

@@ -1200,8 +1200,37 @@ export class SimulationHistoryComponent implements OnInit {
}
togglePin(entry: SimulationHistoryEntry): void {
// Would call API to toggle pin status
console.log('Toggle pin for', entry.simulationId);
this.loadHistory();
const nextPinned = !entry.pinned;
const currentResult = this.historyResult();
if (!currentResult) {
return;
}
this.historyResult.set({
...currentResult,
items: currentResult.items.map((item) =>
item.simulationId === entry.simulationId
? { ...item, pinned: nextPinned }
: item
),
});
this.api.pinSimulation(entry.simulationId, nextPinned, { tenantId: 'default' }).subscribe({
error: () => {
const rollbackResult = this.historyResult();
if (!rollbackResult) {
return;
}
this.historyResult.set({
...rollbackResult,
items: rollbackResult.items.map((item) =>
item.simulationId === entry.simulationId
? { ...item, pinned: entry.pinned }
: item
),
});
},
});
}
}

View File

@@ -0,0 +1,106 @@
import { TestBed } from '@angular/core/testing';
import { firstValueFrom, of } from 'rxjs';
import { take } from 'rxjs/operators';
import { AuthSessionStore } from '../../../core/auth/auth-session.store';
import { PolicyPackSummary } from '../models/policy.models';
import { PolicyApiService } from './policy-api.service';
import { PolicyPackStore } from './policy-pack.store';
describe('PolicyPackStore', () => {
let store: PolicyPackStore;
let api: jasmine.SpyObj<PolicyApiService>;
let isAuthenticatedSpy: jasmine.Spy<() => boolean>;
let sessionSpy: jasmine.Spy<() => { scopes?: string[] } | null>;
const apiPacks: PolicyPackSummary[] = [
{
id: 'pack-core',
name: 'Core Policy Pack',
description: 'Default baseline.',
version: '2026.02.17',
status: 'active',
createdAt: '2026-02-17T00:00:00Z',
modifiedAt: '2026-02-17T00:00:00Z',
createdBy: 'system',
modifiedBy: 'system',
tags: ['baseline'],
},
];
beforeEach(() => {
sessionStorage.clear();
api = jasmine.createSpyObj<PolicyApiService>('PolicyApiService', ['listPacks']);
isAuthenticatedSpy = jasmine.createSpy<() => boolean>('isAuthenticated').and.returnValue(false);
sessionSpy = jasmine.createSpy<() => { scopes?: string[] } | null>('session').and.returnValue(null);
TestBed.configureTestingModule({
providers: [
PolicyPackStore,
{ provide: PolicyApiService, useValue: api },
{
provide: AuthSessionStore,
useValue: {
isAuthenticated: isAuthenticatedSpy,
session: sessionSpy,
},
},
],
});
store = TestBed.inject(PolicyPackStore);
});
afterEach(() => {
sessionStorage.clear();
});
it('returns fallback packs without API call when unauthenticated', async () => {
const packs = await firstValueFrom(store.getPacks().pipe(take(1)));
expect(api.listPacks).not.toHaveBeenCalled();
expect(packs.length).toBe(1);
expect(packs[0]?.name).toBe('Core Policy Pack');
});
it('fetches packs from API when authenticated with policy:read scope', async () => {
isAuthenticatedSpy.and.returnValue(true);
sessionSpy.and.returnValue({ scopes: ['policy:read'] });
api.listPacks.and.returnValue(of(apiPacks));
const packs = await firstValueFrom(store.getPacks().pipe(take(1)));
expect(api.listPacks).toHaveBeenCalledWith({ limit: 50 });
expect(packs).toEqual(apiPacks);
});
it('refreshes fallback packs with API data after auth becomes available', async () => {
const fallback = await firstValueFrom(store.getPacks().pipe(take(1)));
expect(fallback[0]?.version).toBe('latest');
expect(api.listPacks).not.toHaveBeenCalled();
isAuthenticatedSpy.and.returnValue(true);
sessionSpy.and.returnValue({ scopes: ['policy:read'] });
api.listPacks.and.returnValue(of(apiPacks));
const refreshed = await firstValueFrom(store.getPacks().pipe(take(1)));
expect(api.listPacks).toHaveBeenCalledTimes(1);
expect(refreshed).toEqual(apiPacks);
});
it('does not trigger API fetch when remote loading is disabled', async () => {
isAuthenticatedSpy.and.returnValue(true);
sessionSpy.and.returnValue({ scopes: ['policy:read'] });
api.listPacks.and.returnValue(of(apiPacks));
const packs = await firstValueFrom(
store.getPacks({ allowRemoteFetch: false }).pipe(take(1))
);
expect(api.listPacks).not.toHaveBeenCalled();
expect(packs.length).toBe(1);
expect(packs[0]?.name).toBe('Core Policy Pack');
});
});

View File

@@ -4,17 +4,28 @@ import { filter } from 'rxjs/operators';
import { PolicyPackSummary } from '../models/policy.models';
import { PolicyApiService } from './policy-api.service';
import { AuthSessionStore } from '../../../core/auth/auth-session.store';
@Injectable({ providedIn: 'root' })
export class PolicyPackStore {
private readonly api = inject(PolicyApiService);
private readonly authStore = inject(AuthSessionStore);
private readonly packs$ = new BehaviorSubject<PolicyPackSummary[] | null>(null);
private loading = false;
private usingFallback = false;
private readonly cacheKey = 'policy-studio:packs-cache';
getPacks(): Observable<PolicyPackSummary[]> {
if (!this.packs$.value && !this.loading) {
this.fetch();
getPacks(options: { allowRemoteFetch?: boolean } = {}): Observable<PolicyPackSummary[]> {
const allowRemoteFetch = options.allowRemoteFetch ?? true;
if (!this.loading) {
const hasNoData = !this.packs$.value;
if (allowRemoteFetch && (hasNoData || (this.usingFallback && this.canReadPolicyPacks()))) {
this.fetch();
} else if (hasNoData) {
const cached = this.readCache();
this.usingFallback = !cached;
this.packs$.next(cached ?? this.fallbackPacks());
}
}
return this.packs$.pipe(filter((p): p is PolicyPackSummary[] => Array.isArray(p)));
@@ -25,9 +36,18 @@ export class PolicyPackStore {
}
private fetch(): void {
if (!this.canReadPolicyPacks()) {
this.usingFallback = true;
this.packs$.next(this.fallbackPacks());
this.loading = false;
return;
}
this.loading = true;
let fetchedFromFallback = false;
const cached = this.readCache();
if (cached) {
this.usingFallback = false;
this.packs$.next(cached);
this.loading = false;
return;
@@ -36,15 +56,28 @@ export class PolicyPackStore {
this.api
.listPacks({ limit: 50 })
.pipe(
catchError(() => of(this.fallbackPacks())),
catchError(() => {
fetchedFromFallback = true;
return of(this.fallbackPacks());
}),
finalize(() => (this.loading = false))
)
.subscribe((packs) => {
this.usingFallback = fetchedFromFallback;
this.packs$.next(packs);
this.writeCache(packs);
});
}
private canReadPolicyPacks(): boolean {
if (!this.authStore.isAuthenticated()) {
return false;
}
const scopes = this.authStore.session()?.scopes ?? [];
return scopes.includes('policy:read');
}
private fallbackPacks(): PolicyPackSummary[] {
return [
{

View File

@@ -197,7 +197,7 @@ type SortOrder = 'asc' | 'desc';
<td>
<a
class="policy-studio__link"
[routerLink]="['/policy/profiles', profile.profileId]"
[routerLink]="['/policy/governance/profiles', profile.profileId]"
>
{{ profile.profileId }}
</a>
@@ -1090,7 +1090,7 @@ export class PolicyStudioComponent implements OnInit {
viewProfile(profile: RiskProfileSummary): void {
this.store.loadProfile(profile.profileId, { tenantId: this.tenantId });
this.store.loadProfileVersions(profile.profileId, { tenantId: this.tenantId });
this.router.navigate(['/policy/profiles', profile.profileId]);
this.router.navigate(['/policy/governance/profiles', profile.profileId]);
}
simulateWithProfile(profile: RiskProfileSummary): void {
@@ -1099,7 +1099,7 @@ export class PolicyStudioComponent implements OnInit {
}
viewPack(pack: PolicyPackSummary): void {
this.router.navigate(['/policy/packs', pack.packId]);
this.router.navigate(['/policy-studio/packs', pack.packId, 'dashboard']);
}
createRevision(pack: PolicyPackSummary): void {
@@ -1112,11 +1112,11 @@ export class PolicyStudioComponent implements OnInit {
}
openCreateProfile(): void {
this.router.navigate(['/policy/profiles/new']);
this.router.navigate(['/policy/governance/profiles/new']);
}
openCreatePack(): void {
this.router.navigate(['/policy/packs/new']);
this.router.navigate(['/policy-studio/packs']);
}
runSimulation(): void {

View File

@@ -33,7 +33,7 @@ import {
Export Report
</button>
<button class="btn btn-icon" (click)="refreshData()" [disabled]="loading()">
<span class="icon" [class.spinning]="loading()">refresh</span>
<span class="icon" [class.spinning]="refreshing()">refresh</span>
</button>
</div>
</header>
@@ -902,6 +902,7 @@ export class QuotaDashboardComponent implements OnInit, OnDestroy {
private readonly refreshInterval = 30000; // 30 seconds
readonly loading = signal(false);
readonly refreshing = signal(false);
readonly summary = signal<QuotaDashboardSummary | null>(null);
readonly consumptionHistory = signal<Array<{ timestamp: string; percentage: number }>>([]);
readonly forecasts = signal<QuotaForecast[]>([]);
@@ -935,10 +936,11 @@ export class QuotaDashboardComponent implements OnInit, OnDestroy {
}
refreshData(): void {
this.loadData();
this.refreshing.set(true);
this.loadData(true);
}
private loadData(): void {
private loadData(isManualRefresh = false): void {
this.loading.set(true);
forkJoin({
@@ -957,9 +959,11 @@ export class QuotaDashboardComponent implements OnInit, OnDestroy {
this.topTenants.set(data.tenants.items);
this.recentViolations.set(data.violations.items);
this.loading.set(false);
if (isManualRefresh) this.refreshing.set(false);
},
error: () => {
this.loading.set(false);
if (isManualRefresh) this.refreshing.set(false);
},
});
}

View File

@@ -929,8 +929,24 @@ export class ReleaseDetailComponent implements OnInit {
}
showConfigDialog(comp: ReleaseComponent): void {
// TODO: Implement config dialog
alert('Config dialog not yet implemented');
const release = this.release();
if (!release) {
return;
}
const key = prompt(`Configuration key for "${comp.name}"`, '');
if (key === null || key.trim().length === 0) {
return;
}
const value = prompt(`Value for "${key.trim()}"`, '');
if (value === null) {
return;
}
this.store.updateComponentConfig(release.id, comp.id, {
[key.trim()]: value,
});
}
confirmPromote(): void {

View File

@@ -3,9 +3,9 @@
* Sprint: SPRINT_20260118_007_FE_security_consolidation (SEC-007)
*/
import { Component, ChangeDetectionStrategy, Inject, OnInit, signal } from '@angular/core';
import { Component, ChangeDetectionStrategy, Inject, OnInit, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { Router, RouterLink } from '@angular/router';
import { EXCEPTION_API, type ExceptionApi } from '../../core/api/exception.client';
@Component({
@@ -85,6 +85,7 @@ import { EXCEPTION_API, type ExceptionApi } from '../../core/api/exception.clien
`]
})
export class ExceptionsPageComponent implements OnInit {
private readonly router = inject(Router);
exceptions = signal<Array<{ id: string; finding: string; reason: string; requestedBy: string; approvedBy: string | null; expires: string; status: string }>>([]);
loading = signal(true);
error = signal<string | null>(null);
@@ -121,6 +122,8 @@ export class ExceptionsPageComponent implements OnInit {
}
requestException(): void {
console.log('Request exception');
void this.router.navigate(['/policy/exceptions'], {
queryParams: { create: '1' },
});
}
}

View File

@@ -7,7 +7,7 @@
import { Component, ChangeDetectionStrategy, OnInit, inject, signal, computed } from '@angular/core';
import { RouterLink } from '@angular/router';
import { Router, RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { catchError, of } from 'rxjs';
import { SECURITY_FINDINGS_API, type FindingDto } from '../../core/api/security-findings.client';
@@ -355,6 +355,7 @@ interface Finding {
})
export class SecurityFindingsPageComponent implements OnInit {
private readonly findingsApi = inject(SECURITY_FINDINGS_API);
private readonly router = inject(Router);
readonly loading = signal(true);
readonly error = signal<string | null>(null);
@@ -470,14 +471,78 @@ export class SecurityFindingsPageComponent implements OnInit {
}
openWitness(finding: Finding): void {
console.log('Open witness for:', finding.id);
void this.router.navigate(['/security/reachability'], {
queryParams: { findingId: finding.id },
});
}
requestException(finding: Finding): void {
console.log('Request exception for:', finding.id);
void this.router.navigate(['/policy/exceptions'], {
queryParams: {
cveId: finding.id,
package: finding.package,
severity: finding.severity.toLowerCase(),
},
});
}
exportFindings(): void {
console.log('Export findings to CSV');
const findings = this.filteredFindings();
if (findings.length === 0) {
return;
}
const headers = [
'cve_id',
'package',
'version',
'severity',
'cvss',
'reachable',
'reachability_confidence',
'vex_status',
'release_id',
'release_version',
'delta',
'environments',
'first_seen',
];
const escapeCsv = (value: unknown): string => {
const text = String(value ?? '');
if (text.includes(',') || text.includes('"') || text.includes('\n')) {
return `"${text.replace(/"/g, '""')}"`;
}
return text;
};
const rows = findings.map((finding) => [
finding.id,
finding.package,
finding.version,
finding.severity,
finding.cvss,
finding.reachable === null ? 'unknown' : finding.reachable ? 'reachable' : 'unreachable',
finding.reachabilityConfidence ?? '',
finding.vexStatus,
finding.releaseId,
finding.releaseVersion,
finding.delta,
finding.environments.join('|'),
finding.firstSeen,
]);
const csv = [
headers.join(','),
...rows.map((row) => row.map((value) => escapeCsv(value)).join(',')),
].join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
const objectUrl = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = objectUrl;
anchor.download = `security-findings-${new Date().toISOString().slice(0, 10)}.csv`;
anchor.click();
URL.revokeObjectURL(objectUrl);
}
}

View File

@@ -7,7 +7,7 @@
import { Component, ChangeDetectionStrategy, OnInit, inject, signal } from '@angular/core';
import { RouterLink } from '@angular/router';
import { Router, RouterLink } from '@angular/router';
import { catchError, of } from 'rxjs';
import { SECURITY_OVERVIEW_API } from '../../core/api/security-overview.client';
@@ -59,11 +59,11 @@ import { SECURITY_OVERVIEW_API } from '../../core/api/security-overview.client';
<section class="panel">
<div class="panel-header">
<h3>Recent Findings</h3>
<a routerLink="./findings" class="panel-link">View All →</a>
<a routerLink="/security/findings" class="panel-link">View All →</a>
</div>
<div class="findings-list">
@for (finding of recentFindings; track finding.id) {
<a class="finding-item" [routerLink]="['./findings', finding.id]">
<a class="finding-item" [routerLink]="['/security/findings', finding.id]">
<span class="severity-dot" [class]="'severity-dot--' + finding.severity.toLowerCase()"></span>
<div class="finding-info">
<span class="finding-id">{{ finding.id }}</span>
@@ -104,7 +104,7 @@ import { SECURITY_OVERVIEW_API } from '../../core/api/security-overview.client';
<section class="panel">
<div class="panel-header">
<h3>VEX Coverage</h3>
<a routerLink="./vex" class="panel-link">Manage VEX →</a>
<a routerLink="/security/vex" class="panel-link">Manage VEX →</a>
</div>
<div class="vex-stats">
<div class="vex-stat">
@@ -126,7 +126,7 @@ import { SECURITY_OVERVIEW_API } from '../../core/api/security-overview.client';
<section class="panel">
<div class="panel-header">
<h3>Active Exceptions</h3>
<a routerLink="./exceptions" class="panel-link">Manage →</a>
<a routerLink="/security/exceptions" class="panel-link">Manage →</a>
</div>
<div class="exceptions-list">
@for (exception of activeExceptions; track exception.id) {
@@ -295,6 +295,7 @@ import { SECURITY_OVERVIEW_API } from '../../core/api/security-overview.client';
})
export class SecurityOverviewPageComponent implements OnInit {
private readonly overviewApi = inject(SECURITY_OVERVIEW_API);
private readonly router = inject(Router);
readonly loading = signal(true);
readonly error = signal<string | null>(null);
@@ -340,6 +341,7 @@ export class SecurityOverviewPageComponent implements OnInit {
}
runScan(): void {
console.log('Run security scan');
void this.router.navigateByUrl('/ops/scanner');
}
}

View File

@@ -194,10 +194,10 @@ import { FormsModule } from '@angular/forms';
font-size: 0.75rem;
font-weight: var(--font-weight-semibold);
}
.status-badge--not-affected { background: var(--color-severity-low-bg); color: var(--color-status-success-text); }
.status-badge--affected { background: var(--color-severity-critical-bg); color: var(--color-status-error-text); }
.status-badge--fixed { background: var(--color-severity-info-bg); color: var(--color-status-info-text); }
.status-badge--under-investigation { background: var(--color-severity-medium-bg); color: var(--color-status-warning-text); }
.status-badge--not-affected { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
.status-badge--affected { background: var(--color-status-error-bg); color: var(--color-status-error-text); }
.status-badge--fixed { background: var(--color-status-info-bg); color: var(--color-status-info-text); }
.status-badge--under-investigation { background: var(--color-status-warning-bg); color: var(--color-status-warning-text); }
.justification {
max-width: 250px;
@@ -216,8 +216,8 @@ import { FormsModule } from '@angular/forms';
text-transform: uppercase;
}
.source-badge--vendor { background: var(--color-status-excepted-bg); color: var(--color-status-excepted); }
.source-badge--internal { background: var(--color-severity-info-bg); color: var(--color-status-info-text); }
.source-badge--community { background: var(--color-severity-low-bg); color: var(--color-status-success-text); }
.source-badge--internal { background: var(--color-status-info-bg); color: var(--color-status-info-text); }
.source-badge--community { background: var(--color-status-success-bg); color: var(--color-status-success-text); }
.btn {
padding: 0.5rem 1rem;

View File

@@ -8,7 +8,7 @@
import { Component, ChangeDetectionStrategy, signal, computed, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
interface DeployedEnvironment {
name: string;
@@ -515,6 +515,7 @@ interface GateImpact {
})
export class VulnerabilityDetailPageComponent implements OnInit {
private route = inject(ActivatedRoute);
private readonly router = inject(Router);
findingId = signal('');
@@ -587,26 +588,49 @@ export class VulnerabilityDetailPageComponent implements OnInit {
}
openEvidence(): void {
console.log('Opening evidence for:', this.vuln().id);
void this.router.navigate(['/evidence'], {
queryParams: {
subject: this.vuln().id,
source: 'security-vulnerability-detail',
},
});
}
openWitness(): void {
console.log('Opening witness for:', this.vuln().id);
void this.router.navigate(['/security/reachability'], {
queryParams: { cveId: this.vuln().id },
});
}
updateVex(): void {
console.log('Update VEX statement');
void this.router.navigate(['/security/vex'], {
queryParams: { cve: this.vuln().id },
});
}
requestException(): void {
console.log('Request exception');
void this.router.navigate(['/policy/exceptions'], {
queryParams: {
cveId: this.vuln().id,
package: this.vuln().package,
severity: this.vuln().severity.toLowerCase(),
},
});
}
createRemediationTask(): void {
console.log('Create remediation task');
void this.router.navigate(['/operations/orchestrator/jobs'], {
queryParams: {
action: 'remediate',
cveId: this.vuln().id,
package: this.vuln().package,
},
});
}
viewRelatedReleases(): void {
console.log('View related releases');
void this.router.navigate(['/releases'], {
queryParams: { cveId: this.vuln().id },
});
}
}

View File

@@ -5,9 +5,9 @@
* Integration hub with KPI strip and list of integrations.
*/
import { Component, ChangeDetectionStrategy, signal } from '@angular/core';
import { Component, ChangeDetectionStrategy, signal, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { RouterLink } from '@angular/router';
import { Router, RouterLink } from '@angular/router';
interface Integration {
id: string;
@@ -345,6 +345,7 @@ interface Integration {
`]
})
export class IntegrationsSettingsPageComponent {
private readonly router = inject(Router);
filterType = signal<string | null>(null);
integrations = signal<Integration[]>([
@@ -373,7 +374,6 @@ export class IntegrationsSettingsPageComponent {
}
openAddWizard(): void {
// TODO: Open integration wizard modal
console.log('Open add integration wizard');
this.router.navigate(['/integrations/onboarding/registry']);
}
}

View File

@@ -4,11 +4,12 @@
*/
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { RouterLink } from '@angular/router';
@Component({
selector: 'app-policy-governance-settings-page',
imports: [],
imports: [RouterLink],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="policy-settings">
@@ -19,25 +20,25 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
<section class="settings-section">
<h2>Policy Baselines</h2>
<p>Manage policy baselines for different environments.</p>
<button type="button" class="btn btn--primary">+ Create Baseline</button>
<a routerLink="/policy/packs" class="btn btn--primary">+ Create Baseline</a>
</section>
<section class="settings-section">
<h2>Governance Rules</h2>
<p>Define organizational governance rules for releases.</p>
<button type="button" class="btn btn--secondary">Edit Rules</button>
<a routerLink="/policy/governance" class="btn btn--secondary">Edit Rules</a>
</section>
<section class="settings-section">
<h2>Policy Simulation</h2>
<p>Test policy changes before applying them.</p>
<button type="button" class="btn btn--secondary">Run Simulation</button>
<a routerLink="/policy/simulation" class="btn btn--secondary">Run Simulation</a>
</section>
<section class="settings-section">
<h2>Exception Workflow</h2>
<p>Configure how policy exceptions are requested and approved.</p>
<button type="button" class="btn btn--secondary">Configure Workflow</button>
<a routerLink="/policy/exceptions" class="btn btn--secondary">Configure Workflow</a>
</section>
</div>
</div>
@@ -59,7 +60,16 @@ import { Component, ChangeDetectionStrategy } from '@angular/core';
}
.settings-section h2 { margin: 0 0 0.5rem; font-size: 1rem; font-weight: var(--font-weight-semibold); }
.settings-section p { margin: 0 0 1rem; font-size: 0.875rem; color: var(--color-text-secondary); }
.btn { padding: 0.375rem 0.75rem; border-radius: var(--radius-md); font-size: 0.875rem; cursor: pointer; }
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.375rem 0.75rem;
border-radius: var(--radius-md);
font-size: 0.875rem;
cursor: pointer;
text-decoration: none;
}
.btn--primary { background: var(--color-brand-primary); border: none; color: var(--color-text-heading); }
.btn--secondary { background: var(--color-surface-secondary); border: 1px solid var(--color-border-primary); }
`]

View File

@@ -1345,7 +1345,7 @@ import {
[class.selected]="sourceFeedMode() === 'mirror'"
(click)="selectSourceFeedMode('mirror')">
<span class="provider-card-name">Stella Ops Mirror</span>
<span class="provider-card-desc">Pre-aggregated advisory feeds from Stella Ops cloud. Includes NVD, GHSA, OSV, and distribution-specific advisories. Recommended for most deployments.</span>
<span class="provider-card-desc">Pre-aggregated advisory feeds from Stella Ops cloud. Includes NVD, GHSA, OSV, and distribution-specific advisories. Use when an external or internal mirror endpoint is reachable from this environment.</span>
</button>
<button class="provider-card"
[class.selected]="sourceFeedMode() === 'custom'"
@@ -1359,15 +1359,15 @@ import {
@if (sourceFeedMode() === 'mirror') {
<div class="subsection" style="margin-top: 16px;">
<h4>Mirror Configuration</h4>
<p class="section-hint">Connect to the Stella Ops advisory mirror. Anonymous access is available; credentials unlock higher rate limits and priority feeds.</p>
<p class="section-hint">Connect to a reachable advisory mirror endpoint. For local/offline setup, prefer Custom Feed Sources unless a mirror URL is explicitly available.</p>
<div class="form-group">
<label for="sources-mirror-url">Mirror URL</label>
<input
type="text"
id="sources-mirror-url"
[value]="getConfigValue('sources.mirror.url') || 'https://mirror.stella-ops.org/feeds'"
[value]="getConfigValue('sources.mirror.url') || ''"
(input)="onInputChange('sources.mirror.url', $event)"
placeholder="https://mirror.stella-ops.org/feeds"
placeholder="https://<mirror-host>/feeds"
/>
</div>
<div class="form-group">
@@ -3095,8 +3095,7 @@ export class StepContentComponent {
'crypto.provider': 'default',
},
sources: {
'sources.mode': 'mirror',
'sources.mirror.url': 'https://mirror.stella-ops.org/feeds',
'sources.mode': 'custom',
},
telemetry: {
'telemetry.otlpEndpoint': 'http://localhost:4317',
@@ -3417,10 +3416,6 @@ export class StepContentComponent {
selectSourceFeedMode(mode: 'mirror' | 'custom'): void {
this.sourceFeedMode.set(mode);
this.configChange.emit({ key: 'sources.mode', value: mode });
if (mode === 'mirror') {
// Clear individual source selections when switching to mirror
this.configChange.emit({ key: 'sources.mirror.url', value: 'https://mirror.stella-ops.org/feeds' });
}
}
toggleSource(sourceId: string, event: Event): void {

View File

@@ -93,10 +93,15 @@ export class VulnerabilityDetailComponent implements OnInit {
cveId: vuln.cveId,
}).subscribe({
next: (response) => {
const evidenceConfidence = response.evidence?.fixConfidence
?? response.evidence?.confidence
?? response.confidence
?? 0;
this.binaryResolution.set({
status: response.status,
matchType: response.evidence?.matchType as any || 'version_range',
confidence: response.evidence?.fixConfidence || 0,
confidence: evidenceConfidence,
fixedVersion: response.fixedVersion,
distroAdvisoryId: response.evidence?.distroAdvisoryId,
hasEvidence: !!response.evidence,
@@ -179,3 +184,4 @@ export class VulnerabilityDetailComponent implements OnInit {
return 'low';
}
}

View File

@@ -138,14 +138,19 @@ import { OverlayHostComponent } from '../overlay-host/overlay-host.component';
.shell__breadcrumb {
flex-shrink: 0;
padding: 0.5rem 1.5rem;
padding: 0.375rem 1.5rem;
border-bottom: 1px solid var(--color-border-primary);
background: var(--color-surface-secondary);
}
.shell__outlet {
flex: 1;
padding: var(--space-6, 1.5rem);
outline: none;
background:
radial-gradient(circle at 1px 1px, var(--color-border-primary) 0.5px, transparent 0.5px),
var(--color-surface-tertiary);
background-size: 24px 24px, auto;
}
.shell__overlay {

View File

@@ -7,6 +7,8 @@ import {
inject,
signal,
computed,
NgZone,
OnDestroy,
} from '@angular/core';
import { Router, RouterLink } from '@angular/router';
@@ -54,15 +56,18 @@ export interface NavSection {
template: `
<aside
class="sidebar"
[class.sidebar--collapsed]="collapsed"
[class.sidebar--collapsed]="collapsed && !hoverExpanded()"
[class.sidebar--hover-expanded]="hoverExpanded()"
role="navigation"
aria-label="Main navigation"
(mouseenter)="onSidebarMouseEnter()"
(mouseleave)="onSidebarMouseLeave()"
>
<!-- Brand/Logo -->
<div class="sidebar__brand">
<a routerLink="/" class="sidebar__logo">
<img src="assets/img/site.png" alt="" class="sidebar__logo-img" width="28" height="28" />
@if (!collapsed) {
@if (!collapsed || hoverExpanded()) {
<span class="sidebar__logo-text">Stella Ops</span>
}
</a>
@@ -107,7 +112,7 @@ export interface NavSection {
[icon]="section.icon"
[route]="section.route"
[children]="section.children"
[collapsed]="collapsed"
[collapsed]="collapsed && !hoverExpanded()"
[expanded]="expandedGroups().has(section.id)"
(expandedChange)="onGroupToggle(section.id, $event)"
></app-sidebar-nav-group>
@@ -117,7 +122,7 @@ export interface NavSection {
[icon]="section.icon"
[route]="section.route"
[badge]="section.badge$ ? section.badge$() : null"
[collapsed]="collapsed"
[collapsed]="collapsed && !hoverExpanded()"
></app-sidebar-nav-item>
}
}
@@ -125,7 +130,7 @@ export interface NavSection {
<!-- Bottom section (optional version info) -->
<div class="sidebar__footer">
@if (!collapsed) {
@if (!collapsed || hoverExpanded()) {
<span class="sidebar__version">v1.0.0</span>
}
</div>
@@ -153,13 +158,37 @@ export interface NavSection {
overflow: visible;
}
/* Hover auto-expand: slides out over content */
.sidebar--hover-expanded {
width: 240px;
position: absolute;
top: 0;
left: 0;
bottom: 0;
z-index: 200;
box-shadow: 4px 0 24px rgba(0, 0, 0, 0.12), 1px 0 4px rgba(0, 0, 0, 0.06);
animation: sidebar-slide-in 0.25s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes sidebar-slide-in {
from {
width: 56px;
opacity: 0.8;
}
to {
width: 240px;
opacity: 1;
}
}
.sidebar__brand {
display: flex;
align-items: center;
height: 52px;
height: 48px;
padding: 0 1rem;
flex-shrink: 0;
border-bottom: 1px solid var(--color-border-primary);
background: transparent;
}
.sidebar__logo {
@@ -168,19 +197,21 @@ export interface NavSection {
gap: 0.625rem;
color: inherit;
text-decoration: none;
font-weight: var(--font-weight-bold);
font-size: 1.0625rem;
letter-spacing: 0.02em;
font-weight: 700;
font-size: 0.9375rem;
letter-spacing: -0.02em;
white-space: nowrap;
}
.sidebar__logo-img {
flex-shrink: 0;
border-radius: 6px;
border-radius: 5px;
filter: drop-shadow(0 1px 2px rgba(212,146,10,0.12));
}
.sidebar__logo-text {
color: var(--color-text-heading);
letter-spacing: -0.025em;
}
.sidebar__toggle {
@@ -263,24 +294,29 @@ export interface NavSection {
}
.sidebar__version {
font-size: 0.625rem;
font-size: 0.5625rem;
font-family: var(--font-family-mono);
letter-spacing: 0.06em;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-text-muted);
opacity: 0.6;
opacity: 0.4;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppSidebarComponent {
export class AppSidebarComponent implements OnDestroy {
private readonly router = inject(Router);
private readonly authService = inject(AUTH_SERVICE) as AuthService;
private readonly ngZone = inject(NgZone);
@Input() collapsed = false;
@Output() toggleCollapse = new EventEmitter<void>();
@Output() mobileClose = new EventEmitter<void>();
/** Whether sidebar is temporarily expanded due to hover */
readonly hoverExpanded = signal(false);
private hoverTimer: ReturnType<typeof setTimeout> | null = null;
/** Track which groups are expanded */
readonly expandedGroups = signal<Set<string>>(new Set(['security']));
@@ -429,6 +465,29 @@ export class AppSidebarComponent {
return this.authService.hasAnyScope(scopes);
}
onSidebarMouseEnter(): void {
if (!this.collapsed || this.hoverExpanded()) return;
this.ngZone.runOutsideAngular(() => {
this.hoverTimer = setTimeout(() => {
this.ngZone.run(() => this.hoverExpanded.set(true));
}, 2000);
});
}
onSidebarMouseLeave(): void {
if (this.hoverTimer) {
clearTimeout(this.hoverTimer);
this.hoverTimer = null;
}
this.hoverExpanded.set(false);
}
ngOnDestroy(): void {
if (this.hoverTimer) {
clearTimeout(this.hoverTimer);
}
}
onGroupToggle(groupId: string, expanded: boolean): void {
this.expandedGroups.update((groups) => {
const newGroups = new Set(groups);

View File

@@ -5,9 +5,11 @@ import {
Output,
EventEmitter,
OnInit,
OnDestroy,
inject,
signal,
DestroyRef,
NgZone,
} from '@angular/core';
import { Router, NavigationEnd } from '@angular/router';
import { filter } from 'rxjs/operators';
@@ -33,6 +35,8 @@ import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component';
class="nav-group__header"
[class.nav-group__header--active]="isGroupActive()"
(click)="onToggle()"
(mouseenter)="onHeaderMouseEnter()"
(mouseleave)="onHeaderMouseLeave()"
[attr.aria-expanded]="expanded"
[attr.aria-controls]="'nav-group-' + label"
>
@@ -88,7 +92,7 @@ import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component';
<!-- Children (expanded, not collapsed) -->
@if (expanded && !collapsed && children && children.length > 0) {
<div class="nav-group__children" [id]="'nav-group-' + label" role="group">
<div class="nav-group__children" [class.nav-group__children--auto-opened]="autoExpanded()" [id]="'nav-group-' + label" role="group">
@for (child of children; track child.id) {
<app-sidebar-nav-item
[label]="child.label"
@@ -149,13 +153,14 @@ import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component';
border: none;
border-radius: 0;
background: transparent;
color: var(--color-text-secondary);
color: var(--color-text-muted);
cursor: pointer;
text-align: left;
font-size: 0.75rem;
font-weight: var(--font-weight-semibold);
font-family: var(--font-family-mono);
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
letter-spacing: 0.1em;
transition: color 0.15s;
&:hover {
@@ -205,14 +210,32 @@ import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component';
.nav-group__children {
padding-left: 0;
margin-left: 0.25rem;
margin-left: 0.5rem;
margin-top: 0.125rem;
border-left: 2px solid rgba(245, 166, 35, 0.2);
border-left: 1px solid var(--color-border-primary);
transition: border-color 0.2s ease;
overflow: hidden;
}
.nav-group__children--auto-opened {
animation: nav-children-slide-down 0.25s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes nav-children-slide-down {
from {
max-height: 0;
opacity: 0;
transform: translateY(-4px);
}
to {
max-height: 500px;
opacity: 1;
transform: translateY(0);
}
}
.nav-group--expanded .nav-group__children {
border-left-color: rgba(245, 166, 35, 0.35);
border-left-color: var(--color-brand-primary-20);
}
/* Collapsed state */
@@ -272,9 +295,10 @@ import { SidebarNavItemComponent, NavItem } from './sidebar-nav-item.component';
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class SidebarNavGroupComponent implements OnInit {
export class SidebarNavGroupComponent implements OnInit, OnDestroy {
private readonly router = inject(Router);
private readonly destroyRef = inject(DestroyRef);
private readonly ngZone = inject(NgZone);
@Input({ required: true }) label!: string;
@Input({ required: true }) icon!: string;
@@ -288,6 +312,10 @@ export class SidebarNavGroupComponent implements OnInit {
/** Reactive active state wired to Router navigation events */
readonly isGroupActive = signal(false);
/** Whether the group was auto-expanded by hover (for animation class) */
readonly autoExpanded = signal(false);
private hoverTimer: ReturnType<typeof setTimeout> | null = null;
ngOnInit(): void {
this.checkActive(this.router.url);
@@ -306,6 +334,32 @@ export class SidebarNavGroupComponent implements OnInit {
}
onToggle(): void {
this.autoExpanded.set(false);
this.expandedChange.emit(!this.expanded);
}
onHeaderMouseEnter(): void {
if (this.expanded || this.collapsed) return;
this.ngZone.runOutsideAngular(() => {
this.hoverTimer = setTimeout(() => {
this.ngZone.run(() => {
this.autoExpanded.set(true);
this.expandedChange.emit(true);
});
}, 1000);
});
}
onHeaderMouseLeave(): void {
if (this.hoverTimer) {
clearTimeout(this.hoverTimer);
this.hoverTimer = null;
}
}
ngOnDestroy(): void {
if (this.hoverTimer) {
clearTimeout(this.hoverTimer);
}
}
}

View File

@@ -274,18 +274,18 @@ export interface NavItem {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5625rem 0.75rem;
padding: 0.5rem 0.75rem;
margin: 0 0.25rem 1px;
color: var(--color-text-secondary);
text-decoration: none;
font-size: 0.875rem;
font-size: 0.8125rem;
font-weight: var(--font-weight-medium);
transition: all 0.15s;
border-radius: var(--radius-md);
transition: all 0.12s;
border-radius: var(--radius-sm);
cursor: pointer;
position: relative;
min-width: 0;
border-left: 3px solid transparent;
border-left: 2px solid transparent;
&:hover {
background: var(--color-nav-hover);
@@ -309,7 +309,7 @@ export interface NavItem {
}
&:hover {
background: rgba(245, 166, 35, 0.12);
background: var(--color-brand-primary-10);
}
}

View File

@@ -80,9 +80,9 @@ import { UserMenuComponent } from '../../shared/components/user-menu/user-menu.c
display: flex;
align-items: center;
gap: 1rem;
height: 52px;
height: 48px;
padding: 0 1.25rem;
background: var(--color-surface-secondary);
background: var(--color-surface-primary);
border-bottom: 1px solid var(--color-border-primary);
position: relative;
}
@@ -92,9 +92,9 @@ import { UserMenuComponent } from '../../shared/components/user-menu/user-menu.c
position: absolute;
bottom: -1px;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, var(--color-brand-primary) 0%, var(--color-brand-secondary) 50%, transparent 100%);
width: 64px;
height: 1px;
background: var(--color-brand-primary);
opacity: 0.4;
}
@@ -163,21 +163,22 @@ import { UserMenuComponent } from '../../shared/components/user-menu/user-menu.c
.topbar__tenant-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-nav-border);
border-radius: var(--radius-lg);
background: var(--color-surface-secondary);
backdrop-filter: blur(8px);
color: var(--color-text-primary);
font-size: 0.875rem;
font-weight: var(--font-weight-medium);
gap: 0.375rem;
padding: 0.375rem 0.625rem;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-secondary);
font-family: var(--font-family-mono);
font-size: 0.6875rem;
font-weight: 500;
letter-spacing: 0.02em;
cursor: pointer;
transition: all 0.15s ease;
transition: all 0.12s ease;
&:hover {
background: var(--color-nav-hover);
box-shadow: var(--shadow-sm);
border-color: var(--color-border-secondary);
color: var(--color-text-primary);
}
&:focus-visible {

View File

@@ -88,14 +88,14 @@ export class BreadcrumbService {
`,
styles: [`
.breadcrumb {
font-size: 0.875rem;
font-size: 0.75rem;
}
.breadcrumb__list {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.25rem;
gap: 0.125rem;
margin: 0;
padding: 0;
list-style: none;
@@ -139,12 +139,13 @@ export class BreadcrumbService {
.breadcrumb__text--current {
color: var(--color-text-primary);
font-weight: var(--font-weight-medium);
font-weight: 500;
}
.breadcrumb__separator {
flex-shrink: 0;
color: var(--color-text-secondary);
color: var(--color-text-muted);
opacity: 0.5;
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -66,7 +66,7 @@ import { PolicyPackStore } from '../../features/policy-studio/services/policy-pa
})
export class PolicyBaselineChipComponent {
private readonly policyPackStore = inject(PolicyPackStore);
private readonly packs = toSignal(this.policyPackStore.getPacks(), {
private readonly packs = toSignal(this.policyPackStore.getPacks({ allowRemoteFetch: false }), {
initialValue: [],
});

View File

@@ -171,17 +171,17 @@ export interface SearchResult {
align-items: center;
gap: 0.5rem;
padding: 0 0.75rem;
height: 36px;
background: var(--color-surface-secondary);
border: 1px solid transparent;
border-radius: var(--radius-md);
transition: border-color 0.15s, background-color 0.15s, box-shadow 0.15s;
height: 34px;
background: var(--color-surface-tertiary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
transition: border-color 0.12s, background-color 0.12s, box-shadow 0.12s;
}
.search--focused .search__input-wrapper {
background: var(--color-surface-primary);
border-color: var(--color-brand-primary);
box-shadow: 0 0 0 2px rgba(245, 166, 35, 0.1);
box-shadow: 0 0 0 1px var(--color-brand-primary-20);
}
.search--has-results .search__input-wrapper {
@@ -198,24 +198,26 @@ export interface SearchResult {
flex: 1;
border: none;
background: transparent;
font-size: 0.875rem;
font-size: 0.8125rem;
color: var(--color-text-primary);
outline: none;
&::placeholder {
color: var(--color-text-secondary);
color: var(--color-text-muted);
font-size: 0.75rem;
}
}
.search__shortcut {
flex-shrink: 0;
padding: 0.125rem 0.375rem;
background: var(--color-surface-secondary);
padding: 1px 5px;
background: transparent;
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-md);
font-size: 0.6875rem;
border-radius: 2px;
font-size: 0.5625rem;
font-family: var(--font-family-mono);
color: var(--color-text-secondary);
color: var(--color-text-muted);
letter-spacing: 0.04em;
}
@media (max-width: 767px) {

View File

@@ -140,7 +140,7 @@ export const LEGACY_REDIRECT_ROUTE_TEMPLATES: readonly LegacyRedirectTemplate[]
{ path: 'admin/issuers', redirectTo: '/settings/trust/issuers', pathMatch: 'full' },
{ path: 'admin/notifications', redirectTo: '/settings/notifications', pathMatch: 'full' },
{ path: 'admin/audit', redirectTo: '/evidence/audit', pathMatch: 'full' },
{ path: 'admin/policy/governance', redirectTo: '/settings/policy/governance', pathMatch: 'full' },
{ path: 'admin/policy/governance', redirectTo: '/policy/governance', pathMatch: 'full' },
{ path: 'concelier/trivy-db-settings', redirectTo: '/settings/security-data/trivy', pathMatch: 'full' },
// ===========================================

View File

@@ -13,12 +13,72 @@ describe('EvidenceDrawerComponent', () => {
let fixture: ComponentFixture<EvidenceDrawerComponent>;
const mockEvidence: ResolutionEvidence = {
evidenceType: 'binary_fingerprint_match',
matchType: 'fingerprint',
confidence: 0.87,
fixConfidence: 0.87,
distroAdvisoryId: 'DSA-5343-1',
patchHash: 'abc123def456789012345678901234567890abcd',
matchedFingerprintIds: ['ssl3_get_record:001', 'tls1_enc:002'],
functionDiffSummary: 'ssl3_get_record() patched; 3 functions changed',
functionDiffSummary: 'ssl3_get_record() patched; 2 functions changed',
changedFunctions: [
{
name: 'ssl3_get_record',
changeType: 'Modified',
},
{
name: 'tls1_enc',
changeType: 'Added',
},
],
hybridDiff: {
semanticEditScriptDigest: 'sha256:semantic',
oldSymbolMapDigest: 'sha256:old-map',
newSymbolMapDigest: 'sha256:new-map',
symbolPatchPlanDigest: 'sha256:plan',
patchManifestDigest: 'sha256:manifest',
semanticEditScript: {
schemaVersion: '1.0.0',
sourceTreeDigest: 'sha256:tree',
edits: [
{
stableId: 'edit-1',
editType: 'update',
nodeKind: 'method',
nodePath: 'src/ssl.c::ssl3_get_record',
anchor: 'ssl3_get_record',
},
],
},
symbolPatchPlan: {
schemaVersion: '1.0.0',
buildIdBefore: 'build-old',
buildIdAfter: 'build-new',
changes: [
{
symbol: 'ssl3_get_record',
changeType: 'modified',
astAnchors: ['ssl3_get_record'],
deltaRef: 'sha256:delta-ref',
},
],
},
patchManifest: {
schemaVersion: '1.0.0',
buildId: 'build-new',
normalizationRecipeId: 'norm-v1',
totalDeltaBytes: 312,
patches: [
{
symbol: 'ssl3_get_record',
addressRange: '0x401120-0x4012AF',
deltaDigest: 'sha256:delta',
pre: { size: 304, hash: 'sha256:pre' },
post: { size: 312, hash: 'sha256:post' },
},
],
},
},
};
const mockResolution: VulnResolutionSummary = {
@@ -114,9 +174,10 @@ describe('EvidenceDrawerComponent', () => {
expect(content).toContain('3.0.7-1+deb12u1');
});
it('should display patch hash', () => {
it('should display patch hash and diff summary', () => {
const content = fixture.nativeElement.textContent;
expect(content).toContain('abc123def456');
expect(content).toContain('ssl3_get_record() patched');
});
});
@@ -129,23 +190,50 @@ describe('EvidenceDrawerComponent', () => {
it('should display changed functions', () => {
const functionItems = fixture.nativeElement.querySelectorAll('.function-list__item');
expect(functionItems.length).toBeGreaterThan(0);
expect(functionItems.length).toBe(2);
});
it('should show function names', () => {
const functionNames = fixture.nativeElement.querySelectorAll('.function-list__name');
const names = Array.from(functionNames).map((el: any) => el.textContent.trim());
expect(names).toContain('ssl3_get_record');
expect(names).toContain('tls1_enc');
});
it('should emit viewDiff when diff button clicked', () => {
spyOn(component.viewDiff, 'emit');
const diffButton = fixture.nativeElement.querySelector('.function-list__diff-btn');
if (diffButton) {
diffButton.click();
expect(component.viewDiff.emit).toHaveBeenCalled();
}
diffButton.click();
expect(component.viewDiff.emit).toHaveBeenCalled();
});
});
describe('hybrid diff sections', () => {
beforeEach(() => {
fixture.componentRef.setInput('evidence', mockEvidence);
fixture.componentRef.setInput('isOpen', true);
fixture.detectChanges();
});
it('should render hybrid diff summary', () => {
const content = fixture.nativeElement.textContent;
expect(content).toContain('Hybrid Diff Summary');
expect(content).toContain('Semantic Edits');
expect(content).toContain('Symbol Changes');
expect(content).toContain('Patch Entries');
});
it('should render symbol patch plan entries', () => {
const symbolItem = fixture.nativeElement.querySelector('.symbol-change-list__symbol');
expect(symbolItem.textContent).toContain('ssl3_get_record');
});
it('should render artifact digests', () => {
const content = fixture.nativeElement.textContent;
expect(content).toContain('Artifact Digests');
expect(content).toContain('sha256:manifest');
});
});
@@ -164,8 +252,9 @@ describe('EvidenceDrawerComponent', () => {
});
it('should show attestation section when DSSE available', () => {
const section = fixture.nativeElement.querySelector('.evidence-drawer__section:last-of-type h4');
expect(section?.textContent).toContain('Attestation');
const headings = Array.from(fixture.nativeElement.querySelectorAll('.evidence-drawer__section h4'))
.map((el: any) => el.textContent.trim());
expect(headings).toContain('Attestation');
});
it('should show copy button for attestation', () => {
@@ -216,7 +305,7 @@ describe('EvidenceDrawerComponent', () => {
describe('confidence gauge styling', () => {
it('should apply high confidence style for >= 80%', () => {
fixture.componentRef.setInput('evidence', { ...mockEvidence, confidence: 0.92 });
fixture.componentRef.setInput('evidence', { ...mockEvidence, fixConfidence: 0.92, confidence: 0.92 });
fixture.componentRef.setInput('isOpen', true);
fixture.detectChanges();
@@ -225,7 +314,7 @@ describe('EvidenceDrawerComponent', () => {
});
it('should apply medium confidence style for 50-79%', () => {
fixture.componentRef.setInput('evidence', { ...mockEvidence, confidence: 0.65 });
fixture.componentRef.setInput('evidence', { ...mockEvidence, fixConfidence: 0.65, confidence: 0.65 });
fixture.componentRef.setInput('isOpen', true);
fixture.detectChanges();
@@ -234,7 +323,7 @@ describe('EvidenceDrawerComponent', () => {
});
it('should apply low confidence style for < 50%', () => {
fixture.componentRef.setInput('evidence', { ...mockEvidence, confidence: 0.35 });
fixture.componentRef.setInput('evidence', { ...mockEvidence, fixConfidence: 0.35, confidence: 0.35 });
fixture.componentRef.setInput('isOpen', true);
fixture.detectChanges();
@@ -348,9 +437,9 @@ describe('EvidenceDrawerComponent', () => {
it('should have focus-visible support on interactive elements', () => {
const buttons = fixture.nativeElement.querySelectorAll('button');
buttons.forEach((button: HTMLElement) => {
// Buttons should have focus styles (checked via CSS)
expect(button.tagName.toLowerCase()).toBe('button');
});
});
});
});

View File

@@ -22,6 +22,9 @@ import type {
ResolutionEvidence,
VulnResolutionSummary,
FunctionChangeInfo,
HybridDiffEvidence,
SymbolPatchChange,
SymbolPatchArtifact,
} from '../../../core/api/binary-resolution.models';
/**
@@ -136,6 +139,24 @@ import type {
</dd>
</div>
}
@if (!ev.patchCommit && ev.patchHash) {
<div>
<dt>Patch Hash</dt>
<dd><code>{{ ev.patchHash | slice:0:16 }}...</code></dd>
</div>
}
@if (ev.matchedFingerprintIds?.length) {
<div>
<dt>Fingerprints</dt>
<dd>{{ ev.matchedFingerprintIds.length }}</dd>
</div>
}
@if (ev.functionDiffSummary) {
<div>
<dt>Diff Summary</dt>
<dd>{{ ev.functionDiffSummary }}</dd>
</div>
}
</dl>
</section>
<!-- Changed Functions -->
@@ -166,6 +187,136 @@ import type {
</ul>
</section>
}
@if (hasHybridDiff()) {
<section class="evidence-drawer__section">
<h4>Hybrid Diff Summary</h4>
<dl class="evidence-drawer__details">
@if (hybridSemanticEditCount() > 0) {
<div>
<dt>Semantic Edits</dt>
<dd>{{ hybridSemanticEditCount() }}</dd>
</div>
}
@if (hybridSymbolChangeCount() > 0) {
<div>
<dt>Symbol Changes</dt>
<dd>{{ hybridSymbolChangeCount() }}</dd>
</div>
}
@if (hybridPatchCount() > 0) {
<div>
<dt>Patch Entries</dt>
<dd>{{ hybridPatchCount() }}</dd>
</div>
}
@if (hybridTotalDeltaBytes() > 0) {
<div>
<dt>Total Delta</dt>
<dd>{{ formatBytes(hybridTotalDeltaBytes()) }}</dd>
</div>
}
@if (hybridBuildId()) {
<div>
<dt>Build ID</dt>
<dd><code>{{ hybridBuildId() }}</code></dd>
</div>
}
@if (hybridRecipeId()) {
<div>
<dt>Recipe</dt>
<dd><code>{{ hybridRecipeId() }}</code></dd>
</div>
}
</dl>
</section>
}
@if (hybridTopSymbolChanges().length > 0) {
<section class="evidence-drawer__section">
<h4>Symbol Patch Plan</h4>
<ul class="symbol-change-list">
@for (change of hybridTopSymbolChanges(); track change.symbol + change.changeType) {
<li class="symbol-change-list__item">
<span class="symbol-change-list__symbol">{{ change.symbol }}</span>
<span class="symbol-change-list__type">{{ change.changeType }}</span>
@if (change.astAnchors?.length) {
<span class="symbol-change-list__anchors">{{ change.astAnchors.length }} anchors</span>
}
</li>
}
</ul>
@if (hybridSymbolChangeCount() > hybridTopSymbolChanges().length) {
<p class="evidence-drawer__hint">
Showing first {{ hybridTopSymbolChanges().length }} of {{ hybridSymbolChangeCount() }} symbol changes.
</p>
}
</section>
}
@if (hybridTopPatchEntries().length > 0) {
<section class="evidence-drawer__section">
<h4>Patch Manifest</h4>
<ul class="patch-list">
@for (patch of hybridTopPatchEntries(); track patch.symbol) {
<li class="patch-list__item">
<div class="patch-list__header">
<span class="patch-list__symbol">{{ patch.symbol }}</span>
<span class="patch-list__delta">{{ formatBytes(getPatchDeltaBytes(patch)) }}</span>
</div>
@if (patch.addressRange) {
<code class="patch-list__address">{{ patch.addressRange }}</code>
}
</li>
}
</ul>
@if (hybridPatchCount() > hybridTopPatchEntries().length) {
<p class="evidence-drawer__hint">
Showing first {{ hybridTopPatchEntries().length }} of {{ hybridPatchCount() }} patch entries.
</p>
}
</section>
}
@if (hybridDiff(); as hybrid) {
@if (hasHybridDiffDigests()) {
<section class="evidence-drawer__section">
<h4>Artifact Digests</h4>
<dl class="evidence-drawer__details evidence-drawer__details--dense">
@if (hybrid.semanticEditScriptDigest) {
<div>
<dt>Edit Script</dt>
<dd><code>{{ hybrid.semanticEditScriptDigest }}</code></dd>
</div>
}
@if (hybrid.oldSymbolMapDigest) {
<div>
<dt>Old Map</dt>
<dd><code>{{ hybrid.oldSymbolMapDigest }}</code></dd>
</div>
}
@if (hybrid.newSymbolMapDigest) {
<div>
<dt>New Map</dt>
<dd><code>{{ hybrid.newSymbolMapDigest }}</code></dd>
</div>
}
@if (hybrid.symbolPatchPlanDigest) {
<div>
<dt>Patch Plan</dt>
<dd><code>{{ hybrid.symbolPatchPlanDigest }}</code></dd>
</div>
}
@if (hybrid.patchManifestDigest) {
<div>
<dt>Manifest</dt>
<dd><code>{{ hybrid.patchManifestDigest }}</code></dd>
</div>
}
</dl>
</section>
}
}
<!-- DSSE Attestation -->
@if (attestationAvailable()) {
<section class="evidence-drawer__section">
@@ -439,6 +590,73 @@ import type {
}
}
.evidence-drawer__details--dense code {
font-size: 0.7rem;
word-break: break-all;
}
.evidence-drawer__hint {
margin: 0.5rem 0 0;
font-size: 0.75rem;
color: var(--color-text-secondary);
}
.symbol-change-list,
.patch-list {
margin: 0;
padding: 0;
list-style: none;
display: grid;
gap: 0.375rem;
}
.symbol-change-list__item,
.patch-list__item {
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-sm);
padding: 0.5rem;
background: var(--color-surface-secondary);
}
.symbol-change-list__item {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.25rem 0.5rem;
align-items: center;
}
.symbol-change-list__symbol,
.patch-list__symbol,
.patch-list__address {
font-family: monospace;
font-size: 0.75rem;
word-break: break-all;
}
.symbol-change-list__type,
.symbol-change-list__anchors,
.patch-list__delta {
font-size: 0.7rem;
color: var(--color-text-secondary);
}
.symbol-change-list__anchors {
grid-column: 1 / -1;
}
.patch-list__header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.patch-list__address {
display: inline-block;
margin-top: 0.375rem;
color: var(--color-text-secondary);
}
// Copy button
.evidence-drawer__copy-btn {
display: block;
@@ -502,8 +720,8 @@ export class EvidenceDrawerComponent implements OnInit, OnDestroy {
/** Compute confidence percentage */
confidencePercent = computed((): number => {
const ev = this.evidence();
if (!ev?.fixConfidence) return 0;
return Math.round(ev.fixConfidence * 100);
const raw = ev?.fixConfidence ?? ev?.confidence ?? this.resolution()?.confidence ?? 0;
return Math.round(raw * 100);
});
/** Get list of changed functions */
@@ -513,6 +731,79 @@ export class EvidenceDrawerComponent implements OnInit, OnDestroy {
return ev.changedFunctions;
});
/** Hybrid diff evidence if available */
hybridDiff = computed((): HybridDiffEvidence | null => {
const ev = this.evidence();
return ev?.hybridDiff ?? null;
});
/** Whether hybrid evidence exists */
hasHybridDiff = computed((): boolean => {
return this.hybridDiff() !== null;
});
/** Number of semantic edits in hybrid evidence */
hybridSemanticEditCount = computed((): number => {
return this.hybridDiff()?.semanticEditScript?.edits?.length ?? 0;
});
/** Number of symbol changes in hybrid evidence */
hybridSymbolChangeCount = computed((): number => {
return this.hybridDiff()?.symbolPatchPlan?.changes?.length ?? 0;
});
/** Number of patch entries in hybrid evidence */
hybridPatchCount = computed((): number => {
return this.hybridDiff()?.patchManifest?.patches?.length ?? 0;
});
/** Total patch delta bytes across patch entries */
hybridTotalDeltaBytes = computed((): number => {
const manifest = this.hybridDiff()?.patchManifest;
if (!manifest) return 0;
if (typeof manifest.totalDeltaBytes === 'number') {
return manifest.totalDeltaBytes;
}
return (manifest.patches ?? []).reduce((sum, patch) => sum + this.getPatchDeltaBytes(patch), 0);
});
/** Build ID tied to the patch manifest */
hybridBuildId = computed((): string | null => {
const hybrid = this.hybridDiff();
return hybrid?.patchManifest?.buildId ?? hybrid?.symbolPatchPlan?.buildIdAfter ?? null;
});
/** Normalization recipe identifier */
hybridRecipeId = computed((): string | null => {
return this.hybridDiff()?.patchManifest?.normalizationRecipeId ?? null;
});
/** Top symbol changes for compact rendering */
hybridTopSymbolChanges = computed((): SymbolPatchChange[] => {
return (this.hybridDiff()?.symbolPatchPlan?.changes ?? []).slice(0, 6);
});
/** Top patch entries for compact rendering */
hybridTopPatchEntries = computed((): SymbolPatchArtifact[] => {
return (this.hybridDiff()?.patchManifest?.patches ?? []).slice(0, 6);
});
/** Whether any digest information is available */
hasHybridDiffDigests = computed((): boolean => {
const hybrid = this.hybridDiff();
if (!hybrid) return false;
return !!(
hybrid.semanticEditScriptDigest ||
hybrid.oldSymbolMapDigest ||
hybrid.newSymbolMapDigest ||
hybrid.symbolPatchPlanDigest ||
hybrid.patchManifestDigest
);
});
/** Check if attestation is available */
attestationAvailable = computed((): boolean => {
return !!this.attestationDsse();
@@ -605,6 +896,26 @@ export class EvidenceDrawerComponent implements OnInit, OnDestroy {
return null;
}
/** Get absolute delta bytes for a patch entry */
getPatchDeltaBytes(patch: SymbolPatchArtifact): number {
if (typeof patch.deltaSizeBytes === 'number') {
return Math.abs(patch.deltaSizeBytes);
}
if (typeof patch.pre?.size === 'number' && typeof patch.post?.size === 'number') {
return Math.abs(patch.post.size - patch.pre.size);
}
return 0;
}
/** Format bytes for compact display */
formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
/** Generate Rekor transparency log link */
rekorLink(): string | null {
const logIndex = this.rekorLogIndex();
@@ -644,3 +955,4 @@ export class EvidenceDrawerComponent implements OnInit, OnDestroy {
}
}
}

View File

@@ -117,6 +117,7 @@
.user-menu__info {
padding: var(--space-2) var(--space-3);
overflow: hidden;
}
.user-menu__info-name {
@@ -129,6 +130,10 @@
font-size: var(--font-size-xs);
color: var(--color-text-muted);
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 100%;
}
// =============================================================================

View File

@@ -47,7 +47,7 @@ import { NavigationService } from '../../../core/navigation';
<!-- User Info -->
<div class="user-menu__info">
<div class="user-menu__info-name">{{ authService.user()?.name || 'User' }}</div>
<div class="user-menu__info-email">{{ authService.user()?.email || '' }}</div>
<div class="user-menu__info-email" [title]="authService.user()?.email || ''">{{ authService.user()?.email || '' }}</div>
</div>
<div class="user-menu__divider"></div>

View File

@@ -8,7 +8,7 @@
"redirectUri": "/auth/callback",
"silentRefreshRedirectUri": "/auth/silent-refresh",
"postLogoutRedirectUri": "/",
"scope": "openid profile email ui.read ui.admin authority:tenants.read authority:users.read authority:roles.read authority:clients.read authority:tokens.read authority:branding.read authority.audit.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve orch:read analytics.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read release:read scheduler:read scheduler:operate notify.viewer notify.operator notify.admin notify.escalate export.viewer export.operator export.admin vuln:view vuln:investigate vuln:operate vuln:audit",
"scope": "openid profile email offline_access ui.read ui.admin authority:tenants.read authority:users.read authority:roles.read authority:clients.read authority:tokens.read authority:branding.read authority.audit.read graph:read sbom:read scanner:read policy:read policy:simulate policy:author policy:review policy:approve orch:read analytics.read advisory:read vex:read exceptions:read exceptions:approve aoc:verify findings:read release:read scheduler:read scheduler:operate notify.viewer notify.operator notify.admin notify.escalate export.viewer export.operator export.admin vuln:view vuln:investigate vuln:operate vuln:audit",
"audience": "/scanner",
"dpopAlgorithms": ["ES256"],
"refreshLeewaySeconds": 60

View File

@@ -10,9 +10,9 @@
<link rel="icon" type="image/png" sizes="16x16" href="assets/icons/icon-16x16.png">
<link rel="apple-touch-icon" sizes="180x180" href="assets/icons/apple-touch-icon.png">
<link rel="manifest" href="manifest.webmanifest">
<meta name="theme-color" content="#F5A623">
<meta name="theme-color" content="#080A12">
<style>
/* Inline splash screen — visible immediately, removed by Angular on bootstrap */
/* Inline splash — visible immediately, removed by Angular on bootstrap */
.stella-splash {
position: fixed;
inset: 0;
@@ -20,48 +20,53 @@
flex-direction: column;
align-items: center;
justify-content: center;
background: #0C1220;
background:
radial-gradient(ellipse 50% 40% at 50% 40%, rgba(212,146,10,0.05) 0%, transparent 70%),
#080A12;
z-index: 99999;
gap: 24px;
gap: 28px;
}
.stella-splash__img {
width: 120px;
height: 120px;
width: 64px;
height: 64px;
object-fit: contain;
animation: splash-breathe 1.8s ease-in-out infinite;
border-radius: 14px;
filter: drop-shadow(0 2px 12px rgba(212,146,10,0.3));
animation: splash-breathe 2s ease-in-out infinite;
}
.stella-splash__text {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
font-weight: 500;
letter-spacing: 0.08em;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: rgba(245, 240, 230, 0.6);
color: rgba(204,136,16,0.5);
}
.stella-splash__dots {
display: flex;
gap: 8px;
.stella-splash__bar {
width: 48px;
height: 2px;
border-radius: 1px;
background: rgba(212,146,10,0.15);
overflow: hidden;
position: relative;
}
.stella-splash__dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #F5A623;
animation: splash-dot 1.4s ease-in-out infinite;
.stella-splash__bar-fill {
position: absolute;
inset: 0;
background: linear-gradient(90deg, transparent, #CC8810, transparent);
animation: splash-slide 1.6s ease-in-out infinite;
}
.stella-splash__dot:nth-child(2) { animation-delay: 0.2s; }
.stella-splash__dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes splash-breathe {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.04); opacity: 0.85; }
50% { transform: scale(1.03); opacity: 0.85; }
}
@keyframes splash-dot {
0%, 80%, 100% { transform: scale(0.6); opacity: 0.3; }
40% { transform: scale(1); opacity: 1; }
@keyframes splash-slide {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
@media (prefers-reduced-motion: reduce) {
.stella-splash__img { animation: none; }
.stella-splash__dot { animation: none; opacity: 0.7; }
.stella-splash__bar-fill { animation: none; opacity: 0.5; }
}
</style>
</head>
@@ -70,10 +75,8 @@
<div class="stella-splash" id="stella-splash">
<img class="stella-splash__img" src="assets/img/site.png" alt="Stella Ops" />
<span class="stella-splash__text">Stella Ops</span>
<div class="stella-splash__dots">
<div class="stella-splash__dot"></div>
<div class="stella-splash__dot"></div>
<div class="stella-splash__dot"></div>
<div class="stella-splash__bar">
<div class="stella-splash__bar-fill"></div>
</div>
</div>
<script>document.getElementById('stella-splash').dataset.ts=Date.now();</script>

View File

@@ -22,9 +22,9 @@
--input-text-disabled: var(--color-text-muted);
// Focus ring
--focus-ring-color: rgba(245, 166, 35, 0.4);
--focus-ring-offset: 2px;
--focus-ring-width: 3px;
--focus-ring-color: rgba(245, 166, 35, 0.3);
--focus-ring-offset: 1px;
--focus-ring-width: 2px;
// Transition
--input-transition: 150ms cubic-bezier(0.4, 0, 0.2, 1);
@@ -56,11 +56,10 @@ select {
color: var(--input-text);
background-color: var(--input-bg);
border: 1px solid var(--input-border);
border-radius: 0.375rem;
border-radius: var(--radius-sm, 4px);
outline: none;
transition:
border-color var(--input-transition),
background-color var(--input-transition),
box-shadow var(--input-transition);
&::placeholder {
@@ -70,15 +69,12 @@ select {
// Hover state
&:hover:not(:disabled):not(:focus) {
border-color: var(--input-border-hover);
background-color: var(--input-bg-hover);
}
// Focus state with ring
&:focus {
border-color: var(--input-border-focus);
box-shadow:
0 0 0 var(--focus-ring-offset) var(--input-bg),
0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color);
box-shadow: 0 0 0 var(--focus-ring-width) var(--focus-ring-color);
}
// Disabled state
@@ -402,36 +398,28 @@ select {
align-items: center;
justify-content: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
font-size: 0.875rem;
padding: 0.5rem 1rem;
font-size: 0.8125rem;
font-weight: 500;
font-family: inherit;
line-height: 1;
text-decoration: none;
border: none;
border-radius: 0.375rem;
border-radius: var(--radius-sm, 4px);
cursor: pointer;
transition:
background-color var(--input-transition),
color var(--input-transition),
box-shadow var(--input-transition),
transform var(--input-transition);
box-shadow var(--input-transition);
&:focus-visible {
outline: none;
box-shadow:
0 0 0 var(--focus-ring-offset) var(--color-surface-primary),
0 0 0 calc(var(--focus-ring-offset) + var(--focus-ring-width)) var(--focus-ring-color);
box-shadow: 0 0 0 var(--focus-ring-width) var(--focus-ring-color);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none !important;
}
&:active:not(:disabled) {
transform: scale(0.98);
}
}
@@ -480,13 +468,13 @@ select {
// Button sizes
.btn--sm {
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
padding: 0.375rem 0.625rem;
font-size: 0.75rem;
}
.btn--lg {
padding: 0.75rem 1.5rem;
font-size: 1rem;
padding: 0.625rem 1.25rem;
font-size: 0.875rem;
}
// Icon-only button

View File

@@ -66,14 +66,14 @@
// Hover Effects
// -----------------------------------------------------------------------------
// Lift on hover (for cards, buttons)
// Lift on hover (subtle — prefer .hover-border for precision aesthetic)
.hover-lift {
transition: transform var(--motion-duration-normal) var(--motion-ease-default),
box-shadow var(--motion-duration-normal) var(--motion-ease-default);
border-color var(--motion-duration-normal) var(--motion-ease-default);
&:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
transform: translateY(-1px);
border-color: var(--color-border-secondary);
}
&:active {
@@ -86,20 +86,20 @@
transition: transform var(--motion-duration-fast) var(--motion-ease-default);
&:hover {
transform: scale(1.02);
transform: scale(1.01);
}
&:active {
transform: scale(0.98);
transform: scale(0.99);
}
}
// Glow on hover
// Glow on hover (muted)
.hover-glow {
transition: box-shadow var(--motion-duration-normal) var(--motion-ease-default);
&:hover {
box-shadow: 0 0 20px var(--color-brand-primary-30);
box-shadow: 0 0 12px var(--color-brand-primary-20);
}
}
@@ -192,9 +192,7 @@
&:focus-visible {
outline: none;
box-shadow:
0 0 0 2px var(--color-surface-primary),
0 0 0 4px var(--color-brand-primary);
box-shadow: 0 0 0 2px var(--focus-ring-color, rgba(245, 166, 35, 0.3));
}
}
@@ -202,9 +200,7 @@
transition: box-shadow var(--motion-duration-fast) var(--motion-ease-default);
&:focus-within {
box-shadow:
0 0 0 2px var(--color-surface-primary),
0 0 0 4px var(--color-brand-primary);
box-shadow: 0 0 0 2px var(--focus-ring-color, rgba(245, 166, 35, 0.3));
}
}
@@ -431,26 +427,21 @@
.card {
background: var(--color-surface-primary);
border: 1px solid var(--color-border-primary);
border-radius: var(--radius-xl, 12px);
box-shadow: var(--shadow-sm);
transition: box-shadow 0.2s ease, border-color 0.2s ease, transform 0.2s ease;
border-radius: var(--radius-lg, 8px);
box-shadow: none;
transition: border-color 0.12s ease;
}
.card-interactive {
transition:
transform var(--motion-duration-normal) var(--motion-ease-default),
box-shadow var(--motion-duration-normal) var(--motion-ease-default),
border-color var(--motion-duration-normal) var(--motion-ease-default);
&:hover {
box-shadow: var(--shadow-md);
border-color: var(--color-border-secondary);
transform: translateY(-1px);
}
&:active {
transform: translateY(0);
box-shadow: 0 4px 12px -2px rgba(0, 0, 0, 0.1);
border-color: var(--color-brand-primary);
}
}

View File

@@ -16,22 +16,22 @@
color-scheme: light;
// ---------------------------------------------------------------------------
// Surface Colors (backgrounds)
// Surface Colors (backgrounds) — warm cream/parchment matching stella-ops.org
// ---------------------------------------------------------------------------
--color-surface-primary: #FFFFFF;
--color-surface-secondary: #FFFCF5;
--color-surface-tertiary: #FFF9ED;
--color-surface-primary: #FFFCF5;
--color-surface-secondary: #FFF9ED;
--color-surface-tertiary: #FEF3E2;
--color-surface-elevated: #FFFFFF;
--color-surface-inverse: #0C1220;
--color-surface-inverse: #070B14;
--color-surface-overlay: rgba(0, 0, 0, 0.5);
// ---------------------------------------------------------------------------
// Text Colors (tuned for readability darker than pure amber)
// Text Colors (warm brown tones matching stella-ops.org)
// ---------------------------------------------------------------------------
--color-text-primary: #2A1F05;
--color-text-heading: #110B00;
--color-text-secondary: #524318;
--color-text-muted: #857A62;
--color-text-primary: #3D2E0A;
--color-text-heading: #1C1200;
--color-text-secondary: #6B5A2E;
--color-text-muted: #9A8F78;
--color-text-inverse: #F5F0E6;
--color-text-link: #D4920A;
--color-text-link-hover: #F5A623;
@@ -111,19 +111,19 @@
--color-status-info-text: #3A5F7A;
// ---------------------------------------------------------------------------
// Header / Navigation (warm, matching product site)
// Header / Navigation (warm parchment tones matching stella-ops.org)
// ---------------------------------------------------------------------------
--color-header-bg: #FFFCF5;
--color-header-text: #2A1F05;
--color-header-text-muted: #524318;
--color-header-text: #3D2E0A;
--color-header-text-muted: #6B5A2E;
--color-nav-bg: #FBF7EE;
--color-nav-bg: #FFF9ED;
--color-nav-border: #D4C9A8;
--color-nav-hover: #F5EDDC;
--color-nav-hover: #FEF3E2;
// Sidebar (slightly tinted not pure white)
--color-sidebar-bg: #F8F4EA;
--color-sidebar-text: #2A1F05;
// Sidebar (warm cream tint)
--color-sidebar-bg: #FFF9ED;
--color-sidebar-text: #3D2E0A;
--color-dropdown-bg: #FFFFFF;
--color-dropdown-border: #D4C9A8;
@@ -255,13 +255,13 @@
color-scheme: dark;
// ---------------------------------------------------------------------------
// Surface Colors (backgrounds)
// Surface Colors (backgrounds) — deep navy with blue undertone (stella-ops.org)
// ---------------------------------------------------------------------------
--color-surface-primary: #0C1220;
--color-surface-secondary: #141C2E;
--color-surface-tertiary: #1E2A42;
--color-surface-elevated: #141C2E;
--color-surface-inverse: #F5F0E6;
--color-surface-inverse: #F0EDE4;
--color-surface-overlay: rgba(0, 0, 0, 0.7);
// ---------------------------------------------------------------------------
@@ -275,15 +275,15 @@
--color-text-link-hover: #FFCF70;
// ---------------------------------------------------------------------------
// Border Colors
// Border Colors (navy tones matching stella-ops.org)
// ---------------------------------------------------------------------------
--color-border-primary: #334155;
--color-border-secondary: #475569;
--color-border-primary: rgba(30, 42, 66, 0.6);
--color-border-secondary: rgba(30, 42, 66, 0.8);
--color-border-focus: #F5B84A;
--color-border-error: #f87171;
// ---------------------------------------------------------------------------
// Brand Colors (adjusted for dark mode contrast)
// Brand Colors (luminous gold matching stella-ops.org dark theme)
// ---------------------------------------------------------------------------
--color-brand-primary: #F5B84A;
--color-brand-primary-hover: #FFCF70;
@@ -296,8 +296,8 @@
--color-brand-primary-30: rgba(245, 184, 74, 0.3);
--color-brand-soft: rgba(245, 184, 74, 0.12);
--color-border-emphasis: rgba(245, 184, 74, 0.3);
--color-card-heading: #d5a00f;
--color-text-heading: #F5F0E6;
--color-card-heading: #F5B84A;
--color-text-heading: #F0EDE4;
// ---------------------------------------------------------------------------
// Severity Colors (adjusted backgrounds for dark mode)
@@ -340,18 +340,18 @@
--color-status-info-text: #60a5fa;
// ---------------------------------------------------------------------------
// Header / Navigation (dark mode)
// Header / Navigation (dark mode — deep navy matching stella-ops.org)
// ---------------------------------------------------------------------------
--color-header-bg: linear-gradient(90deg, #020617 0%, #0C1220 45%, #3D2E0A 100%);
--color-header-bg: linear-gradient(90deg, #070B14 0%, #0C1220 45%, #141C2E 100%);
--color-nav-bg: #020617;
--color-nav-bg: #0A0F1A;
--color-nav-border: #1E2A42;
--color-nav-hover: #141C2E;
--color-sidebar-bg: #0A0F1A;
--color-sidebar-text: #F5F0E6;
--color-sidebar-text: #F0EDE4;
--color-dropdown-bg: #020617;
--color-dropdown-bg: #0C1220;
--color-dropdown-border: #1E2A42;
--color-dropdown-hover: #141C2E;
@@ -372,8 +372,8 @@
// ---------------------------------------------------------------------------
// Scrollbar
// ---------------------------------------------------------------------------
--color-scrollbar-thumb: #475569;
--color-scrollbar-thumb-hover: #64748b;
--color-scrollbar-thumb: #1E2A42;
--color-scrollbar-thumb-hover: #2A3A56;
// ---------------------------------------------------------------------------
// Skeleton Loading
@@ -465,12 +465,12 @@
:root:not([data-theme]) {
color-scheme: dark;
// Surface
// Surface (deep navy matching stella-ops.org)
--color-surface-primary: #0C1220;
--color-surface-secondary: #141C2E;
--color-surface-tertiary: #1E2A42;
--color-surface-elevated: #141C2E;
--color-surface-inverse: #F5F0E6;
--color-surface-inverse: #F0EDE4;
--color-surface-overlay: rgba(0, 0, 0, 0.7);
// Text
@@ -481,12 +481,12 @@
--color-text-link: #F5B84A;
--color-text-link-hover: #FFCF70;
// Borders
--color-border-primary: #334155;
--color-border-secondary: #475569;
// Borders (navy tones)
--color-border-primary: rgba(30, 42, 66, 0.6);
--color-border-secondary: rgba(30, 42, 66, 0.8);
--color-border-focus: #F5B84A;
// Brand
// Brand (luminous gold matching stella-ops.org)
--color-brand-primary: #F5B84A;
--color-brand-primary-hover: #FFCF70;
--color-brand-secondary: #FFD369;
@@ -498,8 +498,8 @@
--color-brand-primary-30: rgba(245, 184, 74, 0.3);
--color-brand-soft: rgba(245, 184, 74, 0.12);
--color-border-emphasis: rgba(245, 184, 74, 0.3);
--color-card-heading: #d5a00f;
--color-text-heading: #F5F0E6;
--color-card-heading: #F5B84A;
--color-text-heading: #F0EDE4;
// Status backgrounds
--color-status-success-bg: rgba(34, 197, 94, 0.15);
@@ -514,12 +514,12 @@
--color-severity-low-bg: rgba(34, 197, 94, 0.2);
--color-severity-info-bg: rgba(59, 130, 246, 0.2);
// Header
--color-header-bg: linear-gradient(90deg, #020617 0%, #0C1220 45%, #3D2E0A 100%);
--color-nav-bg: #020617;
// Header (deep navy matching stella-ops.org)
--color-header-bg: linear-gradient(90deg, #070B14 0%, #0C1220 45%, #141C2E 100%);
--color-nav-bg: #0A0F1A;
--color-sidebar-bg: #0A0F1A;
--color-sidebar-text: #F5F0E6;
--color-dropdown-bg: #020617;
--color-sidebar-text: #F0EDE4;
--color-dropdown-bg: #0C1220;
// Shadows
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
@@ -533,8 +533,8 @@
--shadow-brand-lg: 0 8px 32px rgba(245, 184, 74, 0.1);
// Scrollbar
--color-scrollbar-thumb: #475569;
--color-scrollbar-thumb-hover: #64748b;
--color-scrollbar-thumb: #1E2A42;
--color-scrollbar-thumb-hover: #2A3A56;
// Skeleton
--color-skeleton-base: #141C2E;