Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## v4.3.1

- Added a live terminal progress map for pull-request scans in Sentinel CLI.
- Fixed Sentinel Cloud mode to scan the full PR through Cloud Pulses, pinned to the GitHub head SHA; the API key is sent only to Sentinel Cloud.
- Kept Sentinel Lite as an independent local option that analyzes the PR diff without using Cloud Pulses.

## v4.3.0

- Added `sntnl hardening` with agile, strict, and CI preference profiles, independently configurable toggles, and an informational local audit of package scripts, `.npmrc`, and lockfile signals.
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,24 @@ sntnl doctor --deep
sntnl integrity
```

`sntnl pr` muestra un mapa en vivo del proceso en stderr. Puedes elegir Sentinel
Lite para analizar localmente el diff, o Sentinel Cloud para enviar el repositorio
y número de PR al endpoint completo de análisis de Cloud. La solicitud queda
fijada al SHA que mostró GitHub; Cloud rechaza el inicio si el head cambió. El
modo Cloud requiere una API key con permiso de escaneo y consume un pulso. La key
de Sentinel se envía únicamente a Sentinel Cloud; Sentinel CLI no reenvía el
token de GitHub. El acceso a repositorios privados depende de que Sentinel Cloud
pueda acceder al repositorio.

El endpoint devuelve el resultado consolidado del motor Cloud, no eventos en
vivo de cada analizador interno. El mapa muestra autenticación, PR/SHA, ejecución
y resultado con esa limitación explícita.

El mapa usa color y animación en terminales interactivas, y texto sencillo en
CI o pipes. `NO_COLOR` y `TERM=dumb` desactivan color. Consulta
[`docs/pr-progress-map.md`](./docs/pr-progress-map.md) para el detalle de alcance
y estados.

---

## 📸 Galería Visual de la Interfaz (Sentinel CLI)
Expand Down
48 changes: 48 additions & 0 deletions docs/pr-progress-map.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Pull request scan progress map

The interactive `sntnl pr` flow renders a compact progress map on stderr. It
uses a spinner while a step is active and retains readable text in non-TTY
output. The displayed steps are driven by the selected engine's real actions.

## Sentinel Lite

Lite fetches the pull request diff through GitHub CLI and analyzes that diff on
the local machine. The map shows PR head resolution, diff retrieval, local
analysis, and the resulting findings. Its scope is changed lines and context
provided by the diff; it is not a full repository scan.

## Sentinel Cloud

Cloud mode pins the PR head SHA, checks the stored API key's `remote_scan`
capability, and submits the repository and PR number to `/api/scan/pr`. Cloud
validates that the PR head still matches the pinned SHA before scanning. A
successful full PR scan consumes one pulse. The output reports the returned
engine version, verdict, risk score, findings, and pulse balance when the usage
endpoint is available.

The full PR endpoint returns an aggregate result and no per-analyzer execution
events. Therefore the progress map reports the remote request and aggregate
response only; it does not imply that individual Cloud tools were observed.
Cloud and Lite findings and verdicts are shown using their selected engine's
result, not silently substituted for one another.

## Terminal behavior and credentials

Example Cloud view:

```text
Sentinel CLI | PR #42 | Sentinel Cloud | mapa de análisis
[OK] Metadatos del pull request: COMPLETE — head 3f8a21c4d019
[OK] Pull request fijado por SHA: COMPLETE — Cloud resolverá el PR completo en el SHA 3f8a21c4d019
[OK] Acceso a Sentinel Cloud: COMPLETE — plan Team
[OK] Motor de análisis: COMPLETE — motor Cloud 4.3; 2 hallazgos
[OK] Veredicto y evidencia: COMPLETE — respuesta agregada SUSPICIOUS
i El endpoint devuelve un resultado agregado; no publica eventos internos de sus analizadores.
```

Progress is written to stderr. Colors and redraw animation are enabled only for
interactive terminals and are disabled by `NO_COLOR` or `TERM=dumb`. The API key
is sent only as a bearer token over the configured Sentinel Cloud URL; the map
prints its label or plan, never the token. Sentinel CLI does not forward its
GitHub CLI credential to Cloud. Public repositories can be scanned directly;
private repository access depends on Cloud's configured GitHub access.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "sntnl-cli",
"version": "4.3.0",
"version": "4.3.1",
"description": "Sentinel Security Intelligence — SAST, supply chain, threat intel, and Skills/MCP for AI coding agents.",
"main": "dist/cli/main.js",
"bin": {
Expand Down
34 changes: 34 additions & 0 deletions src/cli/cloud/cloud_client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
getResolvedBaseUrl,
resolveToken,
fetchLookup,
fetchCloudPRScan,
} from './cloud_client';
import type { CapabilitiesEnvelope, Session } from './cloud_client';

Expand Down Expand Up @@ -222,6 +223,39 @@ describe('cloud_client fetchCapabilities', () => {
});
});

describe('cloud_client fetchCloudPRScan', () => {
const fetchMock = vi.fn();

beforeEach(() => vi.stubGlobal('fetch', fetchMock));
afterEach(() => vi.unstubAllGlobals());

it('sends the full PR coordinates, expected SHA, bearer key, and idempotency key', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({ level1: { status: 'PASS', score: 0 }, level3: { samples: [] }, engine_version: '5.0' }), { status: 200 }));
const result = await fetchCloudPRScan('acme/app', 17, 'a'.repeat(40), 'sentinel-secret', 'https://cloud.example.com///', { idempotencyKey: 'scan-id-1' });
expect(result.ok).toBe(true);
const [url, request] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe('https://cloud.example.com/api/scan/pr');
expect(request.headers).toMatchObject({ Authorization: 'Bearer sentinel-secret', 'Idempotency-Key': 'scan-id-1' });
expect(JSON.parse(String(request.body))).toEqual({ repo: 'acme/app', prNumber: 17, expectedHeadSha: 'a'.repeat(40) });
});

it('returns the Cloud result without translating the engine verdict', async () => {
fetchMock.mockResolvedValue(new Response(JSON.stringify({ level1: { status: 'DEGRADED', score: 32 }, level3: { samples: [{ severity: 'HIGH' }] } }), { status: 200 }));
const result = await fetchCloudPRScan('acme/app', 17, 'a'.repeat(40), 'key', 'https://cloud.example.com');
expect(result.ok).toBe(true);
if (result.ok) expect(result.data.level1.status).toBe('DEGRADED');
});

it('surfaces auth, quota, and stale-head responses as distinct errors', async () => {
for (const [status, kind] of [[401, 'auth'], [429, 'quota'], [409, 'conflict']] as const) {
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ error: 'rejected' }), { status }));
const result = await fetchCloudPRScan('acme/app', 17, 'a'.repeat(40), 'key', 'https://cloud.example.com');
expect(result.ok).toBe(false);
if (!result.ok) expect(result.kind).toBe(kind);
}
});
});

describe('cloud_client loginWithToken', () => {
let fetchMock: ReturnType<typeof vi.fn>;

Expand Down
68 changes: 68 additions & 0 deletions src/cli/cloud/cloud_client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,74 @@ export interface RemoteScanResult {
explanation?: string[];
}

/** Full hosted pull request scan returned by Sentinel Cloud's PR engine. */
export interface CloudPRScanResult {
level1: {
status?: string;
score?: number;
filesAnalyzed?: number;
files_analyzed?: number;
[key: string]: unknown;
};
level3?: { samples?: Array<Record<string, unknown>> };
engine_version?: string;
jobId?: string;
scan_degraded?: boolean;
degraded_reasons?: string[];
breakdown?: Record<string, unknown>;
[key: string]: unknown;
}

export type CloudPRScanFetchResult =
| { ok: true; data: CloudPRScanResult }
| { ok: false; kind: 'auth' | 'forbidden' | 'quota' | 'busy' | 'bad_request' | 'conflict' | 'network'; status?: number; error?: string };

function validateCloudPRScan(value: unknown): CloudPRScanResult | null {
if (!isRecord(value) || !isRecord(value.level1)) return null;
if (value.level3 !== undefined && (!isRecord(value.level3) || (value.level3.samples !== undefined && !Array.isArray(value.level3.samples)))) return null;
return value as unknown as CloudPRScanResult;
}

/** Requests the complete Cloud PR scan. The bearer key is sent only to Sentinel Cloud. */
export async function fetchCloudPRScan(
repo: string,
prNumber: number,
expectedHeadSha: string,
token: string,
baseUrl: string,
opts?: { timeoutMs?: number; idempotencyKey?: string },
): Promise<CloudPRScanFetchResult> {
const url = baseUrl.replace(/\/+$/, '') + '/api/scan/pr';
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), opts?.timeoutMs ?? 180000);
try {
const res = await fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
Accept: 'application/json',
'Idempotency-Key': opts?.idempotencyKey ?? randomUUID(),
},
body: JSON.stringify({ repo, prNumber, expectedHeadSha }),
signal: controller.signal,
});
if (res.status === 401) return { ok: false, kind: 'auth', status: 401 };
if (res.status === 403) return { ok: false, kind: 'forbidden', status: 403, error: await readErrorBody(res) };
if (res.status === 429) return { ok: false, kind: 'quota', status: 429, error: await readErrorBody(res) };
if (res.status === 503) return { ok: false, kind: 'busy', status: 503, error: await readErrorBody(res) };
if (res.status === 409) return { ok: false, kind: 'conflict', status: 409, error: await readErrorBody(res) };
if (res.status === 400 || res.status === 413) return { ok: false, kind: 'bad_request', status: res.status, error: await readErrorBody(res) };
if (!res.ok) return { ok: false, kind: 'network', status: res.status, error: await readErrorBody(res) };
const data = validateCloudPRScan(await res.json());
return data ? { ok: true, data } : { ok: false, kind: 'network', status: res.status, error: 'Invalid Cloud PR scan response.' };
} catch (err) {
return { ok: false, kind: 'network', error: err instanceof Error ? err.message : String(err) };
} finally {
clearTimeout(timer);
}
}

export type RemoteScanFetchResult =
| { ok: true; data: RemoteScanResult }
| {
Expand Down
Loading
Loading