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
49 changes: 47 additions & 2 deletions dist/action.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -26532,6 +26532,29 @@ var MODERATE_PATTERNS = /* @__PURE__ */ new Set([
"deser:yaml.load",
"deser:generic"
]);
var PATTERN_FAMILIES = {
"dynamic-import": [
"exec:importlib",
"exec:__import__",
"exec:dynamic-require",
"exec:dynamic-load-path"
],
eval: ["exec:eval", "exec:exec", "exec:compile", "exec:Function", "exec:vm"],
shell: [
"shell:os.system",
"shell:os.popen",
"shell:subprocess",
"shell:execSync",
"shell:spawnSync",
"shell:execFileSync",
"shell:require-child_process"
],
deser: ["deser:pickle", "deser:marshal", "deser:yaml.load", "deser:generic"]
};
var LABEL_TO_FAMILY = /* @__PURE__ */ new Map();
for (const members of Object.values(PATTERN_FAMILIES)) {
for (const label of members) LABEL_TO_FAMILY.set(label, members);
}
function patternSeverity(label) {
if (CRITICAL_PATTERNS.has(label)) return "critical";
if (HIGH_PATTERNS.has(label)) return "high";
Expand All @@ -26557,14 +26580,36 @@ function higher(a, b2) {
if (!a) return b2;
return SEVERITY_RANK[a] >= SEVERITY_RANK[b2] ? a : b2;
}
function countByLabel(findings) {
const m2 = /* @__PURE__ */ new Map();
for (const { label } of findings) m2.set(label, (m2.get(label) ?? 0) + 1);
return m2;
}
function effectivePatternSeverity(label, oldCount, newCount) {
const base = patternSeverity(label);
if (base === "critical") return base;
const family = LABEL_TO_FAMILY.get(label);
if (!family) return base;
const oldTotal = family.reduce((n, l) => n + (oldCount.get(l) ?? 0), 0);
const newTotal = family.reduce((n, l) => n + (newCount.get(l) ?? 0), 0);
const netIncrease = newTotal - oldTotal;
const threshold = Math.max(3, Math.ceil(oldTotal * 0.2));
if (netIncrease < threshold) return "low";
return base;
}
function packageMaxSeverity(pkg) {
let s3 = null;
for (const v2 of pkg.knownVulns ?? []) {
const vs2 = osvSeverity(v2.severity);
if (vs2) s3 = higher(s3, vs2);
}
for (const f2 of pkg.securityFindings?.delta ?? []) {
s3 = higher(s3, patternSeverity(f2.label));
if (pkg.securityFindings?.delta.length) {
const { old: oldF, new: newF, delta } = pkg.securityFindings;
const oldCount = countByLabel(oldF);
const newCount = countByLabel(newF);
for (const f2 of delta) {
s3 = higher(s3, effectivePatternSeverity(f2.label, oldCount, newCount));
}
}
if ((pkg.binaryFindings?.delta.length ?? 0) > 0) s3 = higher(s3, "high");
if (pkg.securityFindings?.platformDivergence) s3 = higher(s3, "high");
Expand Down
2 changes: 1 addition & 1 deletion dist/action.cjs.map

Large diffs are not rendered by default.

39 changes: 39 additions & 0 deletions src/core/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,8 @@ function formatPackage(pkg: PackageAnalysis, idx: number, total: number): string
'\nSECURITY SCAN' +
divergenceWarning +
'\n' +
formatFindingsSummary(oldF, newF, delta) +
'\n' +
formatFindings(oldF, 'old_hits') +
'\n' +
formatFindings(newF, 'new_hits') +
Expand Down Expand Up @@ -337,6 +339,43 @@ function formatBinaryFindings(findings: BinaryFinding[]): string {
return lines.join('\n');
}

function formatFindingsSummary(
oldF: SecurityFinding[],
newF: SecurityFinding[],
delta: SecurityFinding[],
): string {
const oldCount = new Map<string, number>();
for (const { label } of oldF) oldCount.set(label, (oldCount.get(label) ?? 0) + 1);

const newCount = new Map<string, number>();
for (const { label } of newF) newCount.set(label, (newCount.get(label) ?? 0) + 1);

const deltaCount = new Map<string, number>();
for (const { label } of delta) deltaCount.set(label, (deltaCount.get(label) ?? 0) + 1);

const allLabels = new Set([...oldCount.keys(), ...newCount.keys()]);
if (allLabels.size === 0) return ' by pattern (old → new): none';

// Delta labels first (most new hits), then labels that only dropped
const sorted = [...allLabels].sort((a, b) => {
const dDiff = (deltaCount.get(b) ?? 0) - (deltaCount.get(a) ?? 0);
if (dDiff !== 0) return dDiff;
return (newCount.get(b) ?? 0) - (newCount.get(a) ?? 0);
});

const lines = [' by pattern (old → new, net):'];
for (const label of sorted) {
const o = oldCount.get(label) ?? 0;
const n = newCount.get(label) ?? 0;
const net = n - o;
const netStr = net > 0 ? `+${net}` : `${net}`;
const d = deltaCount.get(label) ?? 0;
const deltaStr = d > 0 ? ` [${d} in delta]` : '';
lines.push(` ${label}: ${o} → ${n} (${netStr})${deltaStr}`);
}
return lines.join('\n');
}

function formatFindings(findings: SecurityFinding[], label: string): string {
if (findings.length === 0) return ` ${label}: none`;

Expand Down
68 changes: 65 additions & 3 deletions src/github/severity.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { PackageAnalysis, SecurityReport } from '../types.js';
import type { PackageAnalysis, SecurityFinding, SecurityReport } from '../types.js';

export type Severity = 'critical' | 'high' | 'moderate' | 'low';

Expand Down Expand Up @@ -41,6 +41,34 @@ const MODERATE_PATTERNS = new Set([
'deser:generic',
]);

// Groups of labels that represent the same underlying capability. When the family's
// net hit count doesn't grow, a new delta hit is a lateral refactor rather than new
// attack surface and is downgraded to 'low'.
const PATTERN_FAMILIES: Record<string, string[]> = {
'dynamic-import': [
'exec:importlib',
'exec:__import__',
'exec:dynamic-require',
'exec:dynamic-load-path',
],
eval: ['exec:eval', 'exec:exec', 'exec:compile', 'exec:Function', 'exec:vm'],
shell: [
'shell:os.system',
'shell:os.popen',
'shell:subprocess',
'shell:execSync',
'shell:spawnSync',
'shell:execFileSync',
'shell:require-child_process',
],
deser: ['deser:pickle', 'deser:marshal', 'deser:yaml.load', 'deser:generic'],
};

const LABEL_TO_FAMILY = new Map<string, string[]>();
for (const members of Object.values(PATTERN_FAMILIES)) {
for (const label of members) LABEL_TO_FAMILY.set(label, members);
}

export function patternSeverity(label: string): Severity {
if (CRITICAL_PATTERNS.has(label)) return 'critical';
if (HIGH_PATTERNS.has(label)) return 'high';
Expand Down Expand Up @@ -69,15 +97,49 @@ export function higher(a: Severity | null, b: Severity): Severity {
return SEVERITY_RANK[a] >= SEVERITY_RANK[b] ? a : b;
}

function countByLabel(findings: SecurityFinding[]): Map<string, number> {
const m = new Map<string, number>();
for (const { label } of findings) m.set(label, (m.get(label) ?? 0) + 1);
return m;
}

function effectivePatternSeverity(
label: string,
oldCount: Map<string, number>,
newCount: Map<string, number>,
): Severity {
const base = patternSeverity(label);
if (base === 'critical') return base;

const family = LABEL_TO_FAMILY.get(label);
if (!family) return base;

const oldTotal = family.reduce((n, l) => n + (oldCount.get(l) ?? 0), 0);
const newTotal = family.reduce((n, l) => n + (newCount.get(l) ?? 0), 0);

// Family net count grew only marginally — treat as lateral refactor, not new attack surface.
// Threshold: net increase must exceed 20% of the old count OR 3 absolute hits to escalate.
const netIncrease = newTotal - oldTotal;
const threshold = Math.max(3, Math.ceil(oldTotal * 0.2));
if (netIncrease < threshold) return 'low';
return base;
}

export function packageMaxSeverity(pkg: PackageAnalysis): Severity | null {
let s: Severity | null = null;

for (const v of pkg.knownVulns ?? []) {
const vs = osvSeverity(v.severity);
if (vs) s = higher(s, vs);
}
for (const f of pkg.securityFindings?.delta ?? []) {
s = higher(s, patternSeverity(f.label));

if (pkg.securityFindings?.delta.length) {
const { old: oldF, new: newF, delta } = pkg.securityFindings;
const oldCount = countByLabel(oldF);
const newCount = countByLabel(newF);
for (const f of delta) {
s = higher(s, effectivePatternSeverity(f.label, oldCount, newCount));
}
}

if ((pkg.binaryFindings?.delta.length ?? 0) > 0) s = higher(s, 'high');
Expand Down