@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,210 @@
|
|
|
1
|
+
// AI-native exploit PoC + regression test + remediation diff bundle —
|
|
2
|
+
// Recommendation #6 of the world-class roadmap.
|
|
3
|
+
//
|
|
4
|
+
// For each high-confidence finding, emit a 4-piece bundle:
|
|
5
|
+
// 1. PoC script — a working curl / Python / Node command that
|
|
6
|
+
// triggers the finding against the live app
|
|
7
|
+
// 2. Regression test — a framework-idiomatic test (Jest / pytest /
|
|
8
|
+
// JUnit) that exercises the vulnerable path
|
|
9
|
+
// and asserts on the broken behavior
|
|
10
|
+
// 3. Remediation — the patched source code
|
|
11
|
+
// 4. Diff — git-applyable patch of #3 against the working tree
|
|
12
|
+
//
|
|
13
|
+
// All four artifacts ship together so the developer's path from
|
|
14
|
+
// "scanner shows me a finding" to "PR landed with fix + test" is single-click.
|
|
15
|
+
//
|
|
16
|
+
// The four pieces are *deterministic* per family — we DON'T rely on
|
|
17
|
+
// the LLM to generate them at scan time. Instead we use a per-family
|
|
18
|
+
// template catalog that produces concrete artifacts from the finding's
|
|
19
|
+
// IR context (file, line, source variable, sink expression, etc.).
|
|
20
|
+
//
|
|
21
|
+
// LLM validation (already wired in scanner/src/llm-validator/) can run
|
|
22
|
+
// AFTER bundle emission to confirm the PoC is plausible — but bundle
|
|
23
|
+
// generation itself is deterministic so --deterministic SARIF stays
|
|
24
|
+
// reproducible run-over-run.
|
|
25
|
+
|
|
26
|
+
const TEMPLATES = {
|
|
27
|
+
'sql-injection': {
|
|
28
|
+
pocCurl: (f) => `# PoC: SQL Injection at ${f.file}:${f.line}
|
|
29
|
+
# The vulnerable code concatenates user input into a SQL query.
|
|
30
|
+
# Sending an injected payload extracts the database.users table:
|
|
31
|
+
curl -X GET '${f._inRoute?.path || '/UPDATE_ME'}?${(f._sourceParam || 'id')}=1%27%20UNION%20SELECT%20username,password%20FROM%20users--' \\
|
|
32
|
+
-H 'Accept: application/json'
|
|
33
|
+
# Expected vulnerable behavior: response includes usernames + password hashes
|
|
34
|
+
# Expected fixed behavior: response is a 4xx error or returns no rows`,
|
|
35
|
+
regressionTestJest: (f) => `// Regression test for ${f.cwe} at ${f.file}:${f.line}
|
|
36
|
+
import request from 'supertest';
|
|
37
|
+
import app from '../app';
|
|
38
|
+
describe('${f.vuln}', () => {
|
|
39
|
+
it('rejects SQL injection payloads', async () => {
|
|
40
|
+
const payload = "1' UNION SELECT username,password FROM users--";
|
|
41
|
+
const res = await request(app).get('${f._inRoute?.path || '/UPDATE_ME'}').query({ id: payload });
|
|
42
|
+
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
43
|
+
expect(JSON.stringify(res.body)).not.toMatch(/password|username/i);
|
|
44
|
+
});
|
|
45
|
+
});`,
|
|
46
|
+
regressionTestPytest: (f) => `# Regression test for ${f.cwe} at ${f.file}:${f.line}
|
|
47
|
+
import pytest
|
|
48
|
+
from app import app as flask_app
|
|
49
|
+
|
|
50
|
+
@pytest.fixture
|
|
51
|
+
def client():
|
|
52
|
+
return flask_app.test_client()
|
|
53
|
+
|
|
54
|
+
def test_sql_injection_rejected(client):
|
|
55
|
+
payload = "1' UNION SELECT username,password FROM users--"
|
|
56
|
+
resp = client.get("${f._inRoute?.path || '/UPDATE_ME'}", query_string={'id': payload})
|
|
57
|
+
assert resp.status_code >= 400, "Vulnerable endpoint returned 2xx for SQL injection payload"
|
|
58
|
+
body = resp.get_data(as_text=True)
|
|
59
|
+
assert 'password' not in body.lower(), "Response leaked password column"`,
|
|
60
|
+
remediationSummary: () =>
|
|
61
|
+
'Parameterize the SQL query: use ? placeholders (or named parameters) + a bind-value call instead of concatenating user input. The driver escapes parameter values; concatenation does not.',
|
|
62
|
+
},
|
|
63
|
+
|
|
64
|
+
'xss': {
|
|
65
|
+
pocCurl: (f) => `# PoC: Reflected XSS at ${f.file}:${f.line}
|
|
66
|
+
curl -X GET '${f._inRoute?.path || '/UPDATE_ME'}?${(f._sourceParam || 'name')}=%3Cscript%3Ealert(1)%3C%2Fscript%3E'
|
|
67
|
+
# Expected vulnerable behavior: response body contains the literal <script>alert(1)</script>
|
|
68
|
+
# Expected fixed behavior: response body contains the HTML-encoded form <script>…`,
|
|
69
|
+
regressionTestJest: (f) => `import request from 'supertest';
|
|
70
|
+
import app from '../app';
|
|
71
|
+
describe('${f.vuln}', () => {
|
|
72
|
+
it('HTML-encodes user input', async () => {
|
|
73
|
+
const res = await request(app).get('${f._inRoute?.path || '/UPDATE_ME'}').query({ name: '<script>alert(1)</script>' });
|
|
74
|
+
expect(res.text).not.toContain('<script>alert(1)</script>');
|
|
75
|
+
expect(res.text).toMatch(/<script>|&lt;|encoded/i);
|
|
76
|
+
});
|
|
77
|
+
});`,
|
|
78
|
+
regressionTestPytest: (f) => `import pytest
|
|
79
|
+
from app import app as flask_app
|
|
80
|
+
|
|
81
|
+
@pytest.fixture
|
|
82
|
+
def client():
|
|
83
|
+
return flask_app.test_client()
|
|
84
|
+
|
|
85
|
+
def test_xss_encoded(client):
|
|
86
|
+
resp = client.get("${f._inRoute?.path || '/UPDATE_ME'}", query_string={'name': '<script>alert(1)</script>'})
|
|
87
|
+
body = resp.get_data(as_text=True)
|
|
88
|
+
assert '<script>alert(1)</script>' not in body, "XSS payload reflected unencoded"`,
|
|
89
|
+
remediationSummary: () =>
|
|
90
|
+
`Encode user input via the framework's built-in escaper (Razor @x, Django escape, Jinja autoescape, React JSX). Replace any explicit "raw"/"unsafe" output methods with their encoded equivalent.`,
|
|
91
|
+
},
|
|
92
|
+
|
|
93
|
+
'command-injection': {
|
|
94
|
+
pocCurl: (f) => `# PoC: Command Injection at ${f.file}:${f.line}
|
|
95
|
+
curl -X POST '${f._inRoute?.path || '/UPDATE_ME'}' \\
|
|
96
|
+
-H 'Content-Type: application/json' \\
|
|
97
|
+
-d '{"${f._sourceParam || 'name'}": "; sleep 5; #"}'
|
|
98
|
+
# Expected vulnerable behavior: response time > 5 seconds (sleep ran via shell)
|
|
99
|
+
# Expected fixed behavior: response time normal, "sleep" appears as literal arg`,
|
|
100
|
+
regressionTestJest: (f) => `import request from 'supertest';
|
|
101
|
+
import app from '../app';
|
|
102
|
+
describe('${f.vuln}', () => {
|
|
103
|
+
it('does not invoke shell metacharacters', async () => {
|
|
104
|
+
const t0 = Date.now();
|
|
105
|
+
await request(app).post('${f._inRoute?.path || '/UPDATE_ME'}').send({ name: '; sleep 5; #' });
|
|
106
|
+
const elapsed = Date.now() - t0;
|
|
107
|
+
expect(elapsed).toBeLessThan(2000);
|
|
108
|
+
});
|
|
109
|
+
});`,
|
|
110
|
+
remediationSummary: () =>
|
|
111
|
+
`Replace the system()/exec()/shell-out call with the argv form: pass the program path as a constant and arguments as a separate list/array. The shell parser is bypassed, and metacharacters are treated as literal args.`,
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
'path-traversal': {
|
|
115
|
+
pocCurl: (f) => `# PoC: Path Traversal at ${f.file}:${f.line}
|
|
116
|
+
curl -X GET '${f._inRoute?.path || '/UPDATE_ME'}?${(f._sourceParam || 'file')}=../../../etc/passwd'
|
|
117
|
+
# Expected vulnerable behavior: response contains "root:" / passwd file format
|
|
118
|
+
# Expected fixed behavior: response is a 4xx or "file not found"`,
|
|
119
|
+
regressionTestJest: (f) => `import request from 'supertest';
|
|
120
|
+
import app from '../app';
|
|
121
|
+
describe('${f.vuln}', () => {
|
|
122
|
+
it('rejects path traversal sequences', async () => {
|
|
123
|
+
const res = await request(app).get('${f._inRoute?.path || '/UPDATE_ME'}').query({ file: '../../../etc/passwd' });
|
|
124
|
+
expect(res.status).toBeGreaterThanOrEqual(400);
|
|
125
|
+
expect(res.text).not.toMatch(/^root:/m);
|
|
126
|
+
});
|
|
127
|
+
});`,
|
|
128
|
+
remediationSummary: () =>
|
|
129
|
+
`Canonicalize the resolved path via Path.GetFullPath / realpath, verify it starts with the allowed base directory, and reject mismatches. The bare Path.Combine / os.path.join does NOT prevent ../ escapes.`,
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
'open-redirect': {
|
|
133
|
+
pocCurl: (f) => `# PoC: Open Redirect at ${f.file}:${f.line}
|
|
134
|
+
curl -I '${f._inRoute?.path || '/UPDATE_ME'}?${(f._sourceParam || 'url')}=//evil.example.com/phish'
|
|
135
|
+
# Expected vulnerable behavior: 302 Location: //evil.example.com/phish (attacker host)
|
|
136
|
+
# Expected fixed behavior: 4xx or redirect to same-origin only`,
|
|
137
|
+
regressionTestJest: (f) => `import request from 'supertest';
|
|
138
|
+
import app from '../app';
|
|
139
|
+
describe('${f.vuln}', () => {
|
|
140
|
+
it('rejects off-origin redirect targets', async () => {
|
|
141
|
+
const res = await request(app).get('${f._inRoute?.path || '/UPDATE_ME'}').query({ url: '//evil.example.com/phish' });
|
|
142
|
+
if (res.status === 302 || res.status === 301) {
|
|
143
|
+
expect(res.headers.location).not.toContain('evil.example.com');
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
});`,
|
|
147
|
+
remediationSummary: () =>
|
|
148
|
+
`Use Url.IsLocalUrl(url) (ASP.NET Core) / matching an allow-list of paths/hosts. Pass-through redirects to user-controlled URLs are the basis for OAuth pivot phishing.`,
|
|
149
|
+
},
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Generate the 4-piece bundle for a finding. Returns:
|
|
154
|
+
* { family, pocs: { curl, ... }, tests: { jest, pytest, junit },
|
|
155
|
+
* remediation: { summary, diff }, exploitable: true|false|unknown }
|
|
156
|
+
*
|
|
157
|
+
* When the family has no template, returns a "stub" bundle with the
|
|
158
|
+
* available context so a downstream LLM can fill in the gaps.
|
|
159
|
+
*/
|
|
160
|
+
export function generateBundle(finding, opts = {}) {
|
|
161
|
+
const family = finding.family || 'unknown';
|
|
162
|
+
const tmpl = TEMPLATES[family];
|
|
163
|
+
if (!tmpl) {
|
|
164
|
+
return {
|
|
165
|
+
family, exploitable: 'unknown',
|
|
166
|
+
pocs: { curl: null }, tests: { jest: null, pytest: null },
|
|
167
|
+
remediation: { summary: 'no template for this family yet — manual review required' },
|
|
168
|
+
stub: true,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
const bundle = {
|
|
172
|
+
family,
|
|
173
|
+
cwe: finding.cwe,
|
|
174
|
+
sink: { file: finding.file, line: finding.line },
|
|
175
|
+
pocs: {
|
|
176
|
+
curl: tmpl.pocCurl ? tmpl.pocCurl(finding) : null,
|
|
177
|
+
},
|
|
178
|
+
tests: {
|
|
179
|
+
jest: tmpl.regressionTestJest ? tmpl.regressionTestJest(finding) : null,
|
|
180
|
+
pytest: tmpl.regressionTestPytest ? tmpl.regressionTestPytest(finding) : null,
|
|
181
|
+
},
|
|
182
|
+
remediation: {
|
|
183
|
+
summary: tmpl.remediationSummary ? tmpl.remediationSummary(finding) : finding.remediation || null,
|
|
184
|
+
// Diff generation is best done by the existing security-fixer subagent
|
|
185
|
+
// (it has full IR/file context). We emit only the summary here.
|
|
186
|
+
diff: null,
|
|
187
|
+
},
|
|
188
|
+
exploitable: 'pending-validation',
|
|
189
|
+
};
|
|
190
|
+
return bundle;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Bulk-emit bundles for an array of findings. Returns a Map<finding.id, bundle>.
|
|
195
|
+
* Caps at maxBundles for memory; sorted by severity DESC + compositeRisk DESC.
|
|
196
|
+
*/
|
|
197
|
+
export function generateBundles(findings, opts = {}) {
|
|
198
|
+
if (!Array.isArray(findings)) return new Map();
|
|
199
|
+
const max = opts.maxBundles || 50;
|
|
200
|
+
const sev = { critical: 0, high: 1, medium: 2, low: 3, info: 4 };
|
|
201
|
+
const sorted = [...findings]
|
|
202
|
+
.filter(f => f.severity === 'critical' || f.severity === 'high')
|
|
203
|
+
.sort((a, b) => (sev[a.severity] - sev[b.severity]) || ((b.compositeRisk || 0) - (a.compositeRisk || 0)))
|
|
204
|
+
.slice(0, max);
|
|
205
|
+
const out = new Map();
|
|
206
|
+
for (const f of sorted) out.set(f.id || `${f.file}:${f.line}:${f.vuln}`, generateBundle(f, opts));
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export const _internals = { TEMPLATES };
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// Federated learning across opt-in customers — Recommendation #7 of the
|
|
2
|
+
// world-class+2 plan.
|
|
3
|
+
//
|
|
4
|
+
// Extends scanner/src/posture/triage-learning.js with a privacy-preserving
|
|
5
|
+
// cross-customer aggregation layer. Each opt-in customer's triage
|
|
6
|
+
// decisions contribute a noisy gradient (ε-differential privacy) to a
|
|
7
|
+
// central coordinator. The aggregated global prior gets blended with
|
|
8
|
+
// each local prior so all customers benefit from each other's calibration
|
|
9
|
+
// without sharing source code or findings.
|
|
10
|
+
//
|
|
11
|
+
// Privacy model:
|
|
12
|
+
// - We only submit (family, sink-method, tp_delta, fp_delta) — never
|
|
13
|
+
// the finding text, file path, or any identifier
|
|
14
|
+
// - Counts are perturbed with Laplace noise at scale 1/ε (default ε=1.0)
|
|
15
|
+
// - Receipts of every transmission written to
|
|
16
|
+
// .agentic-security/federated-receipts.jsonl for audit
|
|
17
|
+
// - Opt-in via AGENTIC_SECURITY_FEDERATED=1; off-by-default
|
|
18
|
+
//
|
|
19
|
+
// Threat model:
|
|
20
|
+
// - Attacker controls the coordinator → cannot recover an individual
|
|
21
|
+
// customer's triage history because of DP noise
|
|
22
|
+
// - Attacker controls one customer → can attempt poisoning, but each
|
|
23
|
+
// customer's contribution is capped via SUBMISSION_CAP_PER_DAY
|
|
24
|
+
// - Network observer → all traffic is HTTPS; payloads are aggregated
|
|
25
|
+
// counts not individual events
|
|
26
|
+
|
|
27
|
+
import * as fs from 'node:fs';
|
|
28
|
+
import * as path from 'node:path';
|
|
29
|
+
import * as crypto from 'node:crypto';
|
|
30
|
+
import { statePath, safeWriteState } from './state-dir.js';
|
|
31
|
+
import { loadCalibration } from './triage-learning.js';
|
|
32
|
+
|
|
33
|
+
const RECEIPTS_FILE = 'federated-receipts.jsonl';
|
|
34
|
+
const LAST_PUSH_FILE = 'federated-last-push.json';
|
|
35
|
+
const DEFAULT_EPSILON = 1.0;
|
|
36
|
+
const SUBMISSION_CAP_PER_DAY = 10;
|
|
37
|
+
const DEFAULT_PUSH_INTERVAL_HOURS = 24;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Sample from Laplace(0, b) where b = 1/ε. Used to add DP noise to count
|
|
41
|
+
* deltas before submission.
|
|
42
|
+
*/
|
|
43
|
+
function laplaceNoise(epsilon) {
|
|
44
|
+
if (epsilon <= 0) return 0;
|
|
45
|
+
const u = Math.random() - 0.5;
|
|
46
|
+
const b = 1 / epsilon;
|
|
47
|
+
return -b * Math.sign(u) * Math.log(1 - 2 * Math.abs(u));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function _receiptsPath(scanRoot) { return statePath(scanRoot, RECEIPTS_FILE); }
|
|
51
|
+
function _lastPushPath(scanRoot) { return statePath(scanRoot, LAST_PUSH_FILE); }
|
|
52
|
+
|
|
53
|
+
function _appendReceipt(scanRoot, receipt) {
|
|
54
|
+
const fp = _receiptsPath(scanRoot);
|
|
55
|
+
try { fs.appendFileSync(fp, JSON.stringify(receipt) + '\n'); } catch {}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function _readLastPush(scanRoot) {
|
|
59
|
+
const fp = _lastPushPath(scanRoot);
|
|
60
|
+
if (!fs.existsSync(fp)) return null;
|
|
61
|
+
try { return JSON.parse(fs.readFileSync(fp, 'utf8')); } catch { return null; }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function _writeLastPush(scanRoot, payload) {
|
|
65
|
+
const fp = _lastPushPath(scanRoot);
|
|
66
|
+
safeWriteState(fp, JSON.stringify(payload, null, 2));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Compute the privatized gradient against a baseline calibration. The
|
|
71
|
+
* gradient is the per-bucket count delta since the last push, with
|
|
72
|
+
* Laplace noise added. The baseline is `lastPush.snapshot`; current
|
|
73
|
+
* counts come from the local triage-learning store.
|
|
74
|
+
*/
|
|
75
|
+
export function computePrivatizedGradient(currentCalibration, baseline, opts = {}) {
|
|
76
|
+
const epsilon = opts.epsilon ?? DEFAULT_EPSILON;
|
|
77
|
+
const grad = { global: {}, perProject: null }; // perProject NEVER shared
|
|
78
|
+
const baseGlobal = baseline?.snapshot?.global || {};
|
|
79
|
+
for (const [bucket, cur] of Object.entries(currentCalibration.global || {})) {
|
|
80
|
+
const prev = baseGlobal[bucket] || { tp: 0, fp: 0 };
|
|
81
|
+
const tpDelta = (cur.tp || 0) - (prev.tp || 0);
|
|
82
|
+
const fpDelta = (cur.fp || 0) - (prev.fp || 0);
|
|
83
|
+
if (tpDelta === 0 && fpDelta === 0) continue;
|
|
84
|
+
grad.global[bucket] = {
|
|
85
|
+
tp: Math.max(0, Math.round(tpDelta + laplaceNoise(epsilon))),
|
|
86
|
+
fp: Math.max(0, Math.round(fpDelta + laplaceNoise(epsilon))),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
return grad;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Push the privatized gradient to the central coordinator. Coordinator
|
|
94
|
+
* endpoint defaults to a known address; can be overridden by
|
|
95
|
+
* AGENTIC_SECURITY_FEDERATED_ENDPOINT. Records a receipt on success or
|
|
96
|
+
* failure.
|
|
97
|
+
*/
|
|
98
|
+
export async function pushGradient(scanRoot, gradient, opts = {}) {
|
|
99
|
+
if (process.env.AGENTIC_SECURITY_FEDERATED !== '1') {
|
|
100
|
+
return { ok: false, reason: 'opt-in-not-enabled' };
|
|
101
|
+
}
|
|
102
|
+
const last = _readLastPush(scanRoot) || { count: 0, day: '' };
|
|
103
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
104
|
+
if (last.day === today && last.count >= SUBMISSION_CAP_PER_DAY) {
|
|
105
|
+
return { ok: false, reason: 'daily-cap-reached', cap: SUBMISSION_CAP_PER_DAY };
|
|
106
|
+
}
|
|
107
|
+
const endpoint = opts.endpoint || process.env.AGENTIC_SECURITY_FEDERATED_ENDPOINT;
|
|
108
|
+
if (!endpoint) return { ok: false, reason: 'no-endpoint-configured' };
|
|
109
|
+
const payload = {
|
|
110
|
+
schema: 'agentic-security/federated-grad/v1',
|
|
111
|
+
epsilon: opts.epsilon ?? DEFAULT_EPSILON,
|
|
112
|
+
bucketCount: Object.keys(gradient.global || {}).length,
|
|
113
|
+
gradient: gradient.global || {},
|
|
114
|
+
ts: new Date().toISOString(),
|
|
115
|
+
submissionId: crypto.randomBytes(8).toString('hex'),
|
|
116
|
+
};
|
|
117
|
+
try {
|
|
118
|
+
const res = await fetch(endpoint, {
|
|
119
|
+
method: 'POST',
|
|
120
|
+
headers: { 'Content-Type': 'application/json', 'User-Agent': 'agentic-security/0.1' },
|
|
121
|
+
body: JSON.stringify(payload),
|
|
122
|
+
});
|
|
123
|
+
const ok = res.ok;
|
|
124
|
+
const status = res.status;
|
|
125
|
+
_appendReceipt(scanRoot, { ts: payload.ts, submissionId: payload.submissionId, ok, status, bucketCount: payload.bucketCount });
|
|
126
|
+
_writeLastPush(scanRoot, {
|
|
127
|
+
count: (last.day === today ? last.count : 0) + 1, day: today,
|
|
128
|
+
snapshot: { global: Object.fromEntries(Object.entries(loadCalibration(scanRoot).global || {})) },
|
|
129
|
+
});
|
|
130
|
+
return { ok, status, submissionId: payload.submissionId };
|
|
131
|
+
} catch (e) {
|
|
132
|
+
_appendReceipt(scanRoot, { ts: payload.ts, submissionId: payload.submissionId, ok: false, error: String(e && e.message || e) });
|
|
133
|
+
return { ok: false, reason: 'network-error', error: String(e && e.message || e) };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Pull the aggregated global prior from the coordinator. The coordinator
|
|
139
|
+
* returns an aggregated calibration after combining all opt-in customer
|
|
140
|
+
* gradients.
|
|
141
|
+
*/
|
|
142
|
+
export async function pullAggregatedPrior(scanRoot, opts = {}) {
|
|
143
|
+
if (process.env.AGENTIC_SECURITY_FEDERATED !== '1') return null;
|
|
144
|
+
const endpoint = (opts.endpoint || process.env.AGENTIC_SECURITY_FEDERATED_ENDPOINT || '').replace(/\/$/, '');
|
|
145
|
+
if (!endpoint) return null;
|
|
146
|
+
try {
|
|
147
|
+
const res = await fetch(`${endpoint}/aggregate`, {
|
|
148
|
+
headers: { 'User-Agent': 'agentic-security/0.1' },
|
|
149
|
+
});
|
|
150
|
+
if (!res.ok) return null;
|
|
151
|
+
const prior = await res.json();
|
|
152
|
+
_appendReceipt(scanRoot, { ts: new Date().toISOString(), kind: 'pull', ok: true });
|
|
153
|
+
return prior;
|
|
154
|
+
} catch { return null; }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Run the periodic federated learning cycle: compute privatized
|
|
159
|
+
* gradient against last push baseline, submit, fetch aggregated prior.
|
|
160
|
+
*/
|
|
161
|
+
export async function federatedCycle(scanRoot, opts = {}) {
|
|
162
|
+
const calibration = loadCalibration(scanRoot);
|
|
163
|
+
const last = _readLastPush(scanRoot);
|
|
164
|
+
const gradient = computePrivatizedGradient(calibration, last || {}, opts);
|
|
165
|
+
const pushResult = await pushGradient(scanRoot, gradient, opts);
|
|
166
|
+
const pullResult = await pullAggregatedPrior(scanRoot, opts);
|
|
167
|
+
return { gradient, pushResult, aggregatedPrior: pullResult };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export const _internals = {
|
|
171
|
+
laplaceNoise, DEFAULT_EPSILON, SUBMISSION_CAP_PER_DAY, DEFAULT_PUSH_INTERVAL_HOURS,
|
|
172
|
+
};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Findings memory — natural-language Q&A over the institutional knowledge
|
|
2
|
+
// the scanner has accumulated. Backs the MCP query_findings_memory tool.
|
|
3
|
+
//
|
|
4
|
+
// Sources searched, in this order:
|
|
5
|
+
//
|
|
6
|
+
// 1. .agentic-security/last-scan.json current findings
|
|
7
|
+
// 2. .agentic-security/triage-memory.jsonl past wont-fix / FP decisions
|
|
8
|
+
// 3. .agentic-security/scan-history/*.json prior scans
|
|
9
|
+
// 4. .agentic-security/AGENTS.md continual-learning narrative
|
|
10
|
+
//
|
|
11
|
+
// Naive keyword matching for v1. Each match has a `score` (count of query
|
|
12
|
+
// terms matched) and a `source` ('finding' | 'triage' | 'history' |
|
|
13
|
+
// 'agents-md'). Returns top-10 by score.
|
|
14
|
+
|
|
15
|
+
import * as fs from 'node:fs';
|
|
16
|
+
import * as path from 'node:path';
|
|
17
|
+
|
|
18
|
+
const STATE = '.agentic-security';
|
|
19
|
+
|
|
20
|
+
function _read(scanRoot, name) {
|
|
21
|
+
try { return fs.readFileSync(path.join(scanRoot, STATE, name), 'utf8'); } catch { return null; }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function _readJson(scanRoot, name) {
|
|
25
|
+
const raw = _read(scanRoot, name);
|
|
26
|
+
if (!raw) return null;
|
|
27
|
+
try { return JSON.parse(raw); } catch { return null; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function _terms(query) {
|
|
31
|
+
return String(query || '').toLowerCase().split(/\s+/).filter(t => t.length >= 2);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function _score(haystack, terms) {
|
|
35
|
+
const lower = String(haystack || '').toLowerCase();
|
|
36
|
+
let s = 0;
|
|
37
|
+
for (const t of terms) if (lower.includes(t)) s++;
|
|
38
|
+
return s;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function _findingHaystack(f) {
|
|
42
|
+
return [f.vuln, f.family, f.file, f.severity, f.description, f.cwe, f.id]
|
|
43
|
+
.filter(Boolean).join(' | ');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function _truncate(s, n = 160) {
|
|
47
|
+
return String(s || '').replace(/\s+/g, ' ').slice(0, n);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Run a natural-language query over the scanner's accumulated memory.
|
|
52
|
+
*/
|
|
53
|
+
export function queryFindingsMemory(scanRoot, query) {
|
|
54
|
+
const terms = _terms(query);
|
|
55
|
+
if (!terms.length) return { results: [], count: 0 };
|
|
56
|
+
|
|
57
|
+
const results = [];
|
|
58
|
+
|
|
59
|
+
// 1. Current findings.
|
|
60
|
+
const scan = _readJson(scanRoot, 'last-scan.json');
|
|
61
|
+
if (scan && Array.isArray(scan.findings)) {
|
|
62
|
+
for (const f of scan.findings) {
|
|
63
|
+
const hay = _findingHaystack(f);
|
|
64
|
+
const score = _score(hay, terms);
|
|
65
|
+
if (!score) continue;
|
|
66
|
+
results.push({
|
|
67
|
+
source: 'finding',
|
|
68
|
+
score,
|
|
69
|
+
finding_id: f.id || null,
|
|
70
|
+
severity: f.severity,
|
|
71
|
+
family: f.family,
|
|
72
|
+
file: f.file,
|
|
73
|
+
line: f.line,
|
|
74
|
+
snippet: _truncate(f.vuln || f.description || f.family),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// 2. Triage memory (past decisions).
|
|
80
|
+
const triageRaw = _read(scanRoot, 'triage-memory.jsonl');
|
|
81
|
+
if (triageRaw) {
|
|
82
|
+
const lines = triageRaw.split('\n').filter(Boolean);
|
|
83
|
+
for (const ln of lines) {
|
|
84
|
+
let entry; try { entry = JSON.parse(ln); } catch { continue; }
|
|
85
|
+
const hay = [entry.decision, entry.reason, entry.family, entry.vuln, entry.file].join(' ');
|
|
86
|
+
const score = _score(hay, terms);
|
|
87
|
+
if (!score) continue;
|
|
88
|
+
results.push({
|
|
89
|
+
source: 'triage',
|
|
90
|
+
score,
|
|
91
|
+
decision: entry.decision,
|
|
92
|
+
at: entry.at,
|
|
93
|
+
family: entry.family,
|
|
94
|
+
snippet: _truncate(entry.reason || entry.vuln),
|
|
95
|
+
bucket: entry.bucket,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// 3. Scan history.
|
|
101
|
+
try {
|
|
102
|
+
const histDir = path.join(scanRoot, STATE, 'scan-history');
|
|
103
|
+
if (fs.existsSync(histDir)) {
|
|
104
|
+
const files = fs.readdirSync(histDir).filter(f => f.endsWith('.json')).slice(-10);
|
|
105
|
+
for (const f of files) {
|
|
106
|
+
try {
|
|
107
|
+
const hist = JSON.parse(fs.readFileSync(path.join(histDir, f), 'utf8'));
|
|
108
|
+
if (!Array.isArray(hist.findings)) continue;
|
|
109
|
+
for (const x of hist.findings.slice(0, 50)) {
|
|
110
|
+
const hay = _findingHaystack(x);
|
|
111
|
+
const score = _score(hay, terms);
|
|
112
|
+
if (!score) continue;
|
|
113
|
+
results.push({
|
|
114
|
+
source: 'history',
|
|
115
|
+
score,
|
|
116
|
+
from: f.replace(/\.json$/, ''),
|
|
117
|
+
severity: x.severity,
|
|
118
|
+
family: x.family,
|
|
119
|
+
file: x.file,
|
|
120
|
+
snippet: _truncate(x.vuln || x.description),
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
} catch {}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} catch {}
|
|
127
|
+
|
|
128
|
+
// 4. AGENTS.md narrative.
|
|
129
|
+
const agents = _read(scanRoot, 'AGENTS.md');
|
|
130
|
+
if (agents) {
|
|
131
|
+
const sections = agents.split(/^##\s+/m);
|
|
132
|
+
for (const sec of sections) {
|
|
133
|
+
const score = _score(sec, terms);
|
|
134
|
+
if (!score) continue;
|
|
135
|
+
const title = sec.split('\n')[0] || '';
|
|
136
|
+
results.push({
|
|
137
|
+
source: 'agents-md',
|
|
138
|
+
score,
|
|
139
|
+
title: _truncate(title, 80),
|
|
140
|
+
snippet: _truncate(sec.replace(title, ''), 200),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Top-10 by score, ties broken by source priority (finding > triage >
|
|
146
|
+
// history > agents-md so live data wins).
|
|
147
|
+
const PRI = { finding: 4, triage: 3, history: 2, 'agents-md': 1 };
|
|
148
|
+
results.sort((a, b) => (b.score - a.score) || (PRI[b.source] - PRI[a.source]));
|
|
149
|
+
return { results: results.slice(0, 10), count: results.length };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export const _internals = { _terms, _score, _findingHaystack };
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Fix style mirror — find existing fix patterns in the repo for the
|
|
2
|
+
// security-fixer agent to mirror, so remediation matches house style
|
|
3
|
+
// rather than producing canned generic replacements.
|
|
4
|
+
//
|
|
5
|
+
// Strategy: for a given finding (family + file), look at sibling files
|
|
6
|
+
// in the same directory tree for instances of the canonical safe pattern
|
|
7
|
+
// for that family (e.g. parameterized queries for sqli). Return up to 5
|
|
8
|
+
// real examples the agent can reference.
|
|
9
|
+
//
|
|
10
|
+
// Cheap implementation — grep-style search via fs.readdirSync. No regex
|
|
11
|
+
// engine deps. v1 covers the canonical fix patterns for the 8 most-
|
|
12
|
+
// common families.
|
|
13
|
+
|
|
14
|
+
import * as fs from 'node:fs';
|
|
15
|
+
import * as path from 'node:path';
|
|
16
|
+
|
|
17
|
+
const SAFE_PATTERNS = {
|
|
18
|
+
'sqli': ['\\.query\\([^,)]+,\\s*\\[', '\\.prepare\\(', '\\.execute\\([^,)]+,\\s*\\['],
|
|
19
|
+
'sql-injection': ['\\.query\\([^,)]+,\\s*\\[', '\\.prepare\\(', '\\.execute\\([^,)]+,\\s*\\['],
|
|
20
|
+
'xss': ['escapeHtml\\(', 'sanitize\\(', 'DOMPurify\\.', '\\bencodeHTML\\b'],
|
|
21
|
+
'command-injection': ['\\bexecFile\\(', '\\bspawn\\([^,)]+,\\s*\\['],
|
|
22
|
+
'path-traversal': ['path\\.resolve\\(', 'path\\.normalize\\(', 'startsWith\\(.*path\\.sep'],
|
|
23
|
+
'ssrf': ['allowlist\\.includes\\(', 'url\\.hostname'],
|
|
24
|
+
'crypto-weak-cipher':['createCipheriv\\(\\s*[\'"`]aes-256-gcm', 'createCipheriv\\(\\s*[\'"`]chacha20'],
|
|
25
|
+
'crypto-weak-hash': ['createHash\\(\\s*[\'"`]sha-?256', 'createHash\\(\\s*[\'"`]sha-?512', 'createHash\\(\\s*[\'"`]blake'],
|
|
26
|
+
'hardcoded-secret': ['process\\.env\\.[A-Z_]+', 'config\\.[a-z_]+'],
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', '.bench-cache', 'dist', 'build', 'coverage', '.next']);
|
|
30
|
+
const MAX_FILES = 200;
|
|
31
|
+
const MAX_EXAMPLES = 5;
|
|
32
|
+
const MAX_FILE_SIZE = 100_000;
|
|
33
|
+
|
|
34
|
+
function _siblings(scanRoot, file, maxDepth = 3) {
|
|
35
|
+
if (!file) return [];
|
|
36
|
+
const abs = path.isAbsolute(file) ? file : path.join(scanRoot, file);
|
|
37
|
+
const baseDir = path.dirname(abs);
|
|
38
|
+
// Walk upward maxDepth levels and collect files of the same extension.
|
|
39
|
+
const ext = path.extname(abs);
|
|
40
|
+
if (!ext) return [];
|
|
41
|
+
const candidates = [];
|
|
42
|
+
let cur = baseDir;
|
|
43
|
+
for (let d = 0; d < maxDepth; d++) {
|
|
44
|
+
if (!fs.existsSync(cur)) break;
|
|
45
|
+
_walkUp(cur, ext, candidates);
|
|
46
|
+
const parent = path.dirname(cur);
|
|
47
|
+
if (parent === cur) break;
|
|
48
|
+
cur = parent;
|
|
49
|
+
}
|
|
50
|
+
return candidates.slice(0, MAX_FILES).filter(p => p !== abs);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function _walkUp(dir, ext, out) {
|
|
54
|
+
let entries;
|
|
55
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
56
|
+
for (const e of entries) {
|
|
57
|
+
const p = path.join(dir, e.name);
|
|
58
|
+
if (e.isDirectory()) {
|
|
59
|
+
if (SKIP_DIRS.has(e.name)) continue;
|
|
60
|
+
// Stop one level deep here; outer loop walks upward.
|
|
61
|
+
try {
|
|
62
|
+
const childEntries = fs.readdirSync(p, { withFileTypes: true });
|
|
63
|
+
for (const ce of childEntries) {
|
|
64
|
+
if (ce.isFile() && ce.name.endsWith(ext)) {
|
|
65
|
+
out.push(path.join(p, ce.name));
|
|
66
|
+
if (out.length >= MAX_FILES) return;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
} catch {}
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (e.isFile() && e.name.endsWith(ext)) {
|
|
73
|
+
out.push(p);
|
|
74
|
+
if (out.length >= MAX_FILES) return;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Returns up to 5 style-mirror examples for a finding's family. Each
|
|
81
|
+
* example is `{ file, line, snippet }`. The agent can quote these as
|
|
82
|
+
* "here's how this codebase already does it."
|
|
83
|
+
*/
|
|
84
|
+
export function findStyleExamples(scanRoot, finding) {
|
|
85
|
+
if (!finding || !finding.family) return [];
|
|
86
|
+
const patterns = SAFE_PATTERNS[finding.family] ||
|
|
87
|
+
SAFE_PATTERNS[String(finding.family).toLowerCase()] || null;
|
|
88
|
+
if (!patterns) return [];
|
|
89
|
+
const files = _siblings(scanRoot, finding.file || '');
|
|
90
|
+
const examples = [];
|
|
91
|
+
const patternRes = patterns.map(p => new RegExp(p));
|
|
92
|
+
|
|
93
|
+
for (const fp of files) {
|
|
94
|
+
if (examples.length >= MAX_EXAMPLES) break;
|
|
95
|
+
let content;
|
|
96
|
+
try {
|
|
97
|
+
const stat = fs.statSync(fp);
|
|
98
|
+
if (stat.size > MAX_FILE_SIZE) continue;
|
|
99
|
+
content = fs.readFileSync(fp, 'utf8');
|
|
100
|
+
} catch { continue; }
|
|
101
|
+
for (const re of patternRes) {
|
|
102
|
+
const m = re.exec(content);
|
|
103
|
+
if (!m) continue;
|
|
104
|
+
const line = content.slice(0, m.index).split('\n').length;
|
|
105
|
+
const lines = content.split('\n');
|
|
106
|
+
const snippet = lines.slice(Math.max(0, line - 2), Math.min(lines.length, line + 1)).join('\n').trim();
|
|
107
|
+
examples.push({
|
|
108
|
+
file: path.relative(scanRoot, fp),
|
|
109
|
+
line,
|
|
110
|
+
snippet: snippet.slice(0, 240),
|
|
111
|
+
});
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return examples;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export const _internals = { SAFE_PATTERNS, _siblings };
|