Skip to content
Open
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
14 changes: 7 additions & 7 deletions .github/actions/sentinel-pr-audit/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ branding:

inputs:
sentinel-version:
description: 'Version of @sentinel/cli to install (npm install -g @sentinel/cli@<version>)'
description: 'Deprecated. The CLI is built and installed from the checked-out repository.'
required: false
default: 'latest'
default: 'local'
github-token:
description: 'GitHub token for API calls (defaults to github.token)'
required: false
Expand All @@ -27,12 +27,12 @@ runs:
with:
node-version: 20

- name: Install Sentinel CLI
- name: Install Sentinel CLI from checkout
shell: bash
env:
SENTINEL_VERSION: ${{ inputs.sentinel-version }}
run: |
npm install -g @sentinel/cli@"$SENTINEL_VERSION"
npm ci
npm run build
npm install -g .

- name: Audit PR
id: audit
Expand All @@ -46,7 +46,7 @@ runs:
SENTINEL_OUTPUT: /tmp/sentinel-result.json
GH_TOKEN: ${{ inputs.github-token }}
run: |
sentinel pr-audit
sntnl pr-audit

- name: Report failure
if: failure() && steps.audit.outcome == 'failure'
Expand Down
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ Sentinel is a deterministic, local-first security intelligence platform. It scan

It also integrates with AI coding agents (Claude, Cursor, Cline, Windsurf, OpenCode, Roo, Gemini, Codex) via platform-specific skills and a Model Context Protocol (MCP) server, but Sentinel works standalone as a CLI security tool for any development workflow.


## Part of the Sentinel platform

| Product | Purpose | When to use |
|---------|---------|-------------|
| **[Sentinel CLI](https://github.com/javier20dev25/sentinel-cli)** | Local-first SAST, supply chain audit, build intelligence, skills/MCP for AI agents | Run locally, in CI, or as part of agent workflows. No account required for core scans. |
| **[Sentinel Audit](https://github.com/javier20dev25/sentinel-audit)** | Focused audit workflows (deps/PRs/CI) and reporting | Structured audits with tighter reporting/automation. |
| **[Sentinel Cloud](https://github.com/javier20dev25/sentinel-cloud)** | Web dashboard, team workflows, full-engine scans with pulses | When you need shared intelligence, collaboration, or the full engine. |
| **[Sentinel Oracle](https://github.com/javier20dev25/sentinel-oracle)** | Merge authorization/enforcement server (gate PR merges) | When you need to enforce "authorize before merge" with isolated enforcement. |

> **Tip:** If Sentinel Cloud is temporarily unreachable or you hit auth/connection issues, use the CLI directly (`sntnl scan`, `sntnl audit-deps`) — it works standalone without Cloud.

---

## Contents
Expand Down
7 changes: 7 additions & 0 deletions dist/cli/intelligence/lockfile_parser.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ export interface LockfileResult {
format: 'npm-v6' | 'npm-v7' | 'yarn' | 'unknown';
}
export declare class LockfileParser {
/**
* Detects lockfiles that are recognized by filename but NOT parseable by this
* parser (pnpm, Yarn Berry). Returns a human-readable reason, or null when the
* file is either supported or unrecognized. This lets callers report an
* explicit INCOMPLETE status instead of a misleading "no dependencies".
*/
detectUnsupported(filename: string, content: string): string | null;
detectFormat(content: string): string;
parsePackageLock(content: string): LockfileResult;
private parseNpmV7;
Expand Down
16 changes: 16 additions & 0 deletions dist/cli/intelligence/lockfile_parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,22 @@ Object.defineProperty(exports, "__esModule", { value: true });
exports.LockfileParser = void 0;
const fs = __importStar(require("fs"));
class LockfileParser {
/**
* Detects lockfiles that are recognized by filename but NOT parseable by this
* parser (pnpm, Yarn Berry). Returns a human-readable reason, or null when the
* file is either supported or unrecognized. This lets callers report an
* explicit INCOMPLETE status instead of a misleading "no dependencies".
*/
detectUnsupported(filename, content) {
const base = filename.toLowerCase();
if (base.endsWith('pnpm-lock.yaml')) {
return 'pnpm-lock.yaml (pnpm) is not supported by the parser';
}
if (base.endsWith('yarn.lock') && /^__metadata:/m.test(content)) {
return 'Yarn Berry (v2+) lockfile is not supported by the parser';
}
return null;
}
detectFormat(content) {
const trimmed = content.trim();
if (!trimmed)
Expand Down
55 changes: 54 additions & 1 deletion dist/cli/intelligence/osv_integrator.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export interface OSVResult {
export declare class OSVIntegrator {
private static readonly API_URL;
private static readonly BATCH_API_URL;
private static readonly VULN_API_URL;
/**
* Query OSV.dev for known vulnerabilities affecting a package@version.
* Uses the public API (no API key required).
Expand All @@ -38,15 +39,67 @@ export declare class OSVIntegrator {
name: string;
version: string;
}[]): Promise<OSVResult[]>;
/**
* OSV's /querybatch endpoint returns only minimal vuln stubs (id + modified)
* — no severity, no affected ranges. This hydrates each unique advisory with
* its full /v1/vulns/{id} document so source severity and fixed versions are
* actually available. Hydration is deduplicated by advisory ID and bounded to
* 8 concurrent requests. Advisories that fail to hydrate keep their stub
* (later reported as UNKNOWN, never fabricated).
*/
hydrateResults(results: OSVResult[]): Promise<OSVResult[]>;
private fetchVuln;
/**
* Normalize a published severity label (OSV/GHSA `database_specific.severity`,
* or CVSS scalar strings) to Sentinel's CRITICAL/HIGH/MEDIUM/LOW vocabulary.
* Returns 'UNKNOWN' when the source value is not a recognized severity — it
* must never be silently coerced into MEDIUM.
*/
static normalizeSeverity(value: string): string;
/**
* Extract a numeric CVSS score from an OSV `severity[].score` value.
* The value may be either a bare number ("7.5") or a CVSS **vector**
* ("CVSS:3.1/AV:N/..."). CVSS v3.x vectors are scored; CVSS v4.0 vectors
* return null because their base metric (MacroVector) is not implemented
* here — callers must fall back to the published severity label.
*/
static severityScoreOf(raw: string): number | null;
/**
* Compute the CVSS v3.0 / v3.1 base score from a vector string.
* Implements the official base-score formula (FIRST spec). Returns null on
* malformed vectors.
*/
static parseCvssV3Vector(vector: string): number | null;
/**
* Resolve the **published** severity of an advisory plus its provenance,
* without ever fabricating a level. Priority:
* 1. database_specific.severity (OSV/GitHub published label)
* 2. CVSS v3.x vector → numeric base score
* 3. numeric CVSS score string
* 4. CVSS v4.0 vector is recorded but left UNKNOWN (not scored here)
* 5. otherwise UNKNOWN with source 'none'
*/
static getSourceSeverity(vuln: OSVVuln): {
level: string;
source: string;
cvssType?: string;
cvssVector?: string;
cvssScore?: number;
};
/**
* Collect the first-fixed versions declared by an advisory's affected ranges.
*/
static getFixedVersions(vuln: OSVVuln): string[];
/**
* Get the highest CVSS score from a vulnerability's severity array.
* Accepts both numeric score strings and CVSS v3.x vectors.
*/
static getMaxSeverity(vuln: OSVVuln): {
type: string;
score: number;
} | null;
/**
* Map OSV severity to Sentinel severity
* Map a numeric CVSS score to Sentinel severity.
*/
static toSentinelSeverity(cvssScore: number): string;
}
195 changes: 192 additions & 3 deletions dist/cli/intelligence/osv_integrator.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,21 +114,209 @@ class OSVIntegrator {
}
});
}
/**
* OSV's /querybatch endpoint returns only minimal vuln stubs (id + modified)
* — no severity, no affected ranges. This hydrates each unique advisory with
* its full /v1/vulns/{id} document so source severity and fixed versions are
* actually available. Hydration is deduplicated by advisory ID and bounded to
* 8 concurrent requests. Advisories that fail to hydrate keep their stub
* (later reported as UNKNOWN, never fabricated).
*/
hydrateResults(results) {
return __awaiter(this, void 0, void 0, function* () {
const ids = new Set();
for (const r of results)
for (const v of r.vulnerabilities)
if (v.id)
ids.add(v.id);
if (ids.size === 0)
return results;
const list = Array.from(ids);
const cache = new Map();
const CONCURRENCY = 8;
for (let i = 0; i < list.length; i += CONCURRENCY) {
const slice = list.slice(i, i + CONCURRENCY);
const docs = yield Promise.all(slice.map(id => this.fetchVuln(id)));
docs.forEach((d, idx) => { if (d)
cache.set(slice[idx], d); });
}
return results.map(r => (Object.assign(Object.assign({}, r), { vulnerabilities: r.vulnerabilities.map(v => cache.get(v.id) || v) })));
});
}
fetchVuln(id) {
return __awaiter(this, void 0, void 0, function* () {
try {
const response = yield fetch(`${OSVIntegrator.VULN_API_URL}/${encodeURIComponent(id)}`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(10000)
});
if (!response.ok)
return null;
const v = (yield response.json());
return {
id: v.id,
summary: v.summary || '',
aliases: v.aliases || [],
severity: v.severity || [],
published: v.published || '',
modified: v.modified || '',
database_specific: v.database_specific,
affected: v.affected
};
}
catch (_a) {
return null;
}
});
}
/**
* Normalize a published severity label (OSV/GHSA `database_specific.severity`,
* or CVSS scalar strings) to Sentinel's CRITICAL/HIGH/MEDIUM/LOW vocabulary.
* Returns 'UNKNOWN' when the source value is not a recognized severity — it
* must never be silently coerced into MEDIUM.
*/
static normalizeSeverity(value) {
const u = String(value || '').trim().toUpperCase();
if (u === 'MODERATE')
return 'MEDIUM';
if (u === 'CRITICAL' || u === 'HIGH' || u === 'MEDIUM' || u === 'LOW')
return u;
return 'UNKNOWN';
}
/**
* Extract a numeric CVSS score from an OSV `severity[].score` value.
* The value may be either a bare number ("7.5") or a CVSS **vector**
* ("CVSS:3.1/AV:N/..."). CVSS v3.x vectors are scored; CVSS v4.0 vectors
* return null because their base metric (MacroVector) is not implemented
* here — callers must fall back to the published severity label.
*/
static severityScoreOf(raw) {
if (!raw)
return null;
const trimmed = raw.trim();
if (/^[0-9]/.test(trimmed)) {
const n = parseFloat(trimmed);
return isNaN(n) ? null : n;
}
if (trimmed.startsWith('CVSS:3.0/') || trimmed.startsWith('CVSS:3.1/')) {
return OSVIntegrator.parseCvssV3Vector(trimmed);
}
return null;
}
/**
* Compute the CVSS v3.0 / v3.1 base score from a vector string.
* Implements the official base-score formula (FIRST spec). Returns null on
* malformed vectors.
*/
static parseCvssV3Vector(vector) {
try {
if (!/^CVSS:3\.[01]\//.test(vector))
return null;
const metric = (k) => {
const m = vector.match(new RegExp('(?:^|/)' + k + ':([A-Za-z])'));
return m ? m[1] : '';
};
const AV = { N: 0.85, A: 0.62, L: 0.55, P: 0.2 }[metric('AV')];
const AC = { L: 0.77, H: 0.44 }[metric('AC')];
const UI = { N: 0.85, R: 0.62 }[metric('UI')];
const S = metric('S');
const CIA = { N: 0.0, L: 0.22, H: 0.56 };
const C = CIA[metric('C')];
const I = CIA[metric('I')];
const A = CIA[metric('A')];
const prTable = S === 'C'
? { N: 0.85, L: 0.68, H: 0.5 }
: { N: 0.85, L: 0.62, H: 0.27 };
const PR = prTable[metric('PR')];
if ([AV, AC, UI, C, I, A, PR].some(v => v === undefined) || (S !== 'U' && S !== 'C')) {
return null;
}
const iss = 1 - ((1 - C) * (1 - I) * (1 - A));
const impact = S === 'U'
? 6.42 * iss
: 7.52 * (iss - 0.029) - 3.25 * Math.pow(iss - 0.02, 15);
if (impact <= 0)
return 0;
const exploitability = 8.22 * AV * AC * PR * UI;
const roundup = (x) => Math.ceil(x * 10) / 10;
const base = S === 'U'
? roundup(Math.min(impact + exploitability, 10))
: roundup(Math.min(1.08 * (impact + exploitability), 10));
return base;
}
catch (_a) {
return null;
}
}
/**
* Resolve the **published** severity of an advisory plus its provenance,
* without ever fabricating a level. Priority:
* 1. database_specific.severity (OSV/GitHub published label)
* 2. CVSS v3.x vector → numeric base score
* 3. numeric CVSS score string
* 4. CVSS v4.0 vector is recorded but left UNKNOWN (not scored here)
* 5. otherwise UNKNOWN with source 'none'
*/
static getSourceSeverity(vuln) {
const db = vuln.database_specific && vuln.database_specific.severity;
if (typeof db === 'string' && db.trim()) {
return { level: OSVIntegrator.normalizeSeverity(db), source: 'osv.database_specific.severity' };
}
let best = null;
for (const s of (vuln.severity || [])) {
const num = OSVIntegrator.severityScoreOf(s.score);
if (num !== null && (!best || num > best.score)) {
best = { type: s.type, score: num, vector: s.score };
}
}
if (best) {
const fromVector = /^CVSS:/.test(best.vector);
return {
level: OSVIntegrator.toSentinelSeverity(best.score),
source: fromVector ? 'osv.severity.cvss_vector' : 'osv.severity.cvss_score',
cvssType: best.type,
cvssVector: best.vector,
cvssScore: best.score
};
}
const v4 = (vuln.severity || []).find(s => s.type === 'CVSS_V4' || /^CVSS:4\.0\//.test(s.score || ''));
if (v4) {
return { level: 'UNKNOWN', source: 'osv.severity.cvss_v4_unmapped', cvssType: v4.type, cvssVector: v4.score };
}
return { level: 'UNKNOWN', source: 'none' };
}
/**
* Collect the first-fixed versions declared by an advisory's affected ranges.
*/
static getFixedVersions(vuln) {
const out = new Set();
for (const a of (vuln.affected || [])) {
for (const r of (a.ranges || [])) {
for (const e of (r.events || [])) {
if (e && typeof e.fixed === 'string' && e.fixed)
out.add(e.fixed);
}
}
}
return Array.from(out).sort();
}
/**
* Get the highest CVSS score from a vulnerability's severity array.
* Accepts both numeric score strings and CVSS v3.x vectors.
*/
static getMaxSeverity(vuln) {
let max = null;
for (const s of vuln.severity) {
const score = parseFloat(s.score);
if (!isNaN(score) && (!max || score > max.score)) {
const score = OSVIntegrator.severityScoreOf(s.score);
if (score !== null && (!max || score > max.score)) {
max = { type: s.type, score };
}
}
return max;
}
/**
* Map OSV severity to Sentinel severity
* Map a numeric CVSS score to Sentinel severity.
*/
static toSentinelSeverity(cvssScore) {
if (cvssScore >= 9.0)
Expand All @@ -143,3 +331,4 @@ class OSVIntegrator {
exports.OSVIntegrator = OSVIntegrator;
OSVIntegrator.API_URL = 'https://api.osv.dev/v1/query';
OSVIntegrator.BATCH_API_URL = 'https://api.osv.dev/v1/querybatch';
OSVIntegrator.VULN_API_URL = 'https://api.osv.dev/v1/vulns';
Loading
Loading