circle-ir 3.18.8 → 3.19.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.
@@ -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,163 @@ 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 = resolveHeaderName(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 resolveHeaderName(arg) {
23024
+ const lit = literalOf(arg);
23025
+ if (lit !== null) return lit;
23026
+ const expr = arg.expression.trim();
23027
+ const dotIdx = expr.lastIndexOf(".");
23028
+ const fieldName = dotIdx >= 0 ? expr.slice(dotIdx + 1) : expr;
23029
+ if (!/^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/.test(fieldName)) return null;
23030
+ return fieldName.split("_").map((part) => part.charAt(0) + part.slice(1).toLowerCase()).join("-");
23031
+ }
23032
+ function detectHandler(graph, calls) {
23033
+ for (const type of graph.ir.types) {
23034
+ if (type.annotations.some((a) => HANDLER_ANNOTATION_RE.test(a))) return true;
23035
+ for (const method of type.methods) {
23036
+ if (method.annotations.some((a) => HANDLER_ANNOTATION_RE.test(a))) return true;
23037
+ }
23038
+ }
23039
+ for (const call of calls) {
23040
+ if (!JS_ROUTE_METHODS.has(call.method_name)) continue;
23041
+ if (!call.receiver) continue;
23042
+ if (!JS_ROUTER_RECEIVERS.has(call.receiver)) continue;
23043
+ const first = call.arguments[0];
23044
+ if (!first) continue;
23045
+ const literal = literalOf(first);
23046
+ if (literal !== null && literal.startsWith("/")) return true;
23047
+ }
23048
+ return false;
23049
+ }
23050
+
22807
23051
  // src/analysis/metrics/passes/size-metrics-pass.ts
22808
23052
  var SizeMetricsPass = class {
22809
23053
  name = "size-metrics";
@@ -23662,6 +23906,7 @@ async function analyze(code, filePath, language, options = {}) {
23662
23906
  if (!disabledPasses.has("missing-stream")) pipeline.add(new MissingStreamPass());
23663
23907
  if (!disabledPasses.has("god-class")) pipeline.add(new GodClassPass());
23664
23908
  if (!disabledPasses.has("naming-convention")) pipeline.add(new NamingConventionPass(passOpts.namingConvention));
23909
+ if (!disabledPasses.has("security-headers")) pipeline.add(new SecurityHeadersPass(passOpts.securityHeaders));
23665
23910
  const { results, findings } = pipeline.run(graph, code, language, config);
23666
23911
  const sinkFilter = results.get("sink-filter");
23667
23912
  const interProc = results.get("interprocedural");
@@ -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.18.8",
3
+ "version": "3.19.1",
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",