opensecurity 0.1.0 → 0.2.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/README.md +156 -30
- package/assets/grammars/README.md +9 -0
- package/assets/grammars/tree-sitter-c-sharp.wasm +0 -0
- package/assets/grammars/tree-sitter-c.wasm +0 -0
- package/assets/grammars/tree-sitter-cpp.wasm +0 -0
- package/assets/grammars/tree-sitter-go.wasm +0 -0
- package/assets/grammars/tree-sitter-java.wasm +0 -0
- package/assets/grammars/tree-sitter-kotlin.wasm +0 -0
- package/assets/grammars/tree-sitter-php.wasm +0 -0
- package/assets/grammars/tree-sitter-python.wasm +0 -0
- package/assets/grammars/tree-sitter-ruby.wasm +0 -0
- package/assets/grammars/tree-sitter-rust.wasm +0 -0
- package/assets/grammars/tree-sitter-swift.wasm +0 -0
- package/dist/adapters/bandit.js +41 -0
- package/dist/adapters/brakeman.js +41 -0
- package/dist/adapters/gosec.js +49 -0
- package/dist/adapters/languages.js +29 -0
- package/dist/adapters/runner.js +46 -0
- package/dist/adapters/semgrep.js +59 -0
- package/dist/adapters/types.js +1 -0
- package/dist/adapters/utils.js +52 -0
- package/dist/analysis/infraPatterns.js +196 -0
- package/dist/analysis/universalPatterns.js +56 -0
- package/dist/cli.js +15 -1
- package/dist/config.js +2 -1
- package/dist/native/languages.js +211 -0
- package/dist/native/loader.js +61 -0
- package/dist/native/rules.js +14 -0
- package/dist/native/taint.js +225 -0
- package/dist/scan.js +207 -0
- package/package.json +46 -2
- package/rules/taint/c.json +47 -0
- package/rules/taint/cpp.json +47 -0
- package/rules/taint/csharp.json +99 -0
- package/rules/taint/go.json +86 -0
- package/rules/taint/java.json +101 -0
- package/rules/taint/kotlin.json +86 -0
- package/rules/taint/php.json +100 -0
- package/rules/taint/python.json +108 -0
- package/rules/taint/ruby.json +101 -0
- package/rules/taint/rust.json +86 -0
- package/rules/taint/swift.json +86 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
function getNodeText(node, source) {
|
|
2
|
+
return source.slice(node.startIndex, node.endIndex);
|
|
3
|
+
}
|
|
4
|
+
function normalizeTraverseNode(node) {
|
|
5
|
+
return node;
|
|
6
|
+
}
|
|
7
|
+
function matchesCallee(name, matcher) {
|
|
8
|
+
if (matcher.callee) {
|
|
9
|
+
const match = matcher.callee;
|
|
10
|
+
if (Array.isArray(match) && match.includes(name))
|
|
11
|
+
return true;
|
|
12
|
+
if (match === name)
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
if (matcher.calleePrefix) {
|
|
16
|
+
const match = matcher.calleePrefix;
|
|
17
|
+
if (Array.isArray(match) && match.some((prefix) => name.startsWith(prefix)))
|
|
18
|
+
return true;
|
|
19
|
+
if (typeof match === "string" && name.startsWith(match))
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
if (matcher.calleePattern) {
|
|
23
|
+
const match = matcher.calleePattern;
|
|
24
|
+
const patterns = Array.isArray(match) ? match : [match];
|
|
25
|
+
return patterns.some((pattern) => matchGlob(name, pattern));
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
function matchGlob(value, pattern) {
|
|
30
|
+
if (pattern === value)
|
|
31
|
+
return true;
|
|
32
|
+
try {
|
|
33
|
+
const picomatch = require("picomatch");
|
|
34
|
+
const isMatch = picomatch(pattern, { nocase: false, dot: true });
|
|
35
|
+
return isMatch(value);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function getNodeName(node, lang, source) {
|
|
42
|
+
if (lang.identifierNodes.includes(node.type)) {
|
|
43
|
+
return getNodeText(node, source);
|
|
44
|
+
}
|
|
45
|
+
if (lang.memberNodes.includes(node.type)) {
|
|
46
|
+
const objectNode = pickField(node, lang.memberObjectFields);
|
|
47
|
+
const propNode = pickField(node, lang.memberPropertyFields);
|
|
48
|
+
const objectName = objectNode ? getNodeName(objectNode, lang, source) ?? getNodeText(objectNode, source) : null;
|
|
49
|
+
const propName = propNode ? getNodeName(propNode, lang, source) ?? getNodeText(propNode, source) : null;
|
|
50
|
+
if (objectName && propName)
|
|
51
|
+
return `${objectName}.${propName}`;
|
|
52
|
+
if (propName)
|
|
53
|
+
return propName;
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
function pickField(node, fields) {
|
|
58
|
+
for (const field of fields) {
|
|
59
|
+
const child = node.childForFieldName?.(field);
|
|
60
|
+
if (child)
|
|
61
|
+
return normalizeTraverseNode(child);
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
function getCallName(node, lang, source) {
|
|
66
|
+
const callee = pickField(node, lang.callCalleeFields) ??
|
|
67
|
+
(node.namedChildren?.[0] ? normalizeTraverseNode(node.namedChildren[0]) : null);
|
|
68
|
+
if (!callee)
|
|
69
|
+
return null;
|
|
70
|
+
return getNodeName(callee, lang, source) ?? getNodeText(callee, source);
|
|
71
|
+
}
|
|
72
|
+
function getCallArguments(node, lang) {
|
|
73
|
+
const argNode = pickField(node, lang.callArgumentFields);
|
|
74
|
+
if (argNode?.namedChildren?.length) {
|
|
75
|
+
return argNode.namedChildren.map(normalizeTraverseNode);
|
|
76
|
+
}
|
|
77
|
+
return [];
|
|
78
|
+
}
|
|
79
|
+
function getAssignmentSides(node, lang) {
|
|
80
|
+
const left = pickField(node, lang.assignmentLeftFields);
|
|
81
|
+
const right = pickField(node, lang.assignmentRightFields);
|
|
82
|
+
return { left: left ? normalizeTraverseNode(left) : null, right: right ? normalizeTraverseNode(right) : null };
|
|
83
|
+
}
|
|
84
|
+
function isStringNode(node, lang) {
|
|
85
|
+
return lang.stringNodes.includes(node.type);
|
|
86
|
+
}
|
|
87
|
+
function walk(node, fn) {
|
|
88
|
+
fn(node);
|
|
89
|
+
const children = node.namedChildren ?? [];
|
|
90
|
+
for (const child of children) {
|
|
91
|
+
walk(normalizeTraverseNode(child), fn);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
function findIdentifiers(node, lang, source) {
|
|
95
|
+
const names = [];
|
|
96
|
+
walk(node, (n) => {
|
|
97
|
+
const name = getNodeName(n, lang, source);
|
|
98
|
+
if (name && lang.identifierNodes.includes(n.type))
|
|
99
|
+
names.push(name);
|
|
100
|
+
});
|
|
101
|
+
return names;
|
|
102
|
+
}
|
|
103
|
+
export function runNativeTaint(tree, source, lang, ruleSet, filePath) {
|
|
104
|
+
const root = normalizeTraverseNode(tree.rootNode ?? tree);
|
|
105
|
+
const findings = [];
|
|
106
|
+
const taintedVarsStack = [new Set()];
|
|
107
|
+
const taintedExpressions = new WeakSet();
|
|
108
|
+
const pushScope = () => taintedVarsStack.push(new Set());
|
|
109
|
+
const popScope = () => taintedVarsStack.pop();
|
|
110
|
+
const currentScope = () => taintedVarsStack[taintedVarsStack.length - 1];
|
|
111
|
+
const taint = (name) => currentScope()?.add(name);
|
|
112
|
+
const untaint = (name) => currentScope()?.delete(name);
|
|
113
|
+
const isTainted = (name) => currentScope()?.has(name) ?? false;
|
|
114
|
+
const valueIsTainted = (node, rule) => {
|
|
115
|
+
if (taintedExpressions.has(node))
|
|
116
|
+
return true;
|
|
117
|
+
const nodeName = getNodeName(node, lang, source);
|
|
118
|
+
if (nodeName && rule.sources?.some((src) => matchesCallee(nodeName, src.matcher))) {
|
|
119
|
+
return true;
|
|
120
|
+
}
|
|
121
|
+
if (lang.identifierNodes.includes(node.type) && nodeName) {
|
|
122
|
+
return isTainted(nodeName);
|
|
123
|
+
}
|
|
124
|
+
if (lang.memberNodes.includes(node.type) && nodeName) {
|
|
125
|
+
return isTainted(nodeName);
|
|
126
|
+
}
|
|
127
|
+
if (lang.callNodes.includes(node.type)) {
|
|
128
|
+
const calleeName = getCallName(node, lang, source);
|
|
129
|
+
if (calleeName) {
|
|
130
|
+
if (rule.sanitizers?.some((san) => matchesCallee(calleeName, san.matcher)))
|
|
131
|
+
return false;
|
|
132
|
+
if (rule.sources?.some((src) => matchesCallee(calleeName, src.matcher)))
|
|
133
|
+
return true;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const children = node.namedChildren ?? [];
|
|
137
|
+
return children.some((child) => valueIsTainted(normalizeTraverseNode(child), rule));
|
|
138
|
+
};
|
|
139
|
+
const handleAssignment = (node, rule) => {
|
|
140
|
+
const { left, right } = getAssignmentSides(node, lang);
|
|
141
|
+
if (!left || !right)
|
|
142
|
+
return;
|
|
143
|
+
const targetNames = findIdentifiers(left, lang, source);
|
|
144
|
+
const tainted = valueIsTainted(right, rule);
|
|
145
|
+
for (const name of targetNames) {
|
|
146
|
+
if (tainted) {
|
|
147
|
+
taint(name);
|
|
148
|
+
taintedExpressions.add(right);
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
untaint(name);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
const reportFinding = (rule, node, message) => {
|
|
156
|
+
const loc = node.startPosition;
|
|
157
|
+
findings.push({
|
|
158
|
+
ruleId: rule.id,
|
|
159
|
+
ruleTitle: rule.title,
|
|
160
|
+
severity: rule.severity,
|
|
161
|
+
owasp: rule.owasp,
|
|
162
|
+
file: filePath,
|
|
163
|
+
line: loc ? loc.row + 1 : undefined,
|
|
164
|
+
column: loc ? loc.column + 1 : undefined,
|
|
165
|
+
message
|
|
166
|
+
});
|
|
167
|
+
};
|
|
168
|
+
const runRule = (rule) => {
|
|
169
|
+
if (rule.kind === "secret") {
|
|
170
|
+
const pattern = rule.literalPattern ? new RegExp(rule.literalPattern, "i") : null;
|
|
171
|
+
if (!pattern)
|
|
172
|
+
return;
|
|
173
|
+
walk(root, (node) => {
|
|
174
|
+
if (!isStringNode(node, lang))
|
|
175
|
+
return;
|
|
176
|
+
const text = getNodeText(node, source);
|
|
177
|
+
if (pattern.test(text)) {
|
|
178
|
+
reportFinding(rule, node, rule.title);
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (rule.kind === "direct") {
|
|
184
|
+
const matcher = {
|
|
185
|
+
callee: rule.callee,
|
|
186
|
+
calleePrefix: rule.calleePrefix,
|
|
187
|
+
calleePattern: rule.calleePattern
|
|
188
|
+
};
|
|
189
|
+
walk(root, (node) => {
|
|
190
|
+
if (!lang.callNodes.includes(node.type))
|
|
191
|
+
return;
|
|
192
|
+
const name = getCallName(node, lang, source);
|
|
193
|
+
if (name && matchesCallee(name, matcher)) {
|
|
194
|
+
reportFinding(rule, node, rule.title);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
walk(root, (node) => {
|
|
200
|
+
if (lang.assignmentNodes.includes(node.type)) {
|
|
201
|
+
handleAssignment(node, rule);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (lang.callNodes.includes(node.type)) {
|
|
205
|
+
const calleeName = getCallName(node, lang, source);
|
|
206
|
+
if (!calleeName)
|
|
207
|
+
return;
|
|
208
|
+
const sink = rule.sinks?.find((candidate) => matchesCallee(calleeName, candidate.matcher));
|
|
209
|
+
if (!sink)
|
|
210
|
+
return;
|
|
211
|
+
const args = getCallArguments(node, lang);
|
|
212
|
+
const hasTainted = args.some((arg) => valueIsTainted(arg, rule));
|
|
213
|
+
if (hasTainted) {
|
|
214
|
+
reportFinding(rule, node, `Tainted data reaches sink ${sink.name}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
};
|
|
219
|
+
for (const rule of ruleSet.rules) {
|
|
220
|
+
taintedVarsStack.length = 0;
|
|
221
|
+
taintedVarsStack.push(new Set());
|
|
222
|
+
runRule(rule);
|
|
223
|
+
}
|
|
224
|
+
return findings;
|
|
225
|
+
}
|
package/dist/scan.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
3
4
|
import traverseImport from "@babel/traverse";
|
|
4
5
|
import { execFile } from "node:child_process";
|
|
5
6
|
import { promisify } from "node:util";
|
|
@@ -10,8 +11,15 @@ import { walkFiles } from "./fileWalker.js";
|
|
|
10
11
|
import { parseSource } from "./analysis/ast.js";
|
|
11
12
|
import { runRuleEngine } from "./analysis/rules.js";
|
|
12
13
|
import { runPatternDetectors } from "./analysis/patterns.js";
|
|
14
|
+
import { runUniversalPatterns } from "./analysis/universalPatterns.js";
|
|
15
|
+
import { runInfraPatterns } from "./analysis/infraPatterns.js";
|
|
13
16
|
import { loadRules } from "./rules/loadRules.js";
|
|
14
17
|
import { scanDependenciesWithCves } from "./deps/engine.js";
|
|
18
|
+
import { runExternalAdapters } from "./adapters/runner.js";
|
|
19
|
+
import { getLanguageByExtension, getNativeLanguages } from "./native/languages.js";
|
|
20
|
+
import { parseWithTreeSitter } from "./native/loader.js";
|
|
21
|
+
import { loadNativeRules } from "./native/rules.js";
|
|
22
|
+
import { runNativeTaint } from "./native/taint.js";
|
|
15
23
|
export const SCHEMA_VERSION = "1.0.0";
|
|
16
24
|
const DEFAULT_MAX_CHARS = 4000;
|
|
17
25
|
const DEFAULT_CONCURRENCY = 2;
|
|
@@ -20,6 +28,8 @@ const DEFAULT_RETRY_DELAY_MS = 500;
|
|
|
20
28
|
const MAX_ESTIMATED_TOKENS = 50000; // Guardrail: reject massive scans
|
|
21
29
|
const CHARS_PER_TOKEN = 4; // Simple heuristic for estimation
|
|
22
30
|
export async function scan(options = {}) {
|
|
31
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
32
|
+
const outputFormat = options.format ?? "text";
|
|
23
33
|
const resolvedRoot = options.targetPath
|
|
24
34
|
? path.resolve(options.cwd ?? process.cwd(), options.targetPath)
|
|
25
35
|
: (options.cwd ?? process.cwd());
|
|
@@ -38,6 +48,13 @@ export async function scan(options = {}) {
|
|
|
38
48
|
const concurrency = Math.max(1, options.concurrency ?? projectConfig.concurrency ?? DEFAULT_CONCURRENCY);
|
|
39
49
|
const maxRetries = Math.max(0, options.maxRetries ?? DEFAULT_MAX_RETRIES);
|
|
40
50
|
const retryDelayMs = Math.max(0, options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS);
|
|
51
|
+
const nativeTaintEnabled = options.nativeTaint ?? projectConfig.nativeTaint ?? true;
|
|
52
|
+
const nativeLangs = resolveNativeLanguages(options.nativeTaintLanguages ?? projectConfig.nativeTaintLanguages);
|
|
53
|
+
const nativeCacheEnabled = options.nativeTaintCache ?? projectConfig.nativeTaintCache ?? true;
|
|
54
|
+
const nativeCachePath = resolveNativeCachePath(cwd, options.nativeTaintCachePath ?? projectConfig.nativeTaintCachePath);
|
|
55
|
+
const nativeCache = nativeCacheEnabled
|
|
56
|
+
? await loadNativeCache(nativeCachePath)
|
|
57
|
+
: { entries: new Map() };
|
|
41
58
|
let files = await walkFiles(cwd, filters);
|
|
42
59
|
if (options.diffOnly) {
|
|
43
60
|
const changed = await getChangedFiles(cwd, options.diffBase ?? "HEAD");
|
|
@@ -128,6 +145,110 @@ export async function scan(options = {}) {
|
|
|
128
145
|
category: "code"
|
|
129
146
|
});
|
|
130
147
|
}
|
|
148
|
+
const universalFindings = runUniversalPatterns(file.content, file.relPath);
|
|
149
|
+
for (const finding of universalFindings) {
|
|
150
|
+
findings.push({
|
|
151
|
+
id: finding.id,
|
|
152
|
+
severity: finding.severity,
|
|
153
|
+
title: finding.title,
|
|
154
|
+
description: `${finding.description} [${finding.owasp}]`,
|
|
155
|
+
file: finding.file,
|
|
156
|
+
line: finding.line,
|
|
157
|
+
owasp: finding.owasp,
|
|
158
|
+
category: "code"
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
if (isInfraFile(file.relPath)) {
|
|
162
|
+
const infraFindings = runInfraPatterns(file.content, file.relPath);
|
|
163
|
+
for (const finding of infraFindings) {
|
|
164
|
+
findings.push({
|
|
165
|
+
id: finding.id,
|
|
166
|
+
severity: finding.severity,
|
|
167
|
+
title: finding.title,
|
|
168
|
+
description: `${finding.description} [${finding.owasp}]`,
|
|
169
|
+
file: finding.file,
|
|
170
|
+
line: finding.line,
|
|
171
|
+
owasp: finding.owasp,
|
|
172
|
+
category: "code"
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const adaptersEnabled = !(options.noAdapters ?? projectConfig.noAdapters ?? false);
|
|
178
|
+
if (adaptersEnabled) {
|
|
179
|
+
const adapterFiles = files.filter((filePath) => isLikelyTextFile(filePath));
|
|
180
|
+
const { findings: adapterFindings, warnings } = await runExternalAdapters({
|
|
181
|
+
cwd,
|
|
182
|
+
files: adapterFiles,
|
|
183
|
+
allowList: options.adapters ?? projectConfig.adapters
|
|
184
|
+
});
|
|
185
|
+
for (const warning of warnings) {
|
|
186
|
+
if (outputFormat !== "json" && outputFormat !== "sarif") {
|
|
187
|
+
console.warn(warning);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
findings.push(...adapterFindings);
|
|
191
|
+
}
|
|
192
|
+
if (nativeTaintEnabled) {
|
|
193
|
+
const nativeWarnings = new Set();
|
|
194
|
+
const ruleCache = new Map();
|
|
195
|
+
const langConfigs = getNativeLanguages();
|
|
196
|
+
const langById = new Map(langConfigs.map((lang) => [lang.id, lang]));
|
|
197
|
+
const nativeFiles = files.filter((filePath) => {
|
|
198
|
+
const lang = getLanguageByExtension(filePath);
|
|
199
|
+
return Boolean(lang && nativeLangs.has(lang.id) && isLikelyTextFile(filePath));
|
|
200
|
+
});
|
|
201
|
+
for (const filePath of nativeFiles) {
|
|
202
|
+
const lang = getLanguageByExtension(filePath);
|
|
203
|
+
if (!lang || !nativeLangs.has(lang.id))
|
|
204
|
+
continue;
|
|
205
|
+
const relPath = path.relative(cwd, filePath);
|
|
206
|
+
const content = await fs.readFile(filePath, "utf8");
|
|
207
|
+
const cacheKey = computeNativeCacheKey(lang.id, content);
|
|
208
|
+
const cached = nativeCache.entries.get(relPath);
|
|
209
|
+
if (cached && cached.hash === cacheKey) {
|
|
210
|
+
const cachedFindings = cached.findings.map((f) => ({ ...f }));
|
|
211
|
+
for (const finding of cachedFindings)
|
|
212
|
+
findings.push(finding);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const parsed = await parseWithTreeSitter(content, lang, packageRoot);
|
|
216
|
+
if (!parsed) {
|
|
217
|
+
nativeWarnings.add(`Native taint skipped: missing parser for ${lang.name}. Run npm run build-grammars or install tree-sitter language bindings.`);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
let rules = ruleCache.get(lang.id);
|
|
221
|
+
if (!rules) {
|
|
222
|
+
rules = await loadNativeRules(packageRoot, lang.id);
|
|
223
|
+
ruleCache.set(lang.id, rules);
|
|
224
|
+
}
|
|
225
|
+
if (!rules) {
|
|
226
|
+
nativeWarnings.add(`Native taint skipped: no rules for ${lang.name}.`);
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
const nativeFindings = runNativeTaint(parsed.tree, content, lang, rules, relPath);
|
|
230
|
+
const mapped = nativeFindings.map((finding) => ({
|
|
231
|
+
id: finding.ruleId,
|
|
232
|
+
severity: finding.severity,
|
|
233
|
+
title: finding.ruleTitle,
|
|
234
|
+
description: `${finding.message} [${finding.owasp}]`,
|
|
235
|
+
file: finding.file,
|
|
236
|
+
line: finding.line,
|
|
237
|
+
column: finding.column,
|
|
238
|
+
owasp: finding.owasp,
|
|
239
|
+
category: "code"
|
|
240
|
+
}));
|
|
241
|
+
for (const finding of mapped)
|
|
242
|
+
findings.push(finding);
|
|
243
|
+
if (nativeCacheEnabled) {
|
|
244
|
+
nativeCache.entries.set(relPath, { hash: cacheKey, findings: mapped });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
if (nativeWarnings.size && outputFormat !== "json" && outputFormat !== "sarif") {
|
|
248
|
+
for (const warning of nativeWarnings) {
|
|
249
|
+
console.warn(warning);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
131
252
|
}
|
|
132
253
|
if ((apiKey || useCodexCli) && !options.noAi) {
|
|
133
254
|
if (options.aiMultiAgent) {
|
|
@@ -258,6 +379,34 @@ export async function scan(options = {}) {
|
|
|
258
379
|
for (const filePath of nonJsFiles) {
|
|
259
380
|
const relPath = path.relative(cwd, filePath);
|
|
260
381
|
const content = await fs.readFile(filePath, "utf8");
|
|
382
|
+
const universalFindings = runUniversalPatterns(content, relPath);
|
|
383
|
+
for (const finding of universalFindings) {
|
|
384
|
+
findings.push({
|
|
385
|
+
id: finding.id,
|
|
386
|
+
severity: finding.severity,
|
|
387
|
+
title: finding.title,
|
|
388
|
+
description: `${finding.description} [${finding.owasp}]`,
|
|
389
|
+
file: finding.file,
|
|
390
|
+
line: finding.line,
|
|
391
|
+
owasp: finding.owasp,
|
|
392
|
+
category: "code"
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
if (isInfraFile(relPath)) {
|
|
396
|
+
const infraFindings = runInfraPatterns(content, relPath);
|
|
397
|
+
for (const finding of infraFindings) {
|
|
398
|
+
findings.push({
|
|
399
|
+
id: finding.id,
|
|
400
|
+
severity: finding.severity,
|
|
401
|
+
title: finding.title,
|
|
402
|
+
description: `${finding.description} [${finding.owasp}]`,
|
|
403
|
+
file: finding.file,
|
|
404
|
+
line: finding.line,
|
|
405
|
+
owasp: finding.owasp,
|
|
406
|
+
category: "code"
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
}
|
|
261
410
|
const chunks = chunkText(content, maxChars);
|
|
262
411
|
for (let i = 0; i < chunks.length; i += 1) {
|
|
263
412
|
const prompt = buildPrompt(relPath, chunks[i], i + 1, chunks.length);
|
|
@@ -355,6 +504,9 @@ export async function scan(options = {}) {
|
|
|
355
504
|
}
|
|
356
505
|
await saveAiCache(cachePath, aiCache);
|
|
357
506
|
}
|
|
507
|
+
if (nativeCacheEnabled) {
|
|
508
|
+
await saveNativeCache(nativeCachePath, nativeCache);
|
|
509
|
+
}
|
|
358
510
|
return { findings: dedupeFindings(findings) };
|
|
359
511
|
}
|
|
360
512
|
export async function listMatchedFiles(options = {}) {
|
|
@@ -956,6 +1108,19 @@ function isLikelyTextFile(filePath) {
|
|
|
956
1108
|
]);
|
|
957
1109
|
return !blocked.has(ext);
|
|
958
1110
|
}
|
|
1111
|
+
function isInfraFile(filePath) {
|
|
1112
|
+
const normalized = filePath.split(path.sep).join("/");
|
|
1113
|
+
const base = path.basename(normalized).toLowerCase();
|
|
1114
|
+
if (base === "dockerfile" || base.endsWith(".dockerfile"))
|
|
1115
|
+
return true;
|
|
1116
|
+
if (base.endsWith(".tf") || base.endsWith(".tfvars"))
|
|
1117
|
+
return true;
|
|
1118
|
+
if (base.endsWith(".yaml") || base.endsWith(".yml"))
|
|
1119
|
+
return true;
|
|
1120
|
+
if (normalized.includes("/k8s/") || normalized.includes("/kubernetes/") || normalized.includes("/helm/"))
|
|
1121
|
+
return true;
|
|
1122
|
+
return false;
|
|
1123
|
+
}
|
|
959
1124
|
export function groupFilesByModule(relPaths, depth) {
|
|
960
1125
|
const groups = new Map();
|
|
961
1126
|
for (const relPath of relPaths) {
|
|
@@ -998,6 +1163,12 @@ function resolveCachePath(cwd, override) {
|
|
|
998
1163
|
}
|
|
999
1164
|
return path.join(cwd, ".opensecurity", "ai-cache.json");
|
|
1000
1165
|
}
|
|
1166
|
+
function resolveNativeCachePath(cwd, override) {
|
|
1167
|
+
if (override && override.trim()) {
|
|
1168
|
+
return path.isAbsolute(override) ? override : path.join(cwd, override);
|
|
1169
|
+
}
|
|
1170
|
+
return path.join(cwd, ".opensecurity", "native-taint-cache.json");
|
|
1171
|
+
}
|
|
1001
1172
|
async function loadAiCache(cachePath) {
|
|
1002
1173
|
try {
|
|
1003
1174
|
const raw = await fs.readFile(cachePath, "utf8");
|
|
@@ -1019,6 +1190,27 @@ async function saveAiCache(cachePath, cache) {
|
|
|
1019
1190
|
await fs.mkdir(path.dirname(cachePath), { recursive: true });
|
|
1020
1191
|
await fs.writeFile(cachePath, JSON.stringify(obj, null, 2), "utf8");
|
|
1021
1192
|
}
|
|
1193
|
+
async function loadNativeCache(cachePath) {
|
|
1194
|
+
try {
|
|
1195
|
+
const raw = await fs.readFile(cachePath, "utf8");
|
|
1196
|
+
const parsed = JSON.parse(raw);
|
|
1197
|
+
const entries = new Map(Object.entries(parsed ?? {}));
|
|
1198
|
+
return { entries };
|
|
1199
|
+
}
|
|
1200
|
+
catch (err) {
|
|
1201
|
+
if (err?.code === "ENOENT")
|
|
1202
|
+
return { entries: new Map() };
|
|
1203
|
+
return { entries: new Map() };
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
async function saveNativeCache(cachePath, cache) {
|
|
1207
|
+
const obj = {};
|
|
1208
|
+
for (const [key, value] of cache.entries.entries()) {
|
|
1209
|
+
obj[key] = value;
|
|
1210
|
+
}
|
|
1211
|
+
await fs.mkdir(path.dirname(cachePath), { recursive: true });
|
|
1212
|
+
await fs.writeFile(cachePath, JSON.stringify(obj, null, 2), "utf8");
|
|
1213
|
+
}
|
|
1022
1214
|
function computeCacheKey(provider, model, content) {
|
|
1023
1215
|
return crypto
|
|
1024
1216
|
.createHash("sha256")
|
|
@@ -1029,6 +1221,21 @@ function computeCacheKey(provider, model, content) {
|
|
|
1029
1221
|
.update(content)
|
|
1030
1222
|
.digest("hex");
|
|
1031
1223
|
}
|
|
1224
|
+
function computeNativeCacheKey(language, content) {
|
|
1225
|
+
return crypto
|
|
1226
|
+
.createHash("sha256")
|
|
1227
|
+
.update(language)
|
|
1228
|
+
.update("|")
|
|
1229
|
+
.update(content)
|
|
1230
|
+
.digest("hex");
|
|
1231
|
+
}
|
|
1232
|
+
function resolveNativeLanguages(list) {
|
|
1233
|
+
const all = getNativeLanguages().map((lang) => lang.id);
|
|
1234
|
+
if (!list || list.length === 0)
|
|
1235
|
+
return new Set(all);
|
|
1236
|
+
const normalized = new Set(list.map((item) => item.trim().toLowerCase()).filter(Boolean));
|
|
1237
|
+
return new Set(all.filter((lang) => normalized.has(lang)));
|
|
1238
|
+
}
|
|
1032
1239
|
async function safeStat(target) {
|
|
1033
1240
|
try {
|
|
1034
1241
|
return await fs.stat(target);
|
package/package.json
CHANGED
|
@@ -1,8 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opensecurity",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"private": false,
|
|
5
|
+
"description": "Open-source CLI for scanning repositories for security risks across code, infra, and dependencies.",
|
|
6
|
+
"license": "MIT",
|
|
5
7
|
"type": "module",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/eliophan/opensecurity.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/eliophan/opensecurity",
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/eliophan/opensecurity/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"security",
|
|
18
|
+
"sast",
|
|
19
|
+
"static-analysis",
|
|
20
|
+
"taint",
|
|
21
|
+
"vulnerability",
|
|
22
|
+
"scanner",
|
|
23
|
+
"devsecops",
|
|
24
|
+
"cli",
|
|
25
|
+
"dependency",
|
|
26
|
+
"ai"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=20"
|
|
30
|
+
},
|
|
6
31
|
"bin": {
|
|
7
32
|
"opensecurity": "dist/cli.js",
|
|
8
33
|
"openSecurity": "dist/cli.js"
|
|
@@ -11,6 +36,7 @@
|
|
|
11
36
|
"dev": "tsx src/cli.ts",
|
|
12
37
|
"proxy": "tsx src/proxy.ts",
|
|
13
38
|
"build": "tsc",
|
|
39
|
+
"build-grammars": "tsx scripts/build-grammars.ts",
|
|
14
40
|
"prepack": "npm run build",
|
|
15
41
|
"package": "npm run build && npm pack",
|
|
16
42
|
"test": "vitest run",
|
|
@@ -18,6 +44,8 @@
|
|
|
18
44
|
},
|
|
19
45
|
"files": [
|
|
20
46
|
"dist",
|
|
47
|
+
"assets",
|
|
48
|
+
"rules",
|
|
21
49
|
"README.md",
|
|
22
50
|
"LICENSE"
|
|
23
51
|
],
|
|
@@ -27,7 +55,11 @@
|
|
|
27
55
|
"@babel/types": "^7.26.9",
|
|
28
56
|
"commander": "^12.0.0",
|
|
29
57
|
"picomatch": "^4.0.2",
|
|
30
|
-
"semver": "^7.6.3"
|
|
58
|
+
"semver": "^7.6.3",
|
|
59
|
+
"web-tree-sitter": "^0.21.0"
|
|
60
|
+
},
|
|
61
|
+
"optionalDependencies": {
|
|
62
|
+
"tree-sitter": "^0.21.1"
|
|
31
63
|
},
|
|
32
64
|
"devDependencies": {
|
|
33
65
|
"@types/babel__traverse": "^7.20.7",
|
|
@@ -37,6 +69,18 @@
|
|
|
37
69
|
"@typescript-eslint/eslint-plugin": "^8.57.0",
|
|
38
70
|
"@typescript-eslint/parser": "^8.57.0",
|
|
39
71
|
"eslint": "^9.39.4",
|
|
72
|
+
"tree-sitter-cli": "^0.21.0",
|
|
73
|
+
"tree-sitter-c": "^0.21.4",
|
|
74
|
+
"tree-sitter-c-sharp": "^0.23.1",
|
|
75
|
+
"tree-sitter-cpp": "^0.22.3",
|
|
76
|
+
"tree-sitter-go": "^0.21.2",
|
|
77
|
+
"tree-sitter-java": "^0.21.0",
|
|
78
|
+
"tree-sitter-kotlin": "^0.3.8",
|
|
79
|
+
"tree-sitter-php": "^0.22.8",
|
|
80
|
+
"tree-sitter-python": "^0.21.0",
|
|
81
|
+
"tree-sitter-ruby": "^0.21.0",
|
|
82
|
+
"tree-sitter-rust": "^0.24.0",
|
|
83
|
+
"tree-sitter-swift": "^0.7.1",
|
|
40
84
|
"tsx": "^4.19.0",
|
|
41
85
|
"typescript": "^5.7.3",
|
|
42
86
|
"vitest": "^2.1.8"
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"language": "c",
|
|
3
|
+
"rules": [
|
|
4
|
+
{
|
|
5
|
+
"id": "c-cmd",
|
|
6
|
+
"title": "Command Injection",
|
|
7
|
+
"severity": "high",
|
|
8
|
+
"owasp": "A03:2021 Injection",
|
|
9
|
+
"kind": "taint",
|
|
10
|
+
"sources": [
|
|
11
|
+
{ "id": "argv", "name": "argv", "matcher": { "calleePattern": ["argv", "getenv"] } }
|
|
12
|
+
],
|
|
13
|
+
"sinks": [
|
|
14
|
+
{ "id": "system", "name": "system", "matcher": { "callee": ["system", "popen"] } }
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"id": "c-path",
|
|
19
|
+
"title": "Path Traversal",
|
|
20
|
+
"severity": "high",
|
|
21
|
+
"owasp": "A01:2021 Broken Access Control",
|
|
22
|
+
"kind": "taint",
|
|
23
|
+
"sources": [
|
|
24
|
+
{ "id": "argv", "name": "argv", "matcher": { "calleePattern": ["argv", "getenv"] } }
|
|
25
|
+
],
|
|
26
|
+
"sinks": [
|
|
27
|
+
{ "id": "fopen", "name": "fopen", "matcher": { "callee": ["fopen", "open", "read"] } }
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"id": "c-weak-crypto",
|
|
32
|
+
"title": "Weak Crypto",
|
|
33
|
+
"severity": "medium",
|
|
34
|
+
"owasp": "A02:2021 Cryptographic Failures",
|
|
35
|
+
"kind": "direct",
|
|
36
|
+
"calleePattern": ["*MD5*", "*SHA1*", "MD5", "SHA1"]
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"id": "c-hardcoded-secret",
|
|
40
|
+
"title": "Hardcoded Secret",
|
|
41
|
+
"severity": "medium",
|
|
42
|
+
"owasp": "A02:2021 Cryptographic Failures",
|
|
43
|
+
"kind": "secret",
|
|
44
|
+
"literalPattern": "(api|secret|token|password|passwd|pwd|key)[^\\n\\r]{0,20}[\"'][A-Za-z0-9_\\-]{8,}[\"']"
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"language": "cpp",
|
|
3
|
+
"rules": [
|
|
4
|
+
{
|
|
5
|
+
"id": "cpp-cmd",
|
|
6
|
+
"title": "Command Injection",
|
|
7
|
+
"severity": "high",
|
|
8
|
+
"owasp": "A03:2021 Injection",
|
|
9
|
+
"kind": "taint",
|
|
10
|
+
"sources": [
|
|
11
|
+
{ "id": "argv", "name": "argv", "matcher": { "calleePattern": ["argv", "getenv"] } }
|
|
12
|
+
],
|
|
13
|
+
"sinks": [
|
|
14
|
+
{ "id": "system", "name": "system", "matcher": { "callee": ["system", "popen"] } }
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"id": "cpp-path",
|
|
19
|
+
"title": "Path Traversal",
|
|
20
|
+
"severity": "high",
|
|
21
|
+
"owasp": "A01:2021 Broken Access Control",
|
|
22
|
+
"kind": "taint",
|
|
23
|
+
"sources": [
|
|
24
|
+
{ "id": "argv", "name": "argv", "matcher": { "calleePattern": ["argv", "getenv"] } }
|
|
25
|
+
],
|
|
26
|
+
"sinks": [
|
|
27
|
+
{ "id": "fstream", "name": "fstream", "matcher": { "calleePattern": ["std::fstream", "std::ifstream", "std::ofstream", "fopen", "open"] } }
|
|
28
|
+
]
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"id": "cpp-weak-crypto",
|
|
32
|
+
"title": "Weak Crypto",
|
|
33
|
+
"severity": "medium",
|
|
34
|
+
"owasp": "A02:2021 Cryptographic Failures",
|
|
35
|
+
"kind": "direct",
|
|
36
|
+
"calleePattern": ["*MD5*", "*SHA1*", "MD5", "SHA1"]
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"id": "cpp-hardcoded-secret",
|
|
40
|
+
"title": "Hardcoded Secret",
|
|
41
|
+
"severity": "medium",
|
|
42
|
+
"owasp": "A02:2021 Cryptographic Failures",
|
|
43
|
+
"kind": "secret",
|
|
44
|
+
"literalPattern": "(api|secret|token|password|passwd|pwd|key)[^\\n\\r]{0,20}[\"'][A-Za-z0-9_\\-]{8,}[\"']"
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
}
|