circle-ir 3.18.8 → 3.19.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/dist/analysis/config-loader.d.ts +11 -1
- package/dist/analysis/config-loader.js +99 -0
- package/dist/analysis/config-loader.js.map +1 -1
- package/dist/analysis/passes/security-headers-pass.d.ts +46 -0
- package/dist/analysis/passes/security-headers-pass.js +226 -0
- package/dist/analysis/passes/security-headers-pass.js.map +1 -0
- package/dist/analyzer.d.ts +3 -0
- package/dist/analyzer.js +3 -0
- package/dist/analyzer.js.map +1 -1
- package/dist/browser/circle-ir.js +236 -0
- package/dist/types/config.d.ts +45 -1
- package/package.json +1 -1
|
@@ -10335,6 +10335,93 @@ function getDefaultConfig() {
|
|
|
10335
10335
|
sanitizers: DEFAULT_SANITIZERS
|
|
10336
10336
|
};
|
|
10337
10337
|
}
|
|
10338
|
+
var DEFAULT_HEADER_RULES = [
|
|
10339
|
+
// -------------------------------------------------------------------------
|
|
10340
|
+
// Clickjacking (CWE-1021)
|
|
10341
|
+
// -------------------------------------------------------------------------
|
|
10342
|
+
{
|
|
10343
|
+
rule_id: "missing-x-frame-options",
|
|
10344
|
+
cwe: "CWE-1021",
|
|
10345
|
+
level: "warning",
|
|
10346
|
+
severity: "medium",
|
|
10347
|
+
header: "X-Frame-Options",
|
|
10348
|
+
kind: "missing",
|
|
10349
|
+
requiresHandler: true,
|
|
10350
|
+
message: "HTTP handler does not set X-Frame-Options \u2014 vulnerable to clickjacking",
|
|
10351
|
+
fix: "Set response.setHeader('X-Frame-Options', 'DENY') or use a CSP frame-ancestors directive",
|
|
10352
|
+
note: "Defense against UI redress / clickjacking attacks"
|
|
10353
|
+
},
|
|
10354
|
+
{
|
|
10355
|
+
rule_id: "x-frame-options-allow-from",
|
|
10356
|
+
cwe: "CWE-1021",
|
|
10357
|
+
level: "warning",
|
|
10358
|
+
severity: "medium",
|
|
10359
|
+
header: "X-Frame-Options",
|
|
10360
|
+
kind: "weak-value",
|
|
10361
|
+
valuePattern: /^allow-from\b/i,
|
|
10362
|
+
message: "X-Frame-Options: ALLOW-FROM is deprecated and unsupported by modern browsers",
|
|
10363
|
+
fix: "Use CSP frame-ancestors directive instead: Content-Security-Policy: frame-ancestors 'self'"
|
|
10364
|
+
},
|
|
10365
|
+
{
|
|
10366
|
+
rule_id: "missing-csp-frame-ancestors",
|
|
10367
|
+
cwe: "CWE-1021",
|
|
10368
|
+
level: "note",
|
|
10369
|
+
severity: "low",
|
|
10370
|
+
header: "Content-Security-Policy",
|
|
10371
|
+
kind: "missing",
|
|
10372
|
+
requiresHandler: true,
|
|
10373
|
+
message: "HTTP handler does not set Content-Security-Policy \u2014 frame-ancestors unset",
|
|
10374
|
+
fix: "Set Content-Security-Policy: frame-ancestors 'self' for defense-in-depth clickjacking protection",
|
|
10375
|
+
note: "Informational; paired with missing-x-frame-options"
|
|
10376
|
+
},
|
|
10377
|
+
// -------------------------------------------------------------------------
|
|
10378
|
+
// CORS Misconfiguration (CWE-346, CWE-942)
|
|
10379
|
+
// -------------------------------------------------------------------------
|
|
10380
|
+
{
|
|
10381
|
+
rule_id: "cors-wildcard-origin",
|
|
10382
|
+
cwe: "CWE-942",
|
|
10383
|
+
level: "error",
|
|
10384
|
+
severity: "high",
|
|
10385
|
+
header: "Access-Control-Allow-Origin",
|
|
10386
|
+
kind: "weak-value",
|
|
10387
|
+
valuePattern: /^\*$/,
|
|
10388
|
+
message: "Access-Control-Allow-Origin: '*' permits cross-origin requests from any site",
|
|
10389
|
+
fix: "Restrict to a specific trusted origin or use an allowlist"
|
|
10390
|
+
},
|
|
10391
|
+
{
|
|
10392
|
+
rule_id: "cors-null-origin",
|
|
10393
|
+
cwe: "CWE-346",
|
|
10394
|
+
level: "error",
|
|
10395
|
+
severity: "high",
|
|
10396
|
+
header: "Access-Control-Allow-Origin",
|
|
10397
|
+
kind: "weak-value",
|
|
10398
|
+
valuePattern: /^null$/i,
|
|
10399
|
+
message: "Access-Control-Allow-Origin: 'null' is exploitable via sandboxed iframes and data: URIs",
|
|
10400
|
+
fix: "Restrict to a specific trusted origin"
|
|
10401
|
+
},
|
|
10402
|
+
{
|
|
10403
|
+
rule_id: "cors-http-origin",
|
|
10404
|
+
cwe: "CWE-346",
|
|
10405
|
+
level: "warning",
|
|
10406
|
+
severity: "medium",
|
|
10407
|
+
header: "Access-Control-Allow-Origin",
|
|
10408
|
+
kind: "weak-value",
|
|
10409
|
+
valuePattern: /^http:\/\//i,
|
|
10410
|
+
message: "Access-Control-Allow-Origin uses insecure http:// scheme",
|
|
10411
|
+
fix: "Use https:// for the allowed origin"
|
|
10412
|
+
},
|
|
10413
|
+
{
|
|
10414
|
+
rule_id: "cors-reflected-origin",
|
|
10415
|
+
cwe: "CWE-346",
|
|
10416
|
+
level: "error",
|
|
10417
|
+
severity: "high",
|
|
10418
|
+
header: "Access-Control-Allow-Origin",
|
|
10419
|
+
kind: "unsafe-value",
|
|
10420
|
+
message: "Access-Control-Allow-Origin set to a dynamic value \u2014 possible origin reflection",
|
|
10421
|
+
fix: "Validate the Origin request header against an allowlist before echoing it back",
|
|
10422
|
+
note: "Fires when the value is not a string literal (likely reflected from request)"
|
|
10423
|
+
}
|
|
10424
|
+
];
|
|
10338
10425
|
|
|
10339
10426
|
// src/analysis/taint-matcher.ts
|
|
10340
10427
|
var PYTHON_TAINTED_PATTERNS = [
|
|
@@ -22804,6 +22891,154 @@ var NamingConventionPass = class {
|
|
|
22804
22891
|
}
|
|
22805
22892
|
};
|
|
22806
22893
|
|
|
22894
|
+
// src/analysis/passes/security-headers-pass.ts
|
|
22895
|
+
var HEADER_WRITE_METHODS = /* @__PURE__ */ new Set([
|
|
22896
|
+
"setHeader",
|
|
22897
|
+
"addHeader",
|
|
22898
|
+
// Java + Node
|
|
22899
|
+
"set",
|
|
22900
|
+
"header",
|
|
22901
|
+
// Express res.set / res.header
|
|
22902
|
+
"insert_header",
|
|
22903
|
+
// Actix Web HttpResponse
|
|
22904
|
+
"insert"
|
|
22905
|
+
// Rust HeaderMap.insert (best-effort)
|
|
22906
|
+
]);
|
|
22907
|
+
var HANDLER_ANNOTATION_RE = /\b(Controller|RestController|RequestMapping|GetMapping|PostMapping|PutMapping|DeleteMapping|PatchMapping|RequestBody|RequestParam|PathVariable|route|blueprint|api_view|get|post|put|delete|patch|head|options)\b/i;
|
|
22908
|
+
var JS_ROUTER_RECEIVERS = /* @__PURE__ */ new Set(["app", "router", "server", "route"]);
|
|
22909
|
+
var JS_ROUTE_METHODS = /* @__PURE__ */ new Set([
|
|
22910
|
+
"get",
|
|
22911
|
+
"post",
|
|
22912
|
+
"put",
|
|
22913
|
+
"delete",
|
|
22914
|
+
"patch",
|
|
22915
|
+
"all",
|
|
22916
|
+
"use",
|
|
22917
|
+
"head",
|
|
22918
|
+
"options"
|
|
22919
|
+
]);
|
|
22920
|
+
var SecurityHeadersPass = class {
|
|
22921
|
+
name = "security-headers";
|
|
22922
|
+
category = "security";
|
|
22923
|
+
rules;
|
|
22924
|
+
constructor(options = {}) {
|
|
22925
|
+
this.rules = options.rules ?? DEFAULT_HEADER_RULES;
|
|
22926
|
+
}
|
|
22927
|
+
run(ctx) {
|
|
22928
|
+
const { graph } = ctx;
|
|
22929
|
+
const file = graph.ir.meta.file;
|
|
22930
|
+
const calls = graph.ir.calls;
|
|
22931
|
+
const writtenHeaders = /* @__PURE__ */ new Map();
|
|
22932
|
+
for (const call of calls) {
|
|
22933
|
+
if (!HEADER_WRITE_METHODS.has(call.method_name)) continue;
|
|
22934
|
+
if (call.arguments.length < 1) continue;
|
|
22935
|
+
const nameLiteral = literalOf(call.arguments[0]);
|
|
22936
|
+
if (nameLiteral === null) continue;
|
|
22937
|
+
const key = nameLiteral.toLowerCase();
|
|
22938
|
+
let list = writtenHeaders.get(key);
|
|
22939
|
+
if (!list) {
|
|
22940
|
+
list = [];
|
|
22941
|
+
writtenHeaders.set(key, list);
|
|
22942
|
+
}
|
|
22943
|
+
list.push(call);
|
|
22944
|
+
}
|
|
22945
|
+
const hasHandler = detectHandler(graph, calls);
|
|
22946
|
+
for (const rule of this.rules) {
|
|
22947
|
+
const headerKey = rule.header.toLowerCase();
|
|
22948
|
+
const writes = writtenHeaders.get(headerKey) ?? [];
|
|
22949
|
+
if (rule.kind === "missing") {
|
|
22950
|
+
if (writes.length > 0) continue;
|
|
22951
|
+
if (rule.requiresHandler !== false && !hasHandler) continue;
|
|
22952
|
+
ctx.addFinding({
|
|
22953
|
+
id: `${rule.rule_id}-${file}`,
|
|
22954
|
+
pass: this.name,
|
|
22955
|
+
category: this.category,
|
|
22956
|
+
rule_id: rule.rule_id,
|
|
22957
|
+
cwe: rule.cwe,
|
|
22958
|
+
severity: rule.severity,
|
|
22959
|
+
level: rule.level,
|
|
22960
|
+
message: rule.message,
|
|
22961
|
+
file,
|
|
22962
|
+
line: 1,
|
|
22963
|
+
fix: rule.fix
|
|
22964
|
+
});
|
|
22965
|
+
continue;
|
|
22966
|
+
}
|
|
22967
|
+
for (const call of writes) {
|
|
22968
|
+
const valueArg = call.arguments[1];
|
|
22969
|
+
if (!valueArg) continue;
|
|
22970
|
+
const valueLiteral = literalOf(valueArg);
|
|
22971
|
+
if (rule.kind === "weak-value") {
|
|
22972
|
+
if (valueLiteral === null) continue;
|
|
22973
|
+
if (!rule.valuePattern) continue;
|
|
22974
|
+
if (!rule.valuePattern.test(valueLiteral)) continue;
|
|
22975
|
+
} else {
|
|
22976
|
+
if (valueLiteral !== null) continue;
|
|
22977
|
+
}
|
|
22978
|
+
ctx.addFinding({
|
|
22979
|
+
id: `${rule.rule_id}-${file}-${call.location.line}`,
|
|
22980
|
+
pass: this.name,
|
|
22981
|
+
category: this.category,
|
|
22982
|
+
rule_id: rule.rule_id,
|
|
22983
|
+
cwe: rule.cwe,
|
|
22984
|
+
severity: rule.severity,
|
|
22985
|
+
level: rule.level,
|
|
22986
|
+
message: rule.message,
|
|
22987
|
+
file,
|
|
22988
|
+
line: call.location.line,
|
|
22989
|
+
fix: rule.fix,
|
|
22990
|
+
snippet: valueLiteral !== null ? `${rule.header}: ${valueLiteral}` : `${rule.header}: ${valueArg.expression}`,
|
|
22991
|
+
evidence: {
|
|
22992
|
+
header: rule.header,
|
|
22993
|
+
value: valueLiteral,
|
|
22994
|
+
expression: valueArg.expression,
|
|
22995
|
+
kind: rule.kind
|
|
22996
|
+
}
|
|
22997
|
+
});
|
|
22998
|
+
}
|
|
22999
|
+
}
|
|
23000
|
+
return { hasHandler, writtenHeaders };
|
|
23001
|
+
}
|
|
23002
|
+
};
|
|
23003
|
+
function literalOf(arg) {
|
|
23004
|
+
if (arg.literal !== null && arg.literal !== void 0 && arg.literal !== "") {
|
|
23005
|
+
return stripQuotes2(arg.literal);
|
|
23006
|
+
}
|
|
23007
|
+
const expr = arg.expression.trim();
|
|
23008
|
+
if (expr.startsWith('"') && expr.endsWith('"') || expr.startsWith("'") && expr.endsWith("'") || expr.startsWith("`") && expr.endsWith("`")) {
|
|
23009
|
+
if (expr.startsWith("`") && expr.includes("${")) return null;
|
|
23010
|
+
return expr.slice(1, -1);
|
|
23011
|
+
}
|
|
23012
|
+
return null;
|
|
23013
|
+
}
|
|
23014
|
+
function stripQuotes2(s) {
|
|
23015
|
+
if (s.length < 2) return s;
|
|
23016
|
+
const first = s[0];
|
|
23017
|
+
const last = s[s.length - 1];
|
|
23018
|
+
if (first === '"' && last === '"' || first === "'" && last === "'" || first === "`" && last === "`") {
|
|
23019
|
+
return s.slice(1, -1);
|
|
23020
|
+
}
|
|
23021
|
+
return s;
|
|
23022
|
+
}
|
|
23023
|
+
function detectHandler(graph, calls) {
|
|
23024
|
+
for (const type of graph.ir.types) {
|
|
23025
|
+
if (type.annotations.some((a) => HANDLER_ANNOTATION_RE.test(a))) return true;
|
|
23026
|
+
for (const method of type.methods) {
|
|
23027
|
+
if (method.annotations.some((a) => HANDLER_ANNOTATION_RE.test(a))) return true;
|
|
23028
|
+
}
|
|
23029
|
+
}
|
|
23030
|
+
for (const call of calls) {
|
|
23031
|
+
if (!JS_ROUTE_METHODS.has(call.method_name)) continue;
|
|
23032
|
+
if (!call.receiver) continue;
|
|
23033
|
+
if (!JS_ROUTER_RECEIVERS.has(call.receiver)) continue;
|
|
23034
|
+
const first = call.arguments[0];
|
|
23035
|
+
if (!first) continue;
|
|
23036
|
+
const literal = literalOf(first);
|
|
23037
|
+
if (literal !== null && literal.startsWith("/")) return true;
|
|
23038
|
+
}
|
|
23039
|
+
return false;
|
|
23040
|
+
}
|
|
23041
|
+
|
|
22807
23042
|
// src/analysis/metrics/passes/size-metrics-pass.ts
|
|
22808
23043
|
var SizeMetricsPass = class {
|
|
22809
23044
|
name = "size-metrics";
|
|
@@ -23662,6 +23897,7 @@ async function analyze(code, filePath, language, options = {}) {
|
|
|
23662
23897
|
if (!disabledPasses.has("missing-stream")) pipeline.add(new MissingStreamPass());
|
|
23663
23898
|
if (!disabledPasses.has("god-class")) pipeline.add(new GodClassPass());
|
|
23664
23899
|
if (!disabledPasses.has("naming-convention")) pipeline.add(new NamingConventionPass(passOpts.namingConvention));
|
|
23900
|
+
if (!disabledPasses.has("security-headers")) pipeline.add(new SecurityHeadersPass(passOpts.securityHeaders));
|
|
23665
23901
|
const { results, findings } = pipeline.run(graph, code, language, config);
|
|
23666
23902
|
const sinkFilter = results.get("sink-filter");
|
|
23667
23903
|
const interProc = results.get("interprocedural");
|
package/dist/types/config.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Types for YAML configuration files (configs/sources/, configs/sinks/)
|
|
3
3
|
*/
|
|
4
|
-
import type { Severity, SinkType, SourceType } from './index.js';
|
|
4
|
+
import type { SarifLevel, Severity, SinkType, SourceType } from './index.js';
|
|
5
5
|
export interface SourceConfig {
|
|
6
6
|
sources: SourcePattern[];
|
|
7
7
|
}
|
|
@@ -43,3 +43,47 @@ export interface TaintConfig {
|
|
|
43
43
|
sinks: SinkPattern[];
|
|
44
44
|
sanitizers: SanitizerPattern[];
|
|
45
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* A rule evaluated by SecurityHeadersPass against HTTP response header
|
|
48
|
+
* writes (setHeader/addHeader) and handler presence. Emits SastFindings
|
|
49
|
+
* without going through the taint source→sink machinery, since headers
|
|
50
|
+
* are a call-site literal inspection problem, not a data-flow problem.
|
|
51
|
+
*/
|
|
52
|
+
export interface HeaderRule {
|
|
53
|
+
/** Rule id (matches docs/PASSES.md rule_id column). */
|
|
54
|
+
rule_id: string;
|
|
55
|
+
/** CWE identifier (e.g. 'CWE-1021', 'CWE-346', 'CWE-942'). */
|
|
56
|
+
cwe: string;
|
|
57
|
+
/** SARIF level: 'error' | 'warning' | 'note' | 'none'. */
|
|
58
|
+
level: SarifLevel;
|
|
59
|
+
/** Severity bucket: 'critical' | 'high' | 'medium' | 'low'. */
|
|
60
|
+
severity: Severity;
|
|
61
|
+
/** HTTP response header this rule applies to (case-insensitive). */
|
|
62
|
+
header: string;
|
|
63
|
+
/**
|
|
64
|
+
* Rule kind:
|
|
65
|
+
* - 'missing' → file has an HTTP handler but never writes this header
|
|
66
|
+
* - 'weak-value' → header written with a value matching `matcher`
|
|
67
|
+
* (e.g. 'ALLOW-FROM', 'null', 'http://…')
|
|
68
|
+
* - 'unsafe-value' → value is dynamic / reflected (not a string literal)
|
|
69
|
+
*/
|
|
70
|
+
kind: 'missing' | 'weak-value' | 'unsafe-value';
|
|
71
|
+
/**
|
|
72
|
+
* Value pattern for 'weak-value' rules. Matched against the literal
|
|
73
|
+
* second argument of setHeader/addHeader (case-insensitive).
|
|
74
|
+
*/
|
|
75
|
+
valuePattern?: RegExp;
|
|
76
|
+
/**
|
|
77
|
+
* If true (the default for kind='missing'), the rule only fires when
|
|
78
|
+
* the file contains at least one HTTP handler (annotated controller
|
|
79
|
+
* method, Express/Koa route, Rust extractor, etc.). Prevents noise on
|
|
80
|
+
* library code, configuration files, and tests.
|
|
81
|
+
*/
|
|
82
|
+
requiresHandler?: boolean;
|
|
83
|
+
/** Human-readable message (header name interpolated with ${header}). */
|
|
84
|
+
message: string;
|
|
85
|
+
/** Suggested fix rendered into SastFinding.fix. */
|
|
86
|
+
fix?: string;
|
|
87
|
+
/** Optional note for PASSES.md / debugging. */
|
|
88
|
+
note?: string;
|
|
89
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "circle-ir",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.19.0",
|
|
4
4
|
"description": "High-performance Static Application Security Testing (SAST) library for detecting security vulnerabilities through taint analysis",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"module": "dist/index.js",
|