@clear-capabilities/agentic-security-scanner 0.79.0 → 0.84.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/178.index.js +1 -1
- package/dist/333.index.js +283 -0
- package/dist/384.index.js +1 -1
- package/dist/637.index.js +1 -1
- package/dist/838.index.js +1 -1
- package/dist/839.index.js +170 -0
- package/dist/985.index.js +140 -1
- package/dist/agentic-security.mjs +10 -10
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +7 -5
- package/src/.agentic-security/findings.json +117732 -0
- package/src/.agentic-security/last-scan.json +117732 -0
- package/src/.agentic-security/last-scan.json.sig +1 -0
- package/src/.agentic-security/scan-history.json +12946 -0
- package/src/.agentic-security/streak.json +21 -0
- package/src/dataflow/.agentic-security/findings.json +6086 -0
- package/src/dataflow/.agentic-security/last-scan.json +6086 -0
- package/src/dataflow/.agentic-security/last-scan.json.sig +1 -0
- package/src/dataflow/.agentic-security/scan-history.json +250 -0
- package/src/dataflow/.agentic-security/streak.json +21 -0
- package/src/dataflow/cross-service-taint.js +201 -0
- package/src/dataflow/formal-verify.js +204 -0
- package/src/dataflow/ifds-precise.js +222 -0
- package/src/dataflow/k2-summary-cache.js +153 -0
- package/src/dataflow/lib-taint-summaries.js +198 -0
- package/src/dataflow/privacy-taint.js +205 -0
- package/src/dataflow/smt-feasibility.js +189 -0
- package/src/engine.js +825 -127
- package/src/ir/.agentic-security/findings.json +4011 -0
- package/src/ir/.agentic-security/last-scan.json +4011 -0
- package/src/ir/.agentic-security/last-scan.json.sig +1 -0
- package/src/ir/.agentic-security/scan-history.json +193 -0
- package/src/ir/.agentic-security/streak.json +20 -0
- package/src/ir/cpp-preprocessor.js +142 -0
- package/src/ir/csharp-ir.js +604 -0
- package/src/ir/universal-ir.js +403 -0
- package/src/mcp/.agentic-security/findings.json +8632 -0
- package/src/mcp/.agentic-security/last-scan.json +8632 -0
- package/src/mcp/.agentic-security/last-scan.json.sig +1 -0
- package/src/mcp/.agentic-security/scan-history.json +331 -0
- package/src/mcp/.agentic-security/streak.json +20 -0
- package/src/mcp/tools.js +140 -1
- package/src/posture/.agentic-security/findings.json +77181 -0
- package/src/posture/.agentic-security/last-scan.json +77181 -0
- package/src/posture/.agentic-security/last-scan.json.sig +1 -0
- package/src/posture/.agentic-security/scan-history.json +8904 -0
- package/src/posture/.agentic-security/streak.json +21 -0
- package/src/posture/api-contract.js +193 -0
- package/src/posture/attack-taxonomy.js +227 -0
- package/src/posture/auditor-walkthrough.js +252 -0
- package/src/posture/claude-authorship.js +197 -0
- package/src/posture/compliance-frameworks/.agentic-security/findings.json +80 -0
- package/src/posture/compliance-frameworks/.agentic-security/last-scan.json +80 -0
- package/src/posture/compliance-frameworks/.agentic-security/last-scan.json.sig +1 -0
- package/src/posture/compliance-frameworks/.agentic-security/scan-history.json +90 -0
- package/src/posture/compliance-frameworks/.agentic-security/streak.json +22 -0
- package/src/posture/compliance-frameworks/ccpa.json +32 -0
- package/src/posture/compliance-frameworks/eu-ai-act.json +51 -0
- package/src/posture/compliance-frameworks/gdpr.json +45 -0
- package/src/posture/compliance-frameworks/hipaa-security-rule.json +56 -0
- package/src/posture/compliance-frameworks/nist-ai-600-1.json +51 -0
- package/src/posture/compliance-frameworks/nist-csf-2.json +73 -0
- package/src/posture/compliance-frameworks/owasp-asvs-5.json +79 -0
- package/src/posture/compliance-frameworks/owasp-llm-top-10.json +69 -0
- package/src/posture/compliance-policy.js +218 -0
- package/src/posture/composite-risk.js +122 -0
- package/src/posture/cross-repo-memory.js +180 -0
- package/src/posture/csharp-analysis.js +330 -0
- package/src/posture/dep-add-guard.js +197 -0
- package/src/posture/exploit-bundle.js +210 -0
- package/src/posture/federated-learning.js +172 -0
- package/src/posture/findings-memory.js +152 -0
- package/src/posture/fix-style-mirror.js +118 -0
- package/src/posture/git-history.js +141 -0
- package/src/posture/intent-context.js +175 -0
- package/src/posture/license-attributions.js +94 -0
- package/src/posture/license-graph.js +238 -0
- package/src/posture/model-rescan.js +76 -0
- package/src/posture/pattern-propagation.js +39 -0
- package/src/posture/pqc-migration-plan.js +158 -0
- package/src/posture/pr-augment.js +234 -0
- package/src/posture/reachability-filter.js +33 -2
- package/src/posture/realtime-cve-monitor.js +214 -0
- package/src/posture/risk-dollars.js +158 -0
- package/src/posture/runtime-correlation.js +174 -0
- package/src/posture/sbom-diff.js +171 -0
- package/src/posture/sca-policy.js +235 -0
- package/src/posture/sca-upgrade.js +259 -0
- package/src/posture/threat-model-auto.js +268 -0
- package/src/posture/threat-model-grounding.js +169 -0
- package/src/posture/time-to-fix.js +129 -0
- package/src/posture/triage-learning.js +170 -0
- package/src/posture/triage-memory.js +151 -0
- package/src/posture/triage.js +40 -1
- package/src/posture/watch-mode.js +171 -0
- package/src/posture/workflow-installer.js +231 -0
- package/src/sast/.agentic-security/findings.json +6154 -0
- package/src/sast/.agentic-security/last-scan.json +6154 -0
- package/src/sast/.agentic-security/last-scan.json.sig +1 -0
- package/src/sast/.agentic-security/scan-history.json +941 -0
- package/src/sast/.agentic-security/streak.json +22 -0
- package/src/sast/_secret-entropy.js +145 -0
- package/src/sast/cloud-iam.js +312 -0
- package/src/sast/cpp.js +138 -4
- package/src/sast/crypto-protocol.js +388 -0
- package/src/sast/csharp-tokenizer.js +392 -0
- package/src/sast/csharp.js +924 -138
- package/src/sast/dapp-frontend.js +200 -0
- package/src/sast/k8s-admission.js +271 -0
- package/src/sast/llm-app.js +272 -0
- package/src/sast/ml-supply-chain.js +259 -0
- package/src/sast/mobile.js +224 -0
- package/src/sast/post-quantum-crypto.js +348 -0
- package/src/sast/web3-advanced.js +375 -0
- package/src/sca/.agentic-security/findings.json +7460 -0
- package/src/sca/.agentic-security/last-scan.json +7460 -0
- package/src/sca/.agentic-security/last-scan.json.sig +1 -0
- package/src/sca/.agentic-security/scan-history.json +113 -0
- package/src/sca/.agentic-security/streak.json +21 -0
- package/src/sca/CLAUDE.md +161 -0
- package/src/sca/binary-metadata.js +37 -15
- package/src/sca/sigstore-verify.js +215 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Git-history-aware finding annotation.
|
|
2
|
+
//
|
|
3
|
+
// For each finding, looks up the introducing commit via `git blame -L
|
|
4
|
+
// <line>,<line>`. Adds:
|
|
5
|
+
//
|
|
6
|
+
// introducedBy — author name (or 'AI' if commit message marks it)
|
|
7
|
+
// introducedIn — commit SHA (12 chars)
|
|
8
|
+
// introducedAt — commit ISO date
|
|
9
|
+
// introducedInMessage — commit subject line (cap 120 chars)
|
|
10
|
+
// originatingPrompt — extracted from commit body when present
|
|
11
|
+
//
|
|
12
|
+
// Then can render a Slack-ready / PR-comment-ready author-ping draft.
|
|
13
|
+
//
|
|
14
|
+
// Conservative: any subprocess error / non-git repo / file-not-tracked
|
|
15
|
+
// leaves the finding unannotated. Caps blame to 1 line per finding (so
|
|
16
|
+
// 200 findings = 200 blame calls). Set AGENTIC_SECURITY_NO_GIT_HISTORY=1
|
|
17
|
+
// to skip entirely.
|
|
18
|
+
|
|
19
|
+
import * as cp from 'node:child_process';
|
|
20
|
+
import * as fs from 'node:fs';
|
|
21
|
+
import * as path from 'node:path';
|
|
22
|
+
|
|
23
|
+
const MAX_BLAME_PER_SCAN = 500;
|
|
24
|
+
const SUBPROC_TIMEOUT_MS = 1500;
|
|
25
|
+
const PROMPT_MARKER_RE = /(?:^|\n)(?:Prompt|User asked|Original request|Co-Authored-By:\s*Claude)/i;
|
|
26
|
+
|
|
27
|
+
function _isGitRepo(scanRoot) {
|
|
28
|
+
try {
|
|
29
|
+
cp.execFileSync('git', ['rev-parse', '--git-dir'], { cwd: scanRoot, stdio: 'ignore', timeout: SUBPROC_TIMEOUT_MS });
|
|
30
|
+
return true;
|
|
31
|
+
} catch { return false; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function _blame(scanRoot, file, line) {
|
|
35
|
+
if (!file || !line || line < 1) return null;
|
|
36
|
+
const rel = path.isAbsolute(file) ? path.relative(scanRoot, file) : file;
|
|
37
|
+
if (rel.startsWith('..')) return null;
|
|
38
|
+
try {
|
|
39
|
+
const stdout = cp.execFileSync(
|
|
40
|
+
'git',
|
|
41
|
+
['blame', '-L', `${line},${line}`, '--porcelain', '--', rel],
|
|
42
|
+
{ cwd: scanRoot, encoding: 'utf8', timeout: SUBPROC_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'ignore'] },
|
|
43
|
+
);
|
|
44
|
+
return _parsePorcelain(stdout);
|
|
45
|
+
} catch { return null; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function _parsePorcelain(out) {
|
|
49
|
+
if (!out) return null;
|
|
50
|
+
const lines = out.split('\n');
|
|
51
|
+
// First line: <sha> <orig-line> <final-line> <num-lines>
|
|
52
|
+
const head = lines[0].split(' ');
|
|
53
|
+
const sha = head[0];
|
|
54
|
+
if (!sha || sha === '0000000000000000000000000000000000000000') return null;
|
|
55
|
+
const meta = { sha: sha.slice(0, 12) };
|
|
56
|
+
for (const ln of lines) {
|
|
57
|
+
if (ln.startsWith('author ')) meta.author = ln.slice(7);
|
|
58
|
+
else if (ln.startsWith('author-mail ')) meta.email = ln.slice(12).replace(/[<>]/g, '');
|
|
59
|
+
else if (ln.startsWith('author-time ')) meta.ts = parseInt(ln.slice(12), 10);
|
|
60
|
+
else if (ln.startsWith('summary ')) meta.summary = ln.slice(8);
|
|
61
|
+
}
|
|
62
|
+
if (meta.ts) meta.at = new Date(meta.ts * 1000).toISOString();
|
|
63
|
+
return meta;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function _fullMessage(scanRoot, sha) {
|
|
67
|
+
try {
|
|
68
|
+
return cp.execFileSync(
|
|
69
|
+
'git', ['show', '-s', '--format=%B', sha],
|
|
70
|
+
{ cwd: scanRoot, encoding: 'utf8', timeout: SUBPROC_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'ignore'] },
|
|
71
|
+
);
|
|
72
|
+
} catch { return ''; }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function _extractPrompt(body) {
|
|
76
|
+
if (!body || !PROMPT_MARKER_RE.test(body)) return null;
|
|
77
|
+
// Take the line(s) that follow a "Prompt:" or "User asked:" marker, up to
|
|
78
|
+
// the next blank line. Truncate to 280 chars.
|
|
79
|
+
const m = body.match(/(?:Prompt|User asked|Original request)\s*[:\-]\s*([\s\S]*?)(?=\n\s*\n|$)/i);
|
|
80
|
+
if (!m) return null;
|
|
81
|
+
return m[1].trim().slice(0, 280);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Annotate findings with git blame + commit context. Returns
|
|
86
|
+
* { annotated, cached, skipped } counts.
|
|
87
|
+
*/
|
|
88
|
+
export function annotateGitHistory(scanRoot, findings) {
|
|
89
|
+
if (process.env.AGENTIC_SECURITY_NO_GIT_HISTORY === '1') return { annotated: 0 };
|
|
90
|
+
if (!Array.isArray(findings) || findings.length === 0) return { annotated: 0 };
|
|
91
|
+
if (!_isGitRepo(scanRoot)) return { annotated: 0, reason: 'not-a-git-repo' };
|
|
92
|
+
|
|
93
|
+
const messageCache = new Map(); // sha → body
|
|
94
|
+
let annotated = 0, skipped = 0;
|
|
95
|
+
|
|
96
|
+
for (let i = 0; i < findings.length && i < MAX_BLAME_PER_SCAN; i++) {
|
|
97
|
+
const f = findings[i];
|
|
98
|
+
if (!f || !f.file || !f.line) { skipped++; continue; }
|
|
99
|
+
const blame = _blame(scanRoot, f.file, f.line);
|
|
100
|
+
if (!blame) { skipped++; continue; }
|
|
101
|
+
|
|
102
|
+
f.introducedBy = blame.author || null;
|
|
103
|
+
f.introducedIn = blame.sha;
|
|
104
|
+
f.introducedAt = blame.at || null;
|
|
105
|
+
|
|
106
|
+
let body = messageCache.get(blame.sha);
|
|
107
|
+
if (body === undefined) {
|
|
108
|
+
body = _fullMessage(scanRoot, blame.sha);
|
|
109
|
+
messageCache.set(blame.sha, body);
|
|
110
|
+
}
|
|
111
|
+
if (body) {
|
|
112
|
+
const subj = body.split('\n')[0].slice(0, 120);
|
|
113
|
+
f.introducedInMessage = subj;
|
|
114
|
+
const prompt = _extractPrompt(body);
|
|
115
|
+
if (prompt) f.originatingPrompt = prompt;
|
|
116
|
+
// Mark AI-authored when commit message carries the Claude co-author trailer.
|
|
117
|
+
if (/Co-Authored-By:\s*Claude/i.test(body)) f.aiAuthored = true;
|
|
118
|
+
}
|
|
119
|
+
annotated++;
|
|
120
|
+
}
|
|
121
|
+
return { annotated, skipped, capped: findings.length > MAX_BLAME_PER_SCAN };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Generate a Slack-ready / PR-comment author-ping for a finding. Returns
|
|
126
|
+
* a Markdown string with a per-finding callout.
|
|
127
|
+
*/
|
|
128
|
+
export function generateAuthorPing(finding) {
|
|
129
|
+
if (!finding || !finding.introducedBy) return null;
|
|
130
|
+
const where = `${finding.file || '?'}:${finding.line || 0}`;
|
|
131
|
+
const lines = [];
|
|
132
|
+
lines.push(`Hey @${finding.introducedBy.replace(/\s+/g, '.')} — heads-up on \`${where}\`:`);
|
|
133
|
+
lines.push('');
|
|
134
|
+
lines.push(`- **${(finding.severity || '?').toUpperCase()}** ${finding.vuln || finding.family || 'finding'}`);
|
|
135
|
+
if (finding.introducedIn) lines.push(`- Introduced in \`${finding.introducedIn}\`${finding.introducedInMessage ? ` — _"${finding.introducedInMessage}"_` : ''}`);
|
|
136
|
+
if (finding.originatingPrompt) lines.push(`- Originating prompt: _"${finding.originatingPrompt}"_`);
|
|
137
|
+
lines.push(`- Could you take a look?`);
|
|
138
|
+
return lines.join('\n');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export const _internals = { _parsePorcelain, _extractPrompt, _isGitRepo };
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Intent-aware false-positive suppression.
|
|
2
|
+
//
|
|
3
|
+
// Reads project / file / Claude-session context to detect when code is
|
|
4
|
+
// deliberately vulnerable (CTF challenge, sandbox, tutorial, example,
|
|
5
|
+
// fixture) so the scanner can demote findings rather than presenting them
|
|
6
|
+
// as production-grade issues.
|
|
7
|
+
//
|
|
8
|
+
// Signal sources (in order of strength):
|
|
9
|
+
//
|
|
10
|
+
// 1. .agentic-security/current-intent.md
|
|
11
|
+
// A file Claude (or the user) writes to declare the current
|
|
12
|
+
// session's intent. The PreToolUse / SessionStart hook can populate
|
|
13
|
+
// this from the recent transcript. Format:
|
|
14
|
+
// # Intent
|
|
15
|
+
// - tutorial: building an intentional SQLi demo for our security training
|
|
16
|
+
// - excluded-paths: ["examples/sqli-demo/**"]
|
|
17
|
+
//
|
|
18
|
+
// 2. File header comments (first ~1500 chars):
|
|
19
|
+
// @sandbox / @example / @intentionally-vulnerable / @ctf-challenge /
|
|
20
|
+
// @demo / @tutorial / // INTENTIONALLY VULNERABLE
|
|
21
|
+
//
|
|
22
|
+
// 3. Path patterns: examples/, demo/, demos/, tutorial/, sandbox/,
|
|
23
|
+
// playground/, challenges/, ctf/
|
|
24
|
+
//
|
|
25
|
+
// 4. CLAUDE.md section headed "Intentionally vulnerable" / "Out of scope"
|
|
26
|
+
//
|
|
27
|
+
// Opt-out: AGENTIC_SECURITY_NO_INTENT_CTX=1
|
|
28
|
+
|
|
29
|
+
import * as fs from 'node:fs';
|
|
30
|
+
import * as path from 'node:path';
|
|
31
|
+
|
|
32
|
+
const INTENT_PATH_RE = /(?:^|\/)(?:examples?|demos?|tutorials?|sandbox|playground|challenges?|ctf)(?:\/|$)/i;
|
|
33
|
+
|
|
34
|
+
const FILE_HEADER_MARKERS = [
|
|
35
|
+
/@sandbox\b/i,
|
|
36
|
+
/@example\b/i,
|
|
37
|
+
/@intentionally[-_]?vulnerable\b/i,
|
|
38
|
+
/@ctf[-_]?challenge\b/i,
|
|
39
|
+
/@demo\b/i,
|
|
40
|
+
/@tutorial\b/i,
|
|
41
|
+
/(?:^|[^A-Za-z])INTENTIONALLY[- ]?VULNERABLE(?:[^A-Za-z]|$)/,
|
|
42
|
+
/(?:^|[^A-Za-z])DELIBERATELY[- ]?UNSAFE(?:[^A-Za-z]|$)/,
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const HEADER_BUDGET = 1500;
|
|
46
|
+
|
|
47
|
+
function _readSafely(fp) {
|
|
48
|
+
try { return fs.readFileSync(fp, 'utf8'); } catch { return ''; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function _readIntentDeclaration(scanRoot) {
|
|
52
|
+
const fp = path.join(scanRoot, '.agentic-security', 'current-intent.md');
|
|
53
|
+
if (!fs.existsSync(fp)) return null;
|
|
54
|
+
const body = _readSafely(fp);
|
|
55
|
+
if (!body) return null;
|
|
56
|
+
const exMatch = body.match(/excluded-paths\s*:\s*\[([\s\S]*?)\]/);
|
|
57
|
+
let excludedPaths = [];
|
|
58
|
+
if (exMatch) {
|
|
59
|
+
excludedPaths = (exMatch[1].match(/"([^"]+)"|'([^']+)'/g) || [])
|
|
60
|
+
.map(s => s.replace(/['"]/g, ''));
|
|
61
|
+
}
|
|
62
|
+
return { body, excludedPaths };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function _claudeMdHasOutOfScope(scanRoot) {
|
|
66
|
+
const fp = path.join(scanRoot, 'CLAUDE.md');
|
|
67
|
+
if (!fs.existsSync(fp)) return [];
|
|
68
|
+
const body = _readSafely(fp);
|
|
69
|
+
const out = [];
|
|
70
|
+
const re = /^#{1,3}\s+(?:Out[- ]of[- ]scope|Intentionally vulnerable|Sandbox|Examples?)[\s\S]*?(?=\n#{1,3}\s|$(?![\s\S]))/gim;
|
|
71
|
+
let m;
|
|
72
|
+
while ((m = re.exec(body))) {
|
|
73
|
+
const sec = m[0];
|
|
74
|
+
// Pull file globs / paths from the section.
|
|
75
|
+
const paths = (sec.match(/`([^`]+)`/g) || []).map(s => s.replace(/`/g, ''));
|
|
76
|
+
out.push(...paths.filter(p => /\/|\*/.test(p)));
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function _fileHeaderHasIntent(file) {
|
|
82
|
+
if (!file) return false;
|
|
83
|
+
try {
|
|
84
|
+
const fd = fs.openSync(file, 'r');
|
|
85
|
+
const buf = Buffer.alloc(HEADER_BUDGET);
|
|
86
|
+
fs.readSync(fd, buf, 0, HEADER_BUDGET, 0);
|
|
87
|
+
fs.closeSync(fd);
|
|
88
|
+
const head = buf.toString('utf8');
|
|
89
|
+
return FILE_HEADER_MARKERS.some(re => re.test(head));
|
|
90
|
+
} catch { return false; }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function _globMatch(pattern, p) {
|
|
94
|
+
// Minimal glob: `**` → `.*`, `*` → `[^/]*`. Path separators normalized.
|
|
95
|
+
const norm = String(p).replace(/\\/g, '/');
|
|
96
|
+
const re = new RegExp(
|
|
97
|
+
'^' + String(pattern).replace(/\\/g, '/')
|
|
98
|
+
.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
|
|
99
|
+
.replace(/\*\*/g, '###DSTAR###')
|
|
100
|
+
.replace(/\*/g, '[^/]*')
|
|
101
|
+
.replace(/###DSTAR###/g, '.*')
|
|
102
|
+
+ '$',
|
|
103
|
+
);
|
|
104
|
+
return re.test(norm);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Extract intent signals once per scan.
|
|
109
|
+
*/
|
|
110
|
+
export function extractIntentSignals(scanRoot) {
|
|
111
|
+
if (process.env.AGENTIC_SECURITY_NO_INTENT_CTX === '1') {
|
|
112
|
+
return { declaredExcludedPaths: [], claudeMdExcludedPaths: [], intent: null };
|
|
113
|
+
}
|
|
114
|
+
const decl = _readIntentDeclaration(scanRoot);
|
|
115
|
+
const claudeMdPaths = _claudeMdHasOutOfScope(scanRoot);
|
|
116
|
+
return {
|
|
117
|
+
declaredExcludedPaths: decl ? decl.excludedPaths : [],
|
|
118
|
+
claudeMdExcludedPaths: claudeMdPaths,
|
|
119
|
+
intent: decl ? decl.body : null,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Per-finding suppression. Returns the count of findings demoted.
|
|
125
|
+
* Findings are mutated in place — sets `intentSuppressed=true`, drops
|
|
126
|
+
* confidence by 50%, adds 'intent-suppressed' tag.
|
|
127
|
+
*/
|
|
128
|
+
export function suppressByIntent(scanRoot, findings) {
|
|
129
|
+
if (process.env.AGENTIC_SECURITY_NO_INTENT_CTX === '1') return { applied: 0 };
|
|
130
|
+
if (!Array.isArray(findings) || findings.length === 0) return { applied: 0 };
|
|
131
|
+
const signals = extractIntentSignals(scanRoot);
|
|
132
|
+
const allExcluded = [...signals.declaredExcludedPaths, ...signals.claudeMdExcludedPaths];
|
|
133
|
+
|
|
134
|
+
let applied = 0;
|
|
135
|
+
const fileHeaderCache = new Map();
|
|
136
|
+
|
|
137
|
+
for (const f of findings) {
|
|
138
|
+
const file = f.file || '';
|
|
139
|
+
const rel = path.isAbsolute(file) ? path.relative(scanRoot, file) : file;
|
|
140
|
+
let suppress = false;
|
|
141
|
+
let reason = null;
|
|
142
|
+
|
|
143
|
+
if (INTENT_PATH_RE.test(rel)) { suppress = true; reason = 'intent-path-pattern'; }
|
|
144
|
+
|
|
145
|
+
if (!suppress && allExcluded.length) {
|
|
146
|
+
for (const pat of allExcluded) {
|
|
147
|
+
if (_globMatch(pat, rel)) { suppress = true; reason = 'intent-declared-exclusion'; break; }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!suppress && file) {
|
|
152
|
+
let hdr = fileHeaderCache.get(file);
|
|
153
|
+
if (hdr === undefined) {
|
|
154
|
+
hdr = _fileHeaderHasIntent(file);
|
|
155
|
+
fileHeaderCache.set(file, hdr);
|
|
156
|
+
}
|
|
157
|
+
if (hdr) { suppress = true; reason = 'intent-file-header'; }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (suppress) {
|
|
161
|
+
f.intentSuppressed = true;
|
|
162
|
+
f.intentReason = reason;
|
|
163
|
+
if (typeof f.confidence === 'number') f.confidence = Math.max(0.15, f.confidence * 0.5);
|
|
164
|
+
f.tags = Array.isArray(f.tags) ? f.tags : [];
|
|
165
|
+
if (!f.tags.includes('intent-suppressed')) f.tags.push('intent-suppressed');
|
|
166
|
+
applied++;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return { applied, total: findings.length };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export const _internals = {
|
|
173
|
+
INTENT_PATH_RE, FILE_HEADER_MARKERS,
|
|
174
|
+
_readIntentDeclaration, _claudeMdHasOutOfScope, _fileHeaderHasIntent, _globMatch,
|
|
175
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Attributions emitter — companion to license-graph.js.
|
|
2
|
+
//
|
|
3
|
+
// Generates two artifacts under .agentic-security/:
|
|
4
|
+
//
|
|
5
|
+
// ATTRIBUTIONS.md — Markdown table of every component with its
|
|
6
|
+
// license, version, copyright holder (when known),
|
|
7
|
+
// and source URL.
|
|
8
|
+
// NOTICE — Apache-style NOTICE file (only when Apache-2.0
|
|
9
|
+
// licensed components are present).
|
|
10
|
+
//
|
|
11
|
+
// The emitter is deterministic — sorts by ecosystem, name, version —
|
|
12
|
+
// so commits don't churn between scans.
|
|
13
|
+
|
|
14
|
+
import * as fs from 'node:fs';
|
|
15
|
+
import * as path from 'node:path';
|
|
16
|
+
|
|
17
|
+
function _sortKey(c) {
|
|
18
|
+
return `${c.ecosystem || 'zz'}:${c.name || ''}:${c.version || ''}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function _safeStr(s) { return String(s || '').replace(/\s+/g, ' ').trim(); }
|
|
22
|
+
|
|
23
|
+
function _inferRepoUrl(c) {
|
|
24
|
+
if (c.repository?.url) return c.repository.url;
|
|
25
|
+
if (typeof c.repository === 'string') return c.repository;
|
|
26
|
+
if (c.homepage) return c.homepage;
|
|
27
|
+
// Common conventions for ecosystem package pages.
|
|
28
|
+
switch (c.ecosystem) {
|
|
29
|
+
case 'npm': return `https://www.npmjs.com/package/${c.name}`;
|
|
30
|
+
case 'pypi': return `https://pypi.org/project/${c.name}/`;
|
|
31
|
+
case 'rubygems': return `https://rubygems.org/gems/${c.name}`;
|
|
32
|
+
case 'cargo': return `https://crates.io/crates/${c.name}`;
|
|
33
|
+
case 'maven': return `https://search.maven.org/artifact/${(c.name || '').replace(':', '/')}`;
|
|
34
|
+
case 'packagist': return `https://packagist.org/packages/${c.name}`;
|
|
35
|
+
case 'pub': return `https://pub.dev/packages/${c.name}`;
|
|
36
|
+
case 'golang': return `https://pkg.go.dev/${c.name}`;
|
|
37
|
+
default: return '';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function generateAttributions(components, options) {
|
|
42
|
+
const opts = options || {};
|
|
43
|
+
const list = (components || []).slice().sort((a, b) => _sortKey(a).localeCompare(_sortKey(b)));
|
|
44
|
+
if (!list.length) return { markdown: '', notice: '' };
|
|
45
|
+
|
|
46
|
+
const lines = [];
|
|
47
|
+
lines.push('# Third-party attributions');
|
|
48
|
+
lines.push('');
|
|
49
|
+
lines.push(`Generated by agentic-security on ${new Date().toISOString().slice(0, 10)}.`);
|
|
50
|
+
lines.push('');
|
|
51
|
+
lines.push(`This project incorporates **${list.length}** third-party components. Each is listed with its license and source URL. See the linked source for the full license text.`);
|
|
52
|
+
lines.push('');
|
|
53
|
+
lines.push('| Package | Version | License | Ecosystem | Source |');
|
|
54
|
+
lines.push('|---------|---------|---------|-----------|--------|');
|
|
55
|
+
for (const c of list) {
|
|
56
|
+
const url = _inferRepoUrl(c);
|
|
57
|
+
const urlMd = url ? `[link](${url})` : '—';
|
|
58
|
+
lines.push(`| ${_safeStr(c.name)} | ${_safeStr(c.version)} | ${_safeStr(c.license || '—')} | ${_safeStr(c.ecosystem)} | ${urlMd} |`);
|
|
59
|
+
}
|
|
60
|
+
lines.push('');
|
|
61
|
+
const markdown = lines.join('\n');
|
|
62
|
+
|
|
63
|
+
// Apache NOTICE: include only Apache-2.0 components per § 4(d) requirement.
|
|
64
|
+
const apache = list.filter(c => /APACHE-2\.0/i.test(c.license || ''));
|
|
65
|
+
let notice = '';
|
|
66
|
+
if (apache.length) {
|
|
67
|
+
const nl = [];
|
|
68
|
+
nl.push(`${opts.projectName || 'This product'} includes software developed by third parties under the Apache 2.0 license:`);
|
|
69
|
+
nl.push('');
|
|
70
|
+
for (const c of apache) {
|
|
71
|
+
nl.push(`* ${c.name} (${c.version || 'unknown version'})`);
|
|
72
|
+
if (c.copyright) nl.push(` Copyright: ${_safeStr(c.copyright)}`);
|
|
73
|
+
const url = _inferRepoUrl(c);
|
|
74
|
+
if (url) nl.push(` Source: ${url}`);
|
|
75
|
+
}
|
|
76
|
+
nl.push('');
|
|
77
|
+
nl.push('See LICENSE / ATTRIBUTIONS.md for full license text.');
|
|
78
|
+
notice = nl.join('\n');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return { markdown, notice, componentCount: list.length };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function persistAttributions(scanRoot, result) {
|
|
85
|
+
if (!result || !result.markdown) return null;
|
|
86
|
+
try { fs.mkdirSync(path.join(scanRoot, '.agentic-security'), { recursive: true }); } catch {}
|
|
87
|
+
try { fs.writeFileSync(path.join(scanRoot, '.agentic-security', 'ATTRIBUTIONS.md'), result.markdown); } catch {}
|
|
88
|
+
if (result.notice) {
|
|
89
|
+
try { fs.writeFileSync(path.join(scanRoot, '.agentic-security', 'NOTICE'), result.notice); } catch {}
|
|
90
|
+
}
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const _internals = { _inferRepoUrl, _sortKey, _safeStr };
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// License-graph supply-chain analyzer — Item #5 of the world-class+3 plan.
|
|
2
|
+
//
|
|
3
|
+
// Extends posture/license-policy.js (per-component allow/deny/review) with:
|
|
4
|
+
//
|
|
5
|
+
// 1. TRANSITIVE COPYLEFT CONTAMINATION
|
|
6
|
+
// An MIT-licensed direct dep that pulls in a GPL/AGPL transitive
|
|
7
|
+
// dep. Today's per-component check passes because the direct dep is
|
|
8
|
+
// MIT — but the GPL is the actual exposure.
|
|
9
|
+
//
|
|
10
|
+
// 2. RELICENSING-RISK LICENSES
|
|
11
|
+
// BSL, SSPL, Elastic 2.0, Common Clause, Server Side Public License,
|
|
12
|
+
// Functional Source License, Sustainable Use License, etc.
|
|
13
|
+
// Permissive at first install — later versions or downstream usage
|
|
14
|
+
// may breach the additional terms.
|
|
15
|
+
//
|
|
16
|
+
// 3. DISTRIBUTION-MODE-AWARE POLICY
|
|
17
|
+
// "SaaS" vs "Binary" vs "Library" have radically different obligations.
|
|
18
|
+
// AGPL is fine for proprietary internal usage but kills SaaS;
|
|
19
|
+
// GPL is fine for a SaaS web app but kills a published library;
|
|
20
|
+
// LGPL static-link concerns only apply to native binary distribution.
|
|
21
|
+
//
|
|
22
|
+
// 4. DUAL-LICENSE TRAP DETECTION
|
|
23
|
+
// Packages declared as `(MIT OR Apache-2.0)` are usually fine, but
|
|
24
|
+
// `(GPL-2.0 OR Commercial)` are a contractual trap — the open option
|
|
25
|
+
// auto-converts your code to GPL unless you've signed a commercial
|
|
26
|
+
// agreement.
|
|
27
|
+
//
|
|
28
|
+
// 5. LICENSE-CHANGE DETECTION
|
|
29
|
+
// Packages known to have relicensed (Elastic, Redis, Sentry,
|
|
30
|
+
// HashiCorp Terraform, MongoDB) — flag the boundary version.
|
|
31
|
+
//
|
|
32
|
+
// Default mode: SaaS. Override via
|
|
33
|
+
// .agentic-security/license-policy.yml `distributionMode:`.
|
|
34
|
+
//
|
|
35
|
+
// Opt-out: AGENTIC_SECURITY_NO_LICENSE_GRAPH=1
|
|
36
|
+
|
|
37
|
+
import * as fs from 'node:fs';
|
|
38
|
+
import * as path from 'node:path';
|
|
39
|
+
|
|
40
|
+
// ── License taxonomy ───────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
const LICENSE_FAMILIES = {
|
|
43
|
+
permissive: new Set(['MIT', 'APACHE-2.0', 'BSD-2-CLAUSE', 'BSD-3-CLAUSE', 'BSD-4-CLAUSE', 'ISC', '0BSD', 'CC0-1.0', 'UNLICENSE', 'WTFPL', 'ZLIB', 'PSF-2.0', 'PYTHON-2.0', 'POSTGRESQL', 'OPENSSL', 'X11']),
|
|
44
|
+
weak_copyleft: new Set(['LGPL-2.0', 'LGPL-2.1', 'LGPL-3.0', 'LGPL-2.1-OR-LATER', 'LGPL-3.0-OR-LATER', 'MPL-1.1', 'MPL-2.0', 'EPL-1.0', 'EPL-2.0', 'CDDL-1.0', 'CDDL-1.1']),
|
|
45
|
+
strong_copyleft: new Set(['GPL-2.0', 'GPL-2.0-ONLY', 'GPL-2.0-OR-LATER', 'GPL-3.0', 'GPL-3.0-ONLY', 'GPL-3.0-OR-LATER']),
|
|
46
|
+
network_copyleft: new Set(['AGPL-1.0', 'AGPL-3.0', 'AGPL-3.0-ONLY', 'AGPL-3.0-OR-LATER']),
|
|
47
|
+
source_available: new Set(['BSL-1.1', 'SSPL-1.0', 'ELASTIC-2.0', 'ELASTIC-1.0', 'COMMONS-CLAUSE', 'FSL-1.0', 'FSL-1.1', 'CONFLUENT-COMMUNITY', 'BUSL-1.1', 'PARITY-7.0.0', 'POLYFORM-NONCOMMERCIAL', 'POLYFORM-PERIMETER', 'POLYFORM-INTERNAL-USE']),
|
|
48
|
+
proprietary: new Set(['UNLICENSED', 'NOLICENSE', 'PROPRIETARY', 'COMMERCIAL']),
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const KNOWN_RELICENSED = [
|
|
52
|
+
// pkg-name regex → { from, to, atVersion, ecosystem }
|
|
53
|
+
{ pkg: /^elasticsearch$/i, from: 'Apache-2.0', to: 'Elastic-2.0 / SSPL', atVersion: '>=7.11.0', ecosystem: 'java/npm' },
|
|
54
|
+
{ pkg: /^@elastic\/elasticsearch$/i, from: 'Apache-2.0', to: 'Elastic-2.0 / SSPL', atVersion: '>=8.0.0', ecosystem: 'npm' },
|
|
55
|
+
{ pkg: /^redis$/i, from: 'BSD-3-Clause', to: 'RSALv2 / SSPL', atVersion: '>=7.4', ecosystem: 'multi' },
|
|
56
|
+
{ pkg: /^@sentry\/.*$/i, from: 'BSD-3-Clause', to: 'FSL-1.1', atVersion: '>=8.0.0', ecosystem: 'npm' },
|
|
57
|
+
{ pkg: /^terraform.*$/i, from: 'MPL-2.0', to: 'BSL-1.1', atVersion: '>=1.6.0', ecosystem: 'multi' },
|
|
58
|
+
{ pkg: /^mongodb$/i, from: 'AGPL-3.0', to: 'SSPL-1.0', atVersion: '>=4.4', ecosystem: 'multi' },
|
|
59
|
+
{ pkg: /^vault$/i, from: 'MPL-2.0', to: 'BSL-1.1', atVersion: '>=1.15.0', ecosystem: 'go' },
|
|
60
|
+
{ pkg: /^consul$/i, from: 'MPL-2.0', to: 'BSL-1.1', atVersion: '>=1.17.0', ecosystem: 'go' },
|
|
61
|
+
{ pkg: /^cockroachdb?$/i, from: 'Apache-2.0', to: 'CCL (modified)', atVersion: '>=19.2', ecosystem: 'go' },
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
function _normLicense(s) { return String(s || '').toUpperCase().replace(/[()]/g, '').trim(); }
|
|
65
|
+
|
|
66
|
+
function _classify(license) {
|
|
67
|
+
if (!license) return 'unknown';
|
|
68
|
+
const l = _normLicense(license);
|
|
69
|
+
// Compound: pick worst family.
|
|
70
|
+
if (/\s(?:AND|OR|WITH)\s/i.test(l)) {
|
|
71
|
+
const parts = l.split(/\s+(?:AND|OR|WITH)\s+/i).map(p => p.trim()).filter(Boolean);
|
|
72
|
+
const families = parts.map(_classify);
|
|
73
|
+
// Worst → best: source_available > network_copyleft > strong > weak > permissive > unknown.
|
|
74
|
+
for (const f of ['source_available', 'network_copyleft', 'strong_copyleft', 'weak_copyleft', 'permissive']) {
|
|
75
|
+
if (families.includes(f)) return f;
|
|
76
|
+
}
|
|
77
|
+
return 'unknown';
|
|
78
|
+
}
|
|
79
|
+
for (const [fam, set] of Object.entries(LICENSE_FAMILIES)) {
|
|
80
|
+
if (set.has(l)) return fam;
|
|
81
|
+
}
|
|
82
|
+
return 'unknown';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ── Distribution-mode policy ───────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
const DEFAULT_DIST_MODE = 'saas';
|
|
88
|
+
|
|
89
|
+
const DIST_MODE_MATRIX = {
|
|
90
|
+
saas: {
|
|
91
|
+
permissive: { verdict: 'allow', why: 'Compatible with SaaS distribution.' },
|
|
92
|
+
weak_copyleft: { verdict: 'allow', why: 'Weak-copyleft (LGPL/MPL/CDDL/EPL) — SaaS distribution does not trigger reciprocity for unmodified usage.' },
|
|
93
|
+
strong_copyleft: { verdict: 'review', why: 'GPL/CGPL is compatible with SaaS (no distribution of binary) but propagates if you ever publish the source or a derived binary. Confirm internal-only.' },
|
|
94
|
+
network_copyleft: { verdict: 'deny', why: 'AGPL "network use as distribution" — SaaS deployment triggers source-disclosure obligations.' },
|
|
95
|
+
source_available: { verdict: 'deny', why: 'Source-available licenses (BSL/SSPL/Elastic/CommonsClause) restrict competitive SaaS offerings.' },
|
|
96
|
+
proprietary: { verdict: 'deny', why: 'Component has no license / declares proprietary — cannot redistribute.' },
|
|
97
|
+
unknown: { verdict: 'review', why: 'Unknown license — verify via upstream repo.' },
|
|
98
|
+
},
|
|
99
|
+
binary: {
|
|
100
|
+
permissive: { verdict: 'allow', why: 'Compatible with binary distribution.' },
|
|
101
|
+
weak_copyleft: { verdict: 'review', why: 'LGPL has static-linking obligations; MPL has file-level reciprocity. Confirm linkage model.' },
|
|
102
|
+
strong_copyleft: { verdict: 'deny', why: 'GPL copyleft propagates to the entire distributed binary.' },
|
|
103
|
+
network_copyleft: { verdict: 'deny', why: 'AGPL is even more restrictive than GPL for distribution.' },
|
|
104
|
+
source_available: { verdict: 'deny', why: 'Source-available licenses (BSL/SSPL/Elastic/CommonsClause) impose use restrictions that often conflict with binary distribution to customers.' },
|
|
105
|
+
proprietary: { verdict: 'deny', why: 'Component has no license / declares proprietary — cannot bundle.' },
|
|
106
|
+
unknown: { verdict: 'review', why: 'Unknown license — verify via upstream repo.' },
|
|
107
|
+
},
|
|
108
|
+
library: {
|
|
109
|
+
permissive: { verdict: 'allow', why: 'Compatible with library publishing.' },
|
|
110
|
+
weak_copyleft: { verdict: 'review', why: 'LGPL/MPL transitive deps complicate downstream users of YOUR library.' },
|
|
111
|
+
strong_copyleft: { verdict: 'deny', why: 'GPL locks all downstream users of your library into GPL.' },
|
|
112
|
+
network_copyleft: { verdict: 'deny', why: 'AGPL forces downstream users into AGPL.' },
|
|
113
|
+
source_available: { verdict: 'deny', why: 'Source-available licenses block downstream commercial use of your library.' },
|
|
114
|
+
proprietary: { verdict: 'deny', why: 'Component has no license — cannot redistribute via your library.' },
|
|
115
|
+
unknown: { verdict: 'review', why: 'Unknown license — verify via upstream repo.' },
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
// ── Transitive walker ──────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
function _depPathLabel(c) {
|
|
122
|
+
return `${c.ecosystem || '?'}:${c.name}@${c.version || '?'}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Build the dep graph + collect transitive contamination paths.
|
|
127
|
+
*
|
|
128
|
+
* components — list of component objects (already produced by the engine).
|
|
129
|
+
* expects: { ecosystem, name, version, license, transitive (boolean),
|
|
130
|
+
* importedBy: string[] (optional) }.
|
|
131
|
+
*/
|
|
132
|
+
export function analyzeLicenseGraph(components, options) {
|
|
133
|
+
const opts = options || {};
|
|
134
|
+
const mode = (opts.distributionMode || DEFAULT_DIST_MODE).toLowerCase();
|
|
135
|
+
const matrix = DIST_MODE_MATRIX[mode] || DIST_MODE_MATRIX[DEFAULT_DIST_MODE];
|
|
136
|
+
if (!Array.isArray(components) || components.length === 0) {
|
|
137
|
+
return { findings: [], summary: { total: 0, deny: 0, review: 0, allow: 0, unknown: 0 }, distributionMode: mode };
|
|
138
|
+
}
|
|
139
|
+
const byKey = new Map();
|
|
140
|
+
for (const c of components) byKey.set(_depPathLabel(c), c);
|
|
141
|
+
const findings = [];
|
|
142
|
+
const summary = { total: components.length, deny: 0, review: 0, allow: 0, unknown: 0 };
|
|
143
|
+
|
|
144
|
+
for (const c of components) {
|
|
145
|
+
const family = _classify(c.license);
|
|
146
|
+
const verdict = matrix[family] || matrix.unknown;
|
|
147
|
+
summary[verdict.verdict] = (summary[verdict.verdict] || 0) + 1;
|
|
148
|
+
if (verdict.verdict === 'allow') continue;
|
|
149
|
+
|
|
150
|
+
const isTransitive = !!c.transitive;
|
|
151
|
+
let path = [_depPathLabel(c)];
|
|
152
|
+
if (isTransitive && Array.isArray(c.importedBy) && c.importedBy.length) {
|
|
153
|
+
// Walk up the graph (one hop in v1 — sufficient for "direct dep that
|
|
154
|
+
// pulled in this offender").
|
|
155
|
+
path = [c.importedBy[0], _depPathLabel(c)];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
findings.push({
|
|
159
|
+
id: `license-graph:${_depPathLabel(c)}:${family}:${verdict.verdict}`,
|
|
160
|
+
kind: 'license', family: 'license-graph',
|
|
161
|
+
severity: verdict.verdict === 'deny' ? 'high' : 'low',
|
|
162
|
+
file: c.filePath || 'package.json', line: 0,
|
|
163
|
+
vuln: `${verdict.verdict === 'deny' ? 'License-incompatible' : 'License-review-needed'}: ${c.name}@${c.version || '?'} (${c.license || 'no license'}) under ${mode} distribution mode`,
|
|
164
|
+
description: verdict.why + (isTransitive ? ` Transitive dep pulled in via ${path.slice(0, -1).join(' → ')}.` : ''),
|
|
165
|
+
remediation: verdict.verdict === 'deny'
|
|
166
|
+
? `Replace ${c.name}@${c.version || '?'} with a permissively-licensed alternative, OR switch to a different distribution mode (set distributionMode: in .agentic-security/license-policy.yml), OR negotiate a commercial license with the upstream.`
|
|
167
|
+
: `Have legal review confirm ${c.license} compatibility with ${mode} distribution. Once approved, add ${c.name} to the policy allow-list.`,
|
|
168
|
+
package: c.name,
|
|
169
|
+
version: c.version,
|
|
170
|
+
ecosystem: c.ecosystem,
|
|
171
|
+
license: c.license || null,
|
|
172
|
+
licenseFamily: family,
|
|
173
|
+
distributionMode: mode,
|
|
174
|
+
isTransitive,
|
|
175
|
+
depPath: path,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ── Dual-license trap detection ─────────────────────────────────────────
|
|
180
|
+
for (const c of components) {
|
|
181
|
+
if (!c.license) continue;
|
|
182
|
+
const lic = _normLicense(c.license);
|
|
183
|
+
if (!/\bOR\b/i.test(lic)) continue;
|
|
184
|
+
const atoms = lic.split(/\s+OR\s+/i).map(s => s.trim());
|
|
185
|
+
const hasCommercial = atoms.some(a => /COMMERCIAL|PROPRIETARY|ENTERPRISE/.test(a));
|
|
186
|
+
const hasStrongCopyleft = atoms.some(a => LICENSE_FAMILIES.strong_copyleft.has(a) || LICENSE_FAMILIES.network_copyleft.has(a));
|
|
187
|
+
if (hasCommercial && hasStrongCopyleft) {
|
|
188
|
+
findings.push({
|
|
189
|
+
id: `license-graph:dual-license-trap:${_depPathLabel(c)}`,
|
|
190
|
+
kind: 'license', family: 'license-dual-trap',
|
|
191
|
+
severity: 'high',
|
|
192
|
+
file: c.filePath || 'package.json', line: 0,
|
|
193
|
+
vuln: `Dual-license trap: ${c.name}@${c.version} offers ${c.license} — the open option is copyleft, the alternative requires a commercial agreement`,
|
|
194
|
+
description: 'Dual GPL-OR-Commercial licensing means: if you have not signed a commercial agreement with the upstream, your usage falls under GPL/AGPL and propagates to your codebase. Common pattern with Qt LGPL/Commercial, MongoDB AGPL/Commercial pre-SSPL, GraalVM.',
|
|
195
|
+
remediation: 'Verify with legal whether a commercial agreement is in place. If not, you are bound by the copyleft option — propagate that to your distribution mode policy.',
|
|
196
|
+
package: c.name, version: c.version, license: c.license,
|
|
197
|
+
});
|
|
198
|
+
summary.deny = (summary.deny || 0) + 1;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── Known relicensed packages ───────────────────────────────────────────
|
|
203
|
+
for (const c of components) {
|
|
204
|
+
for (const r of KNOWN_RELICENSED) {
|
|
205
|
+
if (!r.pkg.test(c.name)) continue;
|
|
206
|
+
findings.push({
|
|
207
|
+
id: `license-graph:relicensed:${_depPathLabel(c)}`,
|
|
208
|
+
kind: 'license', family: 'license-relicense',
|
|
209
|
+
severity: 'medium',
|
|
210
|
+
file: c.filePath || 'package.json', line: 0,
|
|
211
|
+
vuln: `${c.name}@${c.version}: upstream relicensed from ${r.from} → ${r.to} (boundary ${r.atVersion})`,
|
|
212
|
+
description: `Upstream relicensing event for ${c.name}. Older versions were ${r.from}; ${r.atVersion} and later are ${r.to}. Verify which side of the boundary your version is on, and update your policy.`,
|
|
213
|
+
remediation: `Pin to a pre-relicense version if the new terms are unacceptable, OR adopt a fork (e.g. OpenSearch for Elasticsearch, Valkey for Redis, OpenTofu for Terraform).`,
|
|
214
|
+
package: c.name, version: c.version, license: c.license, relicenseInfo: r,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
return { findings, summary, distributionMode: mode };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── Policy file loader (extends posture/license-policy.js shape) ───────────
|
|
223
|
+
|
|
224
|
+
export function loadLicenseGraphPolicy(scanRoot) {
|
|
225
|
+
if (!scanRoot) return { distributionMode: DEFAULT_DIST_MODE };
|
|
226
|
+
const fp = path.join(scanRoot, '.agentic-security', 'license-policy.yml');
|
|
227
|
+
if (!fs.existsSync(fp)) return { distributionMode: DEFAULT_DIST_MODE };
|
|
228
|
+
try {
|
|
229
|
+
const raw = fs.readFileSync(fp, 'utf8');
|
|
230
|
+
const m = /\bdistributionMode\s*:\s*['"]?(saas|binary|library)['"]?/i.exec(raw);
|
|
231
|
+
return { distributionMode: m ? m[1].toLowerCase() : DEFAULT_DIST_MODE };
|
|
232
|
+
} catch { return { distributionMode: DEFAULT_DIST_MODE }; }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export const _internals = {
|
|
236
|
+
LICENSE_FAMILIES, DIST_MODE_MATRIX, KNOWN_RELICENSED,
|
|
237
|
+
_classify, _normLicense,
|
|
238
|
+
};
|