@clear-capabilities/agentic-security-scanner 0.173.0 → 0.175.0
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 +18 -0
- package/dist/agentic-security.mjs +1 -1
- package/dist/agentic-security.mjs.sha256 +1 -1
- package/package.json +4 -4
- package/src/dataflow/engine.js +10 -4
- package/src/language/haskell-findings.js +2 -0
- package/src/language/haskell-guards.js +1 -1
- package/src/language/haskell-ir.js +3 -1
- package/src/language/haskell-models.js +94 -0
- package/src/language/haskell-security-rules.js +21 -1
- package/src/language/nixos-hardening.js +109 -0
- package/src/sast/zip-slip.js +79 -0
package/src/sast/zip-slip.js
CHANGED
|
@@ -38,12 +38,91 @@ import { blankComments } from './_comment-strip.js';
|
|
|
38
38
|
|
|
39
39
|
function _lineOf(raw, idx) { return raw.substring(0, idx).split('\n').length; }
|
|
40
40
|
|
|
41
|
+
// Go (archive/zip, archive/tar). The entry name comes from the archive, so `filepath.Join(dest, f.Name)` is attacker-chosen unless the joined path is checked
|
|
42
|
+
// against `dest`. The check is looked for in the SAME FUNCTION as the use, and it must name the joined path (or the entry name), because a `HasPrefix` on some
|
|
43
|
+
// other value in the same function proves nothing about this one.
|
|
44
|
+
const GO_ARCHIVE_IMPORT_RE = /["`](?:archive\/(?:zip|tar)|github\.com\/(?:klauspost\/compress\/zip|mholt\/archiver(?:\/v\d+)?|mholt\/archives|bodgit\/sevenzip|nwaples\/rardecode(?:\/v\d+)?))["`]/;
|
|
45
|
+
const GO_ZIP_RANGE_RE = /\bfor\s+(?:\w+\s*,\s*)?(\w+)\s*:=\s*range\s+[\w.()]+?\.File\b/g;
|
|
46
|
+
const GO_TAR_NEXT_RE = /\b(\w+)\s*,\s*\w+\s*:?=\s*[\w.]+\.Next\s*\(\s*\)/g;
|
|
47
|
+
const GO_WRITE_SINKS = 'os\\.(?:Create|OpenFile|MkdirAll|Mkdir|WriteFile|Rename|Symlink|Link|Chmod|Remove|RemoveAll)|ioutil\\.WriteFile|afero\\.WriteFile|fs\\.(?:Create|OpenFile|MkdirAll|WriteFile)';
|
|
48
|
+
const GO_GUARD_CALL_RE = /\b(?:strings\.HasPrefix|filepath\.IsLocal|filepath\.Rel|filepath\.IsAbs|securejoin\.SecureJoin|SecureJoin|os\.OpenRoot|OpenInRoot)\s*\(|\bstrings\.Contains\s*\([^)]*[\"`]\.\.[\"`]/;
|
|
49
|
+
|
|
50
|
+
/** The text of the Go function around `idx`, by brace matching from the nearest preceding `func`. */
|
|
51
|
+
function _goFunctionAround(code, idx) {
|
|
52
|
+
const start = code.lastIndexOf('\nfunc ', idx);
|
|
53
|
+
const from = start === -1 ? 0 : start + 1;
|
|
54
|
+
const open = code.indexOf('{', from);
|
|
55
|
+
if (open === -1 || open > idx) return code.slice(from, Math.min(code.length, idx + 2000));
|
|
56
|
+
let depth = 0; let inS = null;
|
|
57
|
+
for (let i = open; i < code.length; i++) {
|
|
58
|
+
const c = code[i];
|
|
59
|
+
if (inS) { if (c === '\\' && inS !== '`') { i++; continue; } if (c === inS) inS = null; continue; }
|
|
60
|
+
if (c === '"' || c === '`') { inS = c; continue; }
|
|
61
|
+
if (c === "'") { const e = code.indexOf("'", i + 2); if (e !== -1 && e - i <= 4) { i = e; continue; } }
|
|
62
|
+
if (c === '{') depth++;
|
|
63
|
+
else if (c === '}') { depth--; if (depth === 0) return code.slice(from, i + 1); }
|
|
64
|
+
}
|
|
65
|
+
return code.slice(from);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function _scanGoArchive(fp, raw, push) {
|
|
69
|
+
const code = blankComments(raw);
|
|
70
|
+
if (!GO_ARCHIVE_IMPORT_RE.test(code)) return;
|
|
71
|
+
// the names that hold an archive entry: `for _, f := range r.File`, `hdr, err := tr.Next()`
|
|
72
|
+
const entries = new Set();
|
|
73
|
+
for (const re of [GO_ZIP_RANGE_RE, GO_TAR_NEXT_RE]) { const r = new RegExp(re.source, re.flags); let m; while ((m = r.exec(code))) entries.add(m[1]); }
|
|
74
|
+
if (!entries.size) return;
|
|
75
|
+
const ent = `(?:${[...entries].map((e) => e.replace(/[$]/g, '\\$')).join('|')})`;
|
|
76
|
+
const nameRef = `${ent}\\s*\\.\\s*Name\\b`;
|
|
77
|
+
// a path built from the entry name: filepath.Join(x, f.Name) / path.Join / dest + "/" + f.Name
|
|
78
|
+
const builtRe = new RegExp(`(\\w+)\\s*:?=\\s*(?:(?:filepath|path)\\.(?:Join|Clean)\\s*\\([^\\n]*?${nameRef}[^\\n]*\\)|[^\\n]*?\\+\\s*${nameRef}[^\\n]*|[^\\n]*?${nameRef}\\s*\\+[^\\n]*)`, 'g');
|
|
79
|
+
const sinkRe = new RegExp(`\\b(?:${GO_WRITE_SINKS})\\s*\\(([^\\n]*)`, 'g');
|
|
80
|
+
const lineOfIdx = (i) => _lineOf(raw, i);
|
|
81
|
+
const flag = (idx, how) => {
|
|
82
|
+
const line = lineOfIdx(idx);
|
|
83
|
+
push({
|
|
84
|
+
id: `zip-slip:${fp}:${line}:go`, file: fp, line,
|
|
85
|
+
vuln: `Zip Slip: an archive entry name reaches a file operation (${how}) without a check that the result stays inside the extraction directory`,
|
|
86
|
+
severity: 'high', cwe: 'CWE-22', stride: 'Tampering',
|
|
87
|
+
snippet: (raw.split('\n')[line - 1] || '').trim().slice(0, 200),
|
|
88
|
+
remediation: 'An archive entry may be named `../../x` or be absolute. After joining, clean the path and refuse it unless it is inside the destination: `target := filepath.Join(dest, f.Name); if !strings.HasPrefix(target, filepath.Clean(dest)+string(os.PathSeparator)) { return error }` (or `filepath.IsLocal(f.Name)` on Go 1.20+, or `os.Root` on Go 1.24+).',
|
|
89
|
+
confidence: 0.8, parser: 'ZIP-SLIP',
|
|
90
|
+
});
|
|
91
|
+
};
|
|
92
|
+
const handled = new Set();
|
|
93
|
+
// 1) a variable built from the entry name, then used by a write
|
|
94
|
+
let b;
|
|
95
|
+
while ((b = builtRe.exec(code))) {
|
|
96
|
+
const v = b[1];
|
|
97
|
+
const fn = _goFunctionAround(code, b.index);
|
|
98
|
+
const fnStart = code.indexOf(fn);
|
|
99
|
+
// is the variable checked in this function? a guard call must mention it (or the entry name)
|
|
100
|
+
const mention = new RegExp(`\\b${v}\\b|${nameRef}`);
|
|
101
|
+
const guarded = fn.split('\n').some((l) => GO_GUARD_CALL_RE.test(l) && mention.test(l));
|
|
102
|
+
if (guarded) { handled.add(b.index); continue; }
|
|
103
|
+
const useRe = new RegExp(`\\b(?:${GO_WRITE_SINKS})\\s*\\([^\\n]*\\b${v}\\b`, 'g');
|
|
104
|
+
useRe.lastIndex = 0;
|
|
105
|
+
const use = useRe.exec(fn);
|
|
106
|
+
if (use) { handled.add(b.index); flag(fnStart + use.index, 'built from the entry name'); }
|
|
107
|
+
}
|
|
108
|
+
// 2) the entry name used directly in a write
|
|
109
|
+
let s;
|
|
110
|
+
while ((s = sinkRe.exec(code))) {
|
|
111
|
+
if (!new RegExp(nameRef).test(s[1])) continue;
|
|
112
|
+
const fn = _goFunctionAround(code, s.index);
|
|
113
|
+
const guarded = fn.split('\n').some((l) => GO_GUARD_CALL_RE.test(l) && new RegExp(nameRef).test(l));
|
|
114
|
+
if (!guarded) flag(s.index, 'the entry name itself');
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
41
118
|
export function scanZipSlip(fp, raw) {
|
|
42
119
|
if (!raw || raw.length > 500_000) return [];
|
|
43
120
|
const findings = [];
|
|
44
121
|
const seen = new Set();
|
|
45
122
|
const push = (f) => { if (!seen.has(f.id)) { seen.add(f.id); findings.push(f); } };
|
|
46
123
|
|
|
124
|
+
if (/\.go$/i.test(fp) && !/_test\.go$/i.test(fp)) _scanGoArchive(fp, raw, push);
|
|
125
|
+
|
|
47
126
|
if (/\.(?:java|kt|kts|scala|groovy)$/i.test(fp)) {
|
|
48
127
|
const code = blankComments(raw);
|
|
49
128
|
// File-wide suppression: canonical path + startsWith pair present
|