feat: add stella-callgraph-node for JavaScript/TypeScript call graph extraction

- Implemented a new tool `stella-callgraph-node` that extracts call graphs from JavaScript/TypeScript projects using Babel AST.
- Added command-line interface with options for JSON output and help.
- Included functionality to analyze project structure, detect functions, and build call graphs.
- Created a package.json file for dependency management.

feat: introduce stella-callgraph-python for Python call graph extraction

- Developed `stella-callgraph-python` to extract call graphs from Python projects using AST analysis.
- Implemented command-line interface with options for JSON output and verbose logging.
- Added framework detection to identify popular web frameworks and their entry points.
- Created an AST analyzer to traverse Python code and extract function definitions and calls.
- Included requirements.txt for project dependencies.

chore: add framework detection for Python projects

- Implemented framework detection logic to identify frameworks like Flask, FastAPI, Django, and others based on project files and import patterns.
- Enhanced the AST analyzer to recognize entry points based on decorators and function definitions.
This commit is contained in:
master
2025-12-19 18:11:59 +02:00
parent 951a38d561
commit 8779e9226f
130 changed files with 19011 additions and 422 deletions

View File

@@ -85,38 +85,38 @@
</select>
</div>
<div class="filter-group">
<label class="filter-group__label">Status</label>
<select
class="filter-group__select"
[value]="statusFilter()"
(change)="setStatusFilter($any($event.target).value)"
>
<option value="all">All Statuses</option>
<option *ngFor="let st of allStatuses" [value]="st">
{{ statusLabels[st] }}
</option>
</select>
</div>
<div class="filter-group">
<label class="filter-group__label">Reachability</label>
<select
class="filter-group__select"
[value]="reachabilityFilter()"
(change)="setReachabilityFilter($any($event.target).value)"
>
<option value="all">All</option>
<option *ngFor="let reach of allReachability" [value]="reach">
{{ reachabilityLabels[reach] }}
</option>
</select>
</div>
<label class="filter-checkbox">
<input
type="checkbox"
[checked]="showExceptedOnly()"
<div class="filter-group">
<label class="filter-group__label">Status</label>
<select
class="filter-group__select"
[value]="statusFilter()"
(change)="setStatusFilter($any($event.target).value)"
>
<option value="all">All Statuses</option>
<option *ngFor="let st of allStatuses" [value]="st">
{{ statusLabels[st] }}
</option>
</select>
</div>
<div class="filter-group">
<label class="filter-group__label">Reachability</label>
<select
class="filter-group__select"
[value]="reachabilityFilter()"
(change)="setReachabilityFilter($any($event.target).value)"
>
<option value="all">All</option>
<option *ngFor="let reach of allReachability" [value]="reach">
{{ reachabilityLabels[reach] }}
</option>
</select>
</div>
<label class="filter-checkbox">
<input
type="checkbox"
[checked]="showExceptedOnly()"
(change)="toggleExceptedOnly()"
/>
<span>Show with exceptions only</span>
@@ -147,14 +147,14 @@
<th class="vuln-table__th vuln-table__th--sortable" (click)="toggleSort('cvssScore')">
CVSS {{ getSortIcon('cvssScore') }}
</th>
<th class="vuln-table__th vuln-table__th--sortable" (click)="toggleSort('status')">
Status {{ getSortIcon('status') }}
</th>
<th class="vuln-table__th">Reachability</th>
<th class="vuln-table__th">Components</th>
<th class="vuln-table__th">Actions</th>
</tr>
</thead>
<th class="vuln-table__th vuln-table__th--sortable" (click)="toggleSort('status')">
Status {{ getSortIcon('status') }}
</th>
<th class="vuln-table__th">Reachability</th>
<th class="vuln-table__th">Components</th>
<th class="vuln-table__th">Actions</th>
</tr>
</thead>
<tbody>
<tr
*ngFor="let vuln of filteredVulnerabilities(); trackBy: trackByVuln"
@@ -188,24 +188,34 @@
{{ formatCvss(vuln.cvssScore) }}
</span>
</td>
<td class="vuln-table__td">
<span class="chip chip--small" [ngClass]="getStatusClass(vuln.status)">
{{ statusLabels[vuln.status] }}
</span>
</td>
<td class="vuln-table__td">
<span
class="chip chip--small"
[ngClass]="getReachabilityClass(vuln)"
[title]="getReachabilityTooltip(vuln)"
>
{{ getReachabilityLabel(vuln) }}
</span>
</td>
<td class="vuln-table__td">
<span class="component-count">{{ vuln.affectedComponents.length }}</span>
</td>
<td class="vuln-table__td vuln-table__td--actions">
<td class="vuln-table__td">
<span class="chip chip--small" [ngClass]="getStatusClass(vuln.status)">
{{ statusLabels[vuln.status] }}
</span>
</td>
<td class="vuln-table__td">
<span
class="chip chip--small"
[ngClass]="getReachabilityClass(vuln)"
[title]="getReachabilityTooltip(vuln)"
>
{{ getReachabilityLabel(vuln) }}
</span>
</td>
<td class="vuln-table__td">
<span class="component-count">{{ vuln.affectedComponents.length }}</span>
</td>
<td class="vuln-table__td vuln-table__td--actions">
<button
type="button"
class="btn btn--small btn--witness"
(click)="openWitnessModal(vuln); $event.stopPropagation()"
*ngIf="hasWitnessData(vuln)"
title="Show reachability witness"
[disabled]="witnessLoading()"
>
<span class="btn__icon">🔍</span> Witness
</button>
<button
type="button"
class="btn btn--small btn--action"
@@ -254,27 +264,48 @@
{{ formatCvss(vuln.cvssScore) }}
</span>
</div>
<div class="detail-item">
<span class="detail-item__label">Status</span>
<span class="chip" [ngClass]="getStatusClass(vuln.status)">
{{ statusLabels[vuln.status] }}
</span>
</div>
<div class="detail-item">
<span class="detail-item__label">Reachability</span>
<span class="chip" [ngClass]="getReachabilityClass(vuln)" [title]="getReachabilityTooltip(vuln)">
{{ getReachabilityLabel(vuln) }}
</span>
<button
type="button"
class="btn btn--secondary btn--small"
(click)="openWhyDrawer()"
[disabled]="!vuln.affectedComponents.length"
>
Why?
</button>
</div>
</div>
<div class="detail-item">
<span class="detail-item__label">Status</span>
<span class="chip" [ngClass]="getStatusClass(vuln.status)">
{{ statusLabels[vuln.status] }}
</span>
</div>
<div class="detail-item">
<span class="detail-item__label">Reachability</span>
<app-confidence-tier-badge
*ngIf="hasWitnessData(vuln)"
[tier]="mapReachabilityToTier(vuln.reachabilityStatus, vuln.reachabilityScore)"
[score]="vuln.reachabilityScore ?? 0"
[showScore]="true"
></app-confidence-tier-badge>
<span
*ngIf="!hasWitnessData(vuln)"
class="chip"
[ngClass]="getReachabilityClass(vuln)"
[title]="getReachabilityTooltip(vuln)"
>
{{ getReachabilityLabel(vuln) }}
</span>
<button
type="button"
class="btn btn--secondary btn--small"
(click)="openWitnessModal(vuln)"
*ngIf="hasWitnessData(vuln)"
[disabled]="witnessLoading()"
>
Show Witness
</button>
<button
type="button"
class="btn btn--secondary btn--small"
(click)="openWhyDrawer()"
[disabled]="!vuln.affectedComponents.length"
*ngIf="!hasWitnessData(vuln)"
>
Why?
</button>
</div>
</div>
<!-- Exception Badge -->
<div class="detail-section" *ngIf="getExceptionBadgeData(vuln) as badgeData">
@@ -337,30 +368,30 @@
</div>
<!-- Actions -->
<div class="detail-panel__actions" *ngIf="!vuln.hasException && !showExceptionDraft()">
<button
type="button"
class="btn btn--primary"
(click)="startExceptionDraft()"
>
Create Exception
</button>
</div>
<app-reachability-why-drawer
[open]="showWhyDrawer()"
[status]="(vuln.reachabilityStatus ?? 'unknown')"
[confidence]="vuln.reachabilityScore ?? null"
[component]="vuln.affectedComponents[0]?.purl ?? null"
[assetId]="vuln.affectedComponents[0]?.assetIds?.[0] ?? null"
(close)="closeWhyDrawer()"
></app-reachability-why-drawer>
<!-- Inline Exception Draft -->
<div class="detail-panel__exception-draft" *ngIf="showExceptionDraft() && exceptionDraftContext()">
<app-exception-draft-inline
[context]="exceptionDraftContext()!"
(created)="onExceptionCreated()"
<div class="detail-panel__actions" *ngIf="!vuln.hasException && !showExceptionDraft()">
<button
type="button"
class="btn btn--primary"
(click)="startExceptionDraft()"
>
Create Exception
</button>
</div>
<app-reachability-why-drawer
[open]="showWhyDrawer()"
[status]="(vuln.reachabilityStatus ?? 'unknown')"
[confidence]="vuln.reachabilityScore ?? null"
[component]="vuln.affectedComponents[0]?.purl ?? null"
[assetId]="vuln.affectedComponents[0]?.assetIds?.[0] ?? null"
(close)="closeWhyDrawer()"
></app-reachability-why-drawer>
<!-- Inline Exception Draft -->
<div class="detail-panel__exception-draft" *ngIf="showExceptionDraft() && exceptionDraftContext()">
<app-exception-draft-inline
[context]="exceptionDraftContext()!"
(created)="onExceptionCreated()"
(cancelled)="cancelExceptionDraft()"
(openFullWizard)="openFullWizard()"
></app-exception-draft-inline>
@@ -378,4 +409,11 @@
></app-exception-explain>
</div>
</div>
<!-- Witness Modal -->
<app-witness-modal
[witness]="witnessModalData()"
[isOpen]="showWitnessModal()"
(close)="closeWitnessModal()"
></app-witness-modal>
</div>

View File

@@ -413,31 +413,31 @@
color: #92400e;
}
.status--excepted {
background: #f3e8ff;
color: #7c3aed;
}
// Reachability chips
.reachability--reachable {
background: #dcfce7;
color: #166534;
}
.reachability--unreachable {
background: #f1f5f9;
color: #475569;
}
.reachability--unknown {
background: #fef3c7;
color: #92400e;
}
// Empty State
.empty-state {
display: flex;
flex-direction: column;
.status--excepted {
background: #f3e8ff;
color: #7c3aed;
}
// Reachability chips
.reachability--reachable {
background: #dcfce7;
color: #166534;
}
.reachability--unreachable {
background: #f1f5f9;
color: #475569;
}
.reachability--unknown {
background: #fef3c7;
color: #92400e;
}
// Empty State
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
@@ -735,6 +735,22 @@
background: #c7d2fe;
}
}
&--witness {
background: #fef3c7;
color: #92400e;
display: inline-flex;
align-items: center;
gap: 0.25rem;
&:hover:not(:disabled) {
background: #fde68a;
}
.btn__icon {
font-size: 0.875rem;
}
}
}
// Explain Modal

View File

@@ -119,6 +119,11 @@ export class VulnerabilityExplorerComponent implements OnInit {
// Why drawer state
readonly showWhyDrawer = signal(false);
// Witness modal state
readonly showWitnessModal = signal(false);
readonly witnessModalData = signal<ReachabilityWitness | null>(null);
readonly witnessLoading = signal(false);
// Constants for template
readonly severityLabels = SEVERITY_LABELS;
readonly statusLabels = STATUS_LABELS;
@@ -397,6 +402,75 @@ export class VulnerabilityExplorerComponent implements OnInit {
this.showWhyDrawer.set(false);
}
// Witness modal methods
async openWitnessModal(vuln: Vulnerability): Promise<void> {
this.witnessLoading.set(true);
try {
// Map reachability status to confidence tier
const tier = this.mapReachabilityToTier(vuln.reachabilityStatus, vuln.reachabilityScore);
// Get or create witness data
const witness = await firstValueFrom(
this.witnessClient.getWitnessForVulnerability(vuln.vulnId)
);
if (witness) {
this.witnessModalData.set(witness);
this.showWitnessModal.set(true);
} else {
// Create a placeholder witness if none exists
const placeholderWitness: ReachabilityWitness = {
witnessId: `witness-${vuln.vulnId}`,
scanId: 'scan-current',
tenantId: 'tenant-default',
vulnId: vuln.vulnId,
cveId: vuln.cveId,
packageName: vuln.affectedComponents[0]?.name ?? 'Unknown',
packageVersion: vuln.affectedComponents[0]?.version,
purl: vuln.affectedComponents[0]?.purl,
confidenceTier: tier,
confidenceScore: vuln.reachabilityScore ?? 0,
isReachable: vuln.reachabilityStatus === 'reachable',
callPath: [],
gates: [],
evidence: {
callGraphHash: undefined,
surfaceHash: undefined,
sbomDigest: undefined,
},
observedAt: new Date().toISOString(),
};
this.witnessModalData.set(placeholderWitness);
this.showWitnessModal.set(true);
}
} catch (error) {
this.showMessage(this.toErrorMessage(error), 'error');
} finally {
this.witnessLoading.set(false);
}
}
closeWitnessModal(): void {
this.showWitnessModal.set(false);
this.witnessModalData.set(null);
}
mapReachabilityToTier(status?: string, score?: number): ConfidenceTier {
if (!status || status === 'unknown') return 'unknown';
if (status === 'unreachable') return 'unreachable';
if (status === 'reachable') {
if (score !== undefined && score >= 0.9) return 'confirmed';
if (score !== undefined && score >= 0.7) return 'likely';
return 'present';
}
return 'unknown';
}
hasWitnessData(vuln: Vulnerability): boolean {
// Show witness button if reachability data exists
return vuln.reachabilityStatus !== undefined && vuln.reachabilityStatus !== null;
}
getReachabilityClass(vuln: Vulnerability): string {
const status = vuln.reachabilityStatus ?? 'unknown';
return `reachability--${status}`;

View File

@@ -0,0 +1,315 @@
/**
* Witness Modal Component Tests.
* Sprint: SPRINT_3700_0005_0001_witness_ui_cli (TEST-001)
*
* Unit tests for the witness modal component.
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { signal } from '@angular/core';
import { WitnessModalComponent } from './witness-modal.component';
import {
ReachabilityWitness,
ConfidenceTier,
WitnessVerificationResult,
} from '../../core/api/witness.models';
import { WitnessMockClient } from '../../core/api/witness.client';
describe('WitnessModalComponent', () => {
let component: WitnessModalComponent;
let fixture: ComponentFixture<WitnessModalComponent>;
let mockWitnessClient: jest.Mocked<WitnessMockClient>;
const mockWitness: ReachabilityWitness = {
witnessId: 'witness-001',
scanId: 'scan-001',
tenantId: 'tenant-001',
vulnId: 'vuln-001',
cveId: 'CVE-2024-12345',
packageName: 'org.example.vulnerable-lib',
packageVersion: '1.2.3',
purl: 'pkg:maven/org.example/vulnerable-lib@1.2.3',
confidenceTier: 'confirmed',
confidenceScore: 0.95,
isReachable: true,
callPath: [
{ nodeId: 'node-1', symbol: 'UserController.getUser', file: 'UserController.java', line: 42 },
{ nodeId: 'node-2', symbol: 'UserService.findUser', file: 'UserService.java', line: 88 },
{ nodeId: 'node-3', symbol: 'JsonParser.parse', file: 'JsonParser.java', line: 156 },
],
entrypoint: {
nodeId: 'entry-1',
symbol: 'UserController.getUser',
file: 'UserController.java',
line: 42,
httpRoute: '/api/users/{id}',
httpMethod: 'GET',
},
sink: {
nodeId: 'sink-1',
symbol: 'JsonParser.parse',
file: 'JsonParser.java',
line: 156,
package: 'org.example.vulnerable-lib',
},
gates: [
{
gateType: 'auth',
symbol: 'AuthFilter.doFilter',
confidence: 0.95,
description: '@RequiresAuth annotation',
},
],
evidence: {
callGraphHash: 'blake3:a1b2c3d4e5f6',
surfaceHash: 'sha256:9f8e7d6c5b4a',
},
signature: {
keyId: 'attestor-stellaops-ed25519',
algorithm: 'ed25519',
signatureValue: 'base64-signature-value',
signedAt: '2025-12-18T10:30:00Z',
},
observedAt: '2025-12-18T10:30:00Z',
};
beforeEach(async () => {
mockWitnessClient = {
verifySignature: jest.fn(),
getWitnessById: jest.fn(),
getWitnessForVulnerability: jest.fn(),
listWitnessesByScan: jest.fn(),
exportWitness: jest.fn(),
} as unknown as jest.Mocked<WitnessMockClient>;
await TestBed.configureTestingModule({
imports: [WitnessModalComponent],
providers: [{ provide: WitnessMockClient, useValue: mockWitnessClient }],
}).compileComponents();
fixture = TestBed.createComponent(WitnessModalComponent);
component = fixture.componentInstance;
});
it('should create', () => {
expect(component).toBeTruthy();
});
describe('when witness is provided', () => {
beforeEach(() => {
fixture.componentRef.setInput('witness', mockWitness);
fixture.componentRef.setInput('isOpen', true);
fixture.detectChanges();
});
it('should display CVE ID', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('CVE-2024-12345');
});
it('should display package name', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('org.example.vulnerable-lib');
});
it('should display confidence tier', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent.toLowerCase()).toContain('confirmed');
});
it('should show path visualization for reachable vulns', () => {
expect(component.pathData()).toBeDefined();
expect(component.pathData()?.steps.length).toBeGreaterThan(0);
});
});
describe('when witness is unreachable', () => {
beforeEach(() => {
const unreachableWitness: ReachabilityWitness = {
...mockWitness,
isReachable: false,
confidenceTier: 'unreachable',
callPath: [],
entrypoint: undefined,
sink: undefined,
};
fixture.componentRef.setInput('witness', unreachableWitness);
fixture.componentRef.setInput('isOpen', true);
fixture.detectChanges();
});
it('should show not reachable message', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('No call path found');
});
});
describe('signature verification', () => {
beforeEach(() => {
fixture.componentRef.setInput('witness', mockWitness);
fixture.componentRef.setInput('isOpen', true);
fixture.detectChanges();
});
it('should show signature status when available', () => {
const compiled = fixture.nativeElement;
expect(compiled.textContent).toContain('attestor-stellaops-ed25519');
});
it('should verify signature on button click', async () => {
const mockResult: WitnessVerificationResult = {
verified: true,
keyId: 'attestor-stellaops-ed25519',
algorithm: 'ed25519',
message: 'Signature valid',
};
mockWitnessClient.verifySignature.mockReturnValue(Promise.resolve(mockResult));
await component.verifySignature();
expect(mockWitnessClient.verifySignature).toHaveBeenCalledWith(mockWitness);
expect(component.verificationResult()).toEqual(mockResult);
});
it('should handle verification failure', async () => {
const failedResult: WitnessVerificationResult = {
verified: false,
keyId: 'attestor-stellaops-ed25519',
algorithm: 'ed25519',
message: 'Invalid signature',
error: 'Signature mismatch',
};
mockWitnessClient.verifySignature.mockReturnValue(Promise.resolve(failedResult));
await component.verifySignature();
expect(component.verificationResult()?.verified).toBe(false);
expect(component.verificationResult()?.error).toBe('Signature mismatch');
});
});
describe('download functionality', () => {
beforeEach(() => {
fixture.componentRef.setInput('witness', mockWitness);
fixture.componentRef.setInput('isOpen', true);
fixture.detectChanges();
});
it('should generate JSON download', () => {
// Mock URL.createObjectURL and document.createElement
const mockUrl = 'blob:mock-url';
global.URL.createObjectURL = jest.fn().mockReturnValue(mockUrl);
global.URL.revokeObjectURL = jest.fn();
const mockAnchor = {
href: '',
download: '',
click: jest.fn(),
};
jest.spyOn(document, 'createElement').mockReturnValue(mockAnchor as unknown as HTMLAnchorElement);
component.downloadJson();
expect(mockAnchor.download).toContain('witness-');
expect(mockAnchor.download).toContain('.json');
expect(mockAnchor.click).toHaveBeenCalled();
});
});
describe('copy functionality', () => {
beforeEach(() => {
fixture.componentRef.setInput('witness', mockWitness);
fixture.componentRef.setInput('isOpen', true);
fixture.detectChanges();
});
it('should copy witness ID to clipboard', async () => {
const mockClipboard = {
writeText: jest.fn().mockResolvedValue(undefined),
};
Object.assign(navigator, { clipboard: mockClipboard });
await component.copyWitnessId();
expect(mockClipboard.writeText).toHaveBeenCalledWith('witness-001');
});
});
describe('modal behavior', () => {
it('should emit close event on backdrop click', () => {
const closeSpy = jest.fn();
component.close.subscribe(closeSpy);
fixture.componentRef.setInput('witness', mockWitness);
fixture.componentRef.setInput('isOpen', true);
fixture.detectChanges();
const backdrop = fixture.nativeElement.querySelector('.witness-modal-backdrop');
backdrop?.click();
expect(closeSpy).toHaveBeenCalled();
});
it('should not emit close when clicking modal content', () => {
const closeSpy = jest.fn();
component.close.subscribe(closeSpy);
fixture.componentRef.setInput('witness', mockWitness);
fixture.componentRef.setInput('isOpen', true);
fixture.detectChanges();
const modal = fixture.nativeElement.querySelector('.witness-modal');
modal?.click();
expect(closeSpy).not.toHaveBeenCalled();
});
});
describe('pathData computed', () => {
it('should transform witness call path to visualization data', () => {
fixture.componentRef.setInput('witness', mockWitness);
fixture.componentRef.setInput('isOpen', true);
fixture.detectChanges();
const pathData = component.pathData();
expect(pathData).toBeDefined();
expect(pathData?.entrypoint).toBeDefined();
expect(pathData?.entrypoint?.symbol).toBe('UserController.getUser');
expect(pathData?.sink?.symbol).toBe('JsonParser.parse');
expect(pathData?.steps.length).toBe(3);
});
it('should include gates in path data', () => {
fixture.componentRef.setInput('witness', mockWitness);
fixture.componentRef.setInput('isOpen', true);
fixture.detectChanges();
const pathData = component.pathData();
expect(pathData?.gates).toBeDefined();
expect(pathData?.gates?.length).toBe(1);
expect(pathData?.gates?.[0].gateType).toBe('auth');
});
});
describe('confidence tier badge', () => {
const tiers: ConfidenceTier[] = ['confirmed', 'likely', 'present', 'unreachable', 'unknown'];
tiers.forEach((tier) => {
it(`should display ${tier} tier correctly`, () => {
const witness: ReachabilityWitness = {
...mockWitness,
confidenceTier: tier,
};
fixture.componentRef.setInput('witness', witness);
fixture.componentRef.setInput('isOpen', true);
fixture.detectChanges();
const badgeElement = fixture.nativeElement.querySelector('app-confidence-tier-badge');
expect(badgeElement).toBeTruthy();
});
});
});
});