@clear-capabilities/agentic-security-scanner 0.127.0 → 0.128.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/CHANGELOG.md +60 -0
- package/dist/11.index.js +353 -0
- package/dist/113.index.js +525 -0
- package/dist/178.index.js +1 -1
- package/dist/384.index.js +1 -1
- package/dist/435.index.js +18 -7
- package/dist/637.index.js +1 -1
- package/dist/826.index.js +4 -1
- package/dist/agentic-security.mjs +1 -2
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +3 -3
- package/src/engine.js +27 -1
- package/src/integrations/tickets.js +9 -3
- package/src/mcp/tools.js +17 -6
- package/src/posture/CLAUDE.md +7 -0
- package/src/posture/entrypoint-inventory.js +248 -0
- package/src/posture/falsification.js +121 -0
- package/src/posture/fix-honesty-gate.js +175 -0
- package/src/posture/fix-verify.js +18 -3
- package/src/posture/model-routing.js +126 -0
- package/src/posture/root-cause-sweep.js +262 -0
- package/src/pr-comment.js +3 -1
- package/src/util/untrusted.js +148 -0
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// Untrusted-content hardening primitives (addition #4: meta-security —
|
|
2
|
+
// self-hardening the agent surface).
|
|
3
|
+
//
|
|
4
|
+
// Attacker-authored code and finding text reach several LLM prompts, several
|
|
5
|
+
// rendered outputs (issue / PR / ticket bodies), and audit writers. This module
|
|
6
|
+
// is the single, tested place that neutralizes that content before it crosses a
|
|
7
|
+
// trust boundary. See docs/AGENT_THREAT_MODEL.md for the path→CWE map.
|
|
8
|
+
//
|
|
9
|
+
// Design notes:
|
|
10
|
+
// - Pure + dependency-light (node:crypto, node:fs only). No network, no state.
|
|
11
|
+
// - Fail-closed: unknown/adversarial input degrades to the safe value ('' or
|
|
12
|
+
// `false`), never throws.
|
|
13
|
+
// - Deterministic: fenceUntrusted derives its nonce from the content hash so
|
|
14
|
+
// the wrapping is reproducible and testable (no Date.now / random source).
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
import * as fs from 'node:fs';
|
|
17
|
+
import * as path from 'node:path';
|
|
18
|
+
|
|
19
|
+
// ─── escapeMarkdown ──────────────────────────────────────────────────────────
|
|
20
|
+
// Neutralize markdown/HTML control characters so attacker-controlled finding
|
|
21
|
+
// text cannot inject markup, links, or code spans when interpolated into an
|
|
22
|
+
// issue / PR / ticket body. HTML-dangerous chars (& < >) are entity-encoded so
|
|
23
|
+
// no raw tag can render in any markdown flavour; markdown-structural chars
|
|
24
|
+
// (backtick [ ] ! and backslash) are backslash-escaped.
|
|
25
|
+
//
|
|
26
|
+
// Non-strings collapse to '' (fail-closed — a null vuln never becomes "null").
|
|
27
|
+
//
|
|
28
|
+
// Order is load-bearing: escape `&` before we emit `&`/`<`/`>`, and
|
|
29
|
+
// escape literal `\` before we introduce our own backslashes, so nothing is
|
|
30
|
+
// double-consumed.
|
|
31
|
+
export function escapeMarkdown(s) {
|
|
32
|
+
if (typeof s !== 'string') return '';
|
|
33
|
+
return s
|
|
34
|
+
.replace(/&/g, '&')
|
|
35
|
+
.replace(/</g, '<')
|
|
36
|
+
.replace(/>/g, '>')
|
|
37
|
+
.replace(/\\/g, '\\\\')
|
|
38
|
+
.replace(/`/g, '\\`')
|
|
39
|
+
.replace(/\[/g, '\\[')
|
|
40
|
+
.replace(/\]/g, '\\]')
|
|
41
|
+
.replace(/!/g, '\\!');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// ─── fenceUntrusted ──────────────────────────────────────────────────────────
|
|
45
|
+
// Wrap untrusted text in a clearly-delimited block whose delimiter carries a
|
|
46
|
+
// per-call nonce, so an injected close-delimiter inside the text cannot
|
|
47
|
+
// terminate the fence early (the classic prompt-injection "break out of the
|
|
48
|
+
// data block" move). Intended for the LLM-prompt paths (triage / dedup / fix)
|
|
49
|
+
// where the model must treat the wrapped span as inert data.
|
|
50
|
+
//
|
|
51
|
+
// The nonce is derived deterministically from a sha256 of the content (first 8
|
|
52
|
+
// hex). That makes it (a) reproducible/testable and (b) unguessable by the
|
|
53
|
+
// author of the content — an attacker cannot pre-compute the resulting nonce to
|
|
54
|
+
// forge a matching close-delimiter, because the nonce depends on the very bytes
|
|
55
|
+
// they would have to write.
|
|
56
|
+
//
|
|
57
|
+
// Returns { text, nonce }.
|
|
58
|
+
export function fenceUntrusted(s, label = 'untrusted') {
|
|
59
|
+
const content = typeof s === 'string' ? s : '';
|
|
60
|
+
const lbl = String(label || 'untrusted').replace(/[^A-Za-z0-9_-]/g, '');
|
|
61
|
+
const nonce = createHash('sha256').update(content, 'utf8').digest('hex').slice(0, 8);
|
|
62
|
+
const open = `<<BEGIN ${lbl} ${nonce}>>`;
|
|
63
|
+
const close = `<<END ${lbl} ${nonce}>>`;
|
|
64
|
+
return { text: `${open}\n${content}\n${close}`, nonce };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── isAllowedFetchHost ──────────────────────────────────────────────────────
|
|
68
|
+
// Guard for any outbound fetch whose URL can be influenced by untrusted finding
|
|
69
|
+
// data (e.g. a metadata/advisory URL lifted from a dependency manifest). Blocks
|
|
70
|
+
// SSRF against link-local / loopback / RFC1918 targets AND enforces an explicit
|
|
71
|
+
// allowlist — a host must be BOTH non-internal AND on the allowlist. Empty
|
|
72
|
+
// allowlist ⇒ nothing passes (fail-closed). Malformed URL ⇒ false.
|
|
73
|
+
export function isAllowedFetchHost(url, allowlist = []) {
|
|
74
|
+
let host;
|
|
75
|
+
try {
|
|
76
|
+
host = new URL(String(url)).hostname.toLowerCase();
|
|
77
|
+
} catch {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
if (!host) return false;
|
|
81
|
+
// Strip IPv6 brackets: "[::1]" → "::1".
|
|
82
|
+
const h = host.replace(/^\[/, '').replace(/\]$/, '');
|
|
83
|
+
|
|
84
|
+
// Block internal / link-local / loopback destinations up front — these must
|
|
85
|
+
// never be reachable even if an operator mistakenly allowlists one.
|
|
86
|
+
if (h === 'localhost' || h.endsWith('.localhost')) return false;
|
|
87
|
+
if (h === '::1' || h === '0.0.0.0') return false;
|
|
88
|
+
if (h === '169.254.169.254' || h.startsWith('169.254.')) return false; // link-local + cloud metadata
|
|
89
|
+
if (h.startsWith('127.')) return false; // loopback /8
|
|
90
|
+
if (h.startsWith('10.')) return false; // RFC1918 /8
|
|
91
|
+
if (h.startsWith('192.168.')) return false; // RFC1918 /16
|
|
92
|
+
const m172 = h.match(/^172\.(\d{1,3})\./); // RFC1918 172.16-31/12
|
|
93
|
+
if (m172) {
|
|
94
|
+
const oct = Number(m172[1]);
|
|
95
|
+
if (oct >= 16 && oct <= 31) return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const allow = Array.isArray(allowlist)
|
|
99
|
+
? allowlist.map((a) => String(a).toLowerCase())
|
|
100
|
+
: [];
|
|
101
|
+
if (allow.length === 0) return false; // fail-closed: no allowlist ⇒ deny all
|
|
102
|
+
return allow.includes(h);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ─── redactSecrets ───────────────────────────────────────────────────────────
|
|
106
|
+
// Mask token-shaped substrings before finding-adjacent text is written to an
|
|
107
|
+
// audit log or handed to an LLM. The provider/scheme prefix is preserved so a
|
|
108
|
+
// human triager can still tell WHAT kind of credential leaked without seeing
|
|
109
|
+
// its value. Non-strings collapse to ''.
|
|
110
|
+
const _REDACTED = '***REDACTED***';
|
|
111
|
+
export function redactSecrets(s) {
|
|
112
|
+
if (typeof s !== 'string') return '';
|
|
113
|
+
return s
|
|
114
|
+
// URL basic-auth: scheme://user:password@ → keep user, mask password.
|
|
115
|
+
.replace(/(\b[a-z][a-z0-9+.-]*:\/\/[^\s:@/]+:)[^\s@/]+@/gi, `$1${_REDACTED}@`)
|
|
116
|
+
// Authorization: Bearer <token>
|
|
117
|
+
.replace(/\b(Authorization\s*:\s*Bearer\s+)[A-Za-z0-9._~+/=-]+/gi, `$1${_REDACTED}`)
|
|
118
|
+
// ?access_token= / &token= / ?token= / &access_token=
|
|
119
|
+
.replace(/([?&](?:access_token|token)=)[^&\s#]+/gi, `$1${_REDACTED}`)
|
|
120
|
+
// Raw provider token prefixes (GitHub PATs, Anthropic keys). Keep prefix.
|
|
121
|
+
.replace(/\b(ghp_|gho_|ghu_|ghs_|github_pat_|sk-ant-)[A-Za-z0-9_-]+/g, `$1${_REDACTED}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── secure filesystem writes ────────────────────────────────────────────────
|
|
125
|
+
// Audit logs, scan state, and any file that may carry finding text or secrets
|
|
126
|
+
// must be owner-only. openSync's mode argument is still masked by the process
|
|
127
|
+
// umask, so writeSecure ALSO chmods explicitly — the file is 0600 regardless of
|
|
128
|
+
// the ambient umask. secureDirMode is the matching 0700 for any parent dir we
|
|
129
|
+
// have to create.
|
|
130
|
+
export const secureFileMode = 0o600;
|
|
131
|
+
export const secureDirMode = 0o700;
|
|
132
|
+
|
|
133
|
+
export function writeSecure(filePath, data) {
|
|
134
|
+
const dir = path.dirname(filePath);
|
|
135
|
+
if (!fs.existsSync(dir)) {
|
|
136
|
+
fs.mkdirSync(dir, { recursive: true, mode: secureDirMode });
|
|
137
|
+
try { fs.chmodSync(dir, secureDirMode); } catch { /* best-effort */ }
|
|
138
|
+
}
|
|
139
|
+
const fd = fs.openSync(filePath, 'w', secureFileMode);
|
|
140
|
+
try {
|
|
141
|
+
fs.writeSync(fd, typeof data === 'string' ? data : String(data ?? ''));
|
|
142
|
+
} finally {
|
|
143
|
+
fs.closeSync(fd);
|
|
144
|
+
}
|
|
145
|
+
// Force the mode down even if umask loosened it at creation time.
|
|
146
|
+
fs.chmodSync(filePath, secureFileMode);
|
|
147
|
+
return filePath;
|
|
148
|
+
}
|