codegate-ai 0.7.0 → 0.8.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.
@@ -0,0 +1,170 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { buildFindingEvidence } from "../evidence.js";
5
+ const COMPONENTS_PATH = join(dirname(fileURLToPath(import.meta.url)), "../advisories/agent-components.json");
6
+ const GENERIC_AFFECTED_TOOLS = [
7
+ "claude-code",
8
+ "codex-cli",
9
+ "opencode",
10
+ "cursor",
11
+ "windsurf",
12
+ "github-copilot",
13
+ ];
14
+ function globToRegExp(glob) {
15
+ const pattern = glob.replaceAll("\\", "/").trim();
16
+ if (pattern.length === 0) {
17
+ return /^$/u;
18
+ }
19
+ let regex = "^";
20
+ for (let index = 0; index < pattern.length; index += 1) {
21
+ const char = pattern[index];
22
+ if (char === "*") {
23
+ if (pattern[index + 1] === "*") {
24
+ regex += ".*";
25
+ index += 1;
26
+ }
27
+ else {
28
+ regex += "[^/]*";
29
+ }
30
+ continue;
31
+ }
32
+ if (char === "?") {
33
+ regex += "[^/]";
34
+ continue;
35
+ }
36
+ if ("\\^$.*+?()[]{}|".includes(char)) {
37
+ regex += `\\${char}`;
38
+ continue;
39
+ }
40
+ regex += char;
41
+ }
42
+ regex += "$";
43
+ return new RegExp(regex, "u");
44
+ }
45
+ function matchesGlob(value, glob) {
46
+ const normalizedValue = value.replaceAll("\\", "/");
47
+ const normalizedGlob = glob.replaceAll("\\", "/").trim();
48
+ if (normalizedGlob.startsWith("**/")) {
49
+ const suffix = normalizedGlob.slice(3);
50
+ return normalizedValue === suffix || normalizedValue.endsWith(`/${suffix}`);
51
+ }
52
+ return globToRegExp(normalizedGlob).test(normalizedValue);
53
+ }
54
+ function loadComponents() {
55
+ const raw = readFileSync(COMPONENTS_PATH, "utf8");
56
+ const parsed = JSON.parse(raw);
57
+ return parsed;
58
+ }
59
+ const ADVISORY_COMPONENTS = loadComponents();
60
+ function normalizeToken(value) {
61
+ return value.trim().toLowerCase();
62
+ }
63
+ function collectStringCandidates(value, path = [], output = []) {
64
+ if (typeof value === "string") {
65
+ output.push({ path: path.join("."), value });
66
+ return output;
67
+ }
68
+ if (Array.isArray(value)) {
69
+ value.forEach((entry, index) => {
70
+ collectStringCandidates(entry, [...path, String(index)], output);
71
+ });
72
+ return output;
73
+ }
74
+ if (!value || typeof value !== "object") {
75
+ return output;
76
+ }
77
+ for (const [key, child] of Object.entries(value)) {
78
+ collectStringCandidates(child, [...path, key], output);
79
+ }
80
+ return output;
81
+ }
82
+ function makeFinding(filePath, matchedPath, component, signature, evidence) {
83
+ const location = { field: matchedPath };
84
+ if (typeof evidence?.line === "number") {
85
+ location.line = evidence.line;
86
+ }
87
+ if (typeof evidence?.column === "number") {
88
+ location.column = evidence.column;
89
+ }
90
+ return {
91
+ rule_id: component.rule_id,
92
+ finding_id: `${component.rule_id}-${filePath}-${matchedPath}`,
93
+ severity: component.severity,
94
+ category: component.category,
95
+ layer: "L2",
96
+ file_path: filePath,
97
+ location,
98
+ description: component.description,
99
+ affected_tools: GENERIC_AFFECTED_TOOLS,
100
+ cve: null,
101
+ owasp: component.owasp,
102
+ cwe: component.cwe,
103
+ confidence: component.confidence,
104
+ fixable: true,
105
+ remediation_actions: component.remediation_actions,
106
+ metadata: {
107
+ sources: component.metadata?.sources ?? [],
108
+ sinks: component.metadata?.sinks ?? [],
109
+ referenced_secrets: component.metadata?.referenced_secrets ?? [],
110
+ risk_tags: component.metadata?.risk_tags ?? [],
111
+ origin: component.metadata?.origin ?? "agent-components.json",
112
+ },
113
+ evidence: evidence?.evidence ?? null,
114
+ suppressed: false,
115
+ };
116
+ }
117
+ function componentMatchesFile(component, filePath) {
118
+ return component.file_patterns.some((pattern) => matchesGlob(filePath, pattern));
119
+ }
120
+ function signatureMatches(value, signatures) {
121
+ const normalizedValue = normalizeToken(value);
122
+ for (const signature of signatures) {
123
+ const normalizedSignature = normalizeToken(signature);
124
+ if (normalizedSignature.length > 0 && normalizedValue.includes(normalizedSignature)) {
125
+ return signature;
126
+ }
127
+ }
128
+ return null;
129
+ }
130
+ function detectComponentMatch(input, component) {
131
+ if (!componentMatchesFile(component, input.filePath)) {
132
+ return null;
133
+ }
134
+ const stringCandidates = collectStringCandidates(input.parsed);
135
+ for (const candidate of stringCandidates) {
136
+ const signature = signatureMatches(candidate.value, component.signatures);
137
+ if (!signature) {
138
+ continue;
139
+ }
140
+ const evidence = buildFindingEvidence({
141
+ textContent: input.textContent,
142
+ jsonPaths: candidate.path.length > 0 ? [candidate.path] : [],
143
+ searchTerms: [signature],
144
+ fallbackValue: `${candidate.path} = ${candidate.value}`,
145
+ });
146
+ return makeFinding(input.filePath, candidate.path || component.id, component, signature, evidence);
147
+ }
148
+ for (const signature of component.signatures) {
149
+ if (!signatureMatches(input.textContent, [signature])) {
150
+ continue;
151
+ }
152
+ const evidence = buildFindingEvidence({
153
+ textContent: input.textContent,
154
+ searchTerms: [signature],
155
+ fallbackValue: signature,
156
+ });
157
+ return makeFinding(input.filePath, component.id, component, signature, evidence);
158
+ }
159
+ return null;
160
+ }
161
+ export function detectAdvisoryIntelligence(input) {
162
+ const findings = [];
163
+ for (const component of ADVISORY_COMPONENTS) {
164
+ const finding = detectComponentMatch(input, component);
165
+ if (finding) {
166
+ findings.push(finding);
167
+ }
168
+ }
169
+ return findings;
170
+ }
@@ -95,6 +95,12 @@ function makeFinding(filePath, field, ruleId, severity, description, evidence) {
95
95
  confidence: "HIGH",
96
96
  fixable: true,
97
97
  remediation_actions: ["remove_field", "replace_with_default"],
98
+ metadata: {
99
+ sources: [filePath, field],
100
+ sinks: ["process-execution"],
101
+ risk_tags: ["command-execution", "shell-pipeline"],
102
+ origin: "command-exec",
103
+ },
98
104
  evidence: evidence?.evidence ?? null,
99
105
  suppressed: false,
100
106
  };
@@ -48,6 +48,11 @@ function makeFinding(filePath, field, ruleId, description, evidence, severity =
48
48
  confidence: "HIGH",
49
49
  fixable: true,
50
50
  remediation_actions: ["strip_unicode", "remove_block", "quarantine_file"],
51
+ metadata: {
52
+ sources: [filePath, field],
53
+ risk_tags: ["rule-injection", "prompt-injection"],
54
+ origin: "rule-file",
55
+ },
51
56
  evidence: evidence?.evidence ?? null,
52
57
  observed: narrative.observed ?? null,
53
58
  inference: narrative.inference ?? null,
@@ -1,6 +1,6 @@
1
1
  import { type GitHookEntry } from "./detectors/git-hooks.js";
2
2
  import { type SymlinkEscapeEntry } from "./detectors/symlink.js";
3
- import type { Finding } from "../types/finding.js";
3
+ import { type Finding } from "../types/finding.js";
4
4
  import type { DiscoveryFormat } from "../types/discovery.js";
5
5
  export interface StaticFileInput {
6
6
  filePath: string;
@@ -17,6 +17,9 @@ export interface StaticEngineConfig {
17
17
  trustedApiDomains: string[];
18
18
  unicodeAnalysis: boolean;
19
19
  checkIdeSettings: boolean;
20
+ rulePackPaths?: string[];
21
+ allowedRules?: string[];
22
+ skipRules?: string[];
20
23
  }
21
24
  export interface StaticEngineInput {
22
25
  projectRoot: string;
@@ -1,4 +1,5 @@
1
1
  import { detectCommandExecution } from "./detectors/command-exec.js";
2
+ import { detectAdvisoryIntelligence } from "./detectors/advisory-intelligence.js";
2
3
  import { detectConsentBypass } from "./detectors/consent-bypass.js";
3
4
  import { detectEnvOverrides } from "./detectors/env-override.js";
4
5
  import { detectGitHookIssues } from "./detectors/git-hooks.js";
@@ -6,6 +7,78 @@ import { detectIdeSettingsIssues } from "./detectors/ide-settings.js";
6
7
  import { detectPluginManifestIssues } from "./detectors/plugin-manifest.js";
7
8
  import { detectRuleFileIssues } from "./detectors/rule-file.js";
8
9
  import { detectSymlinkEscapes } from "./detectors/symlink.js";
10
+ import { FINDING_CATEGORIES } from "../types/finding.js";
11
+ import { buildFindingEvidence } from "./evidence.js";
12
+ import { evaluateRule, loadRulePacks } from "./rule-engine.js";
13
+ const GENERIC_AFFECTED_TOOLS = [
14
+ "claude-code",
15
+ "codex-cli",
16
+ "opencode",
17
+ "cursor",
18
+ "windsurf",
19
+ "github-copilot",
20
+ ];
21
+ function parseRuleSeverity(value) {
22
+ const normalized = value.trim().toUpperCase();
23
+ if (normalized === "CRITICAL" ||
24
+ normalized === "HIGH" ||
25
+ normalized === "MEDIUM" ||
26
+ normalized === "LOW") {
27
+ return normalized;
28
+ }
29
+ return "INFO";
30
+ }
31
+ function parseRuleCategory(value) {
32
+ const normalized = value.trim().toUpperCase();
33
+ if (FINDING_CATEGORIES.some((category) => category === normalized)) {
34
+ return normalized;
35
+ }
36
+ return "CONFIG_PRESENT";
37
+ }
38
+ function remediationActionsForRule(rule) {
39
+ if (rule.query_type === "text_pattern") {
40
+ return ["quarantine_file", "remove_block"];
41
+ }
42
+ return ["remove_field", "replace_with_default"];
43
+ }
44
+ function findingFromRulePackMatch(file, rule) {
45
+ const locationField = rule.query_type === "text_pattern" ? "content" : rule.query;
46
+ const evidence = buildFindingEvidence({
47
+ textContent: file.textContent,
48
+ searchTerms: [rule.query],
49
+ fallbackValue: `${locationField} matched rule ${rule.id}`,
50
+ });
51
+ const affectedTools = rule.tool === "*" ? GENERIC_AFFECTED_TOOLS : [rule.tool];
52
+ return {
53
+ rule_id: rule.id,
54
+ finding_id: `RULE_PACK-${rule.id}-${file.filePath}-${locationField}`,
55
+ severity: parseRuleSeverity(rule.severity),
56
+ category: parseRuleCategory(rule.category),
57
+ layer: "L2",
58
+ file_path: file.filePath,
59
+ location: { field: locationField },
60
+ description: rule.description,
61
+ affected_tools: affectedTools,
62
+ cve: rule.cve ?? null,
63
+ owasp: rule.owasp,
64
+ cwe: rule.cwe,
65
+ confidence: "HIGH",
66
+ fixable: true,
67
+ remediation_actions: remediationActionsForRule(rule),
68
+ metadata: {
69
+ sources: [file.filePath, locationField],
70
+ risk_tags: ["rule-pack"],
71
+ origin: "rule-pack",
72
+ },
73
+ evidence: evidence?.evidence ?? null,
74
+ suppressed: false,
75
+ };
76
+ }
77
+ function hasEquivalentFinding(findings, candidate) {
78
+ return findings.some((finding) => finding.rule_id === candidate.rule_id &&
79
+ finding.file_path === candidate.file_path &&
80
+ (finding.location.field ?? "") === (candidate.location.field ?? ""));
81
+ }
9
82
  function dedupeFindings(findings) {
10
83
  const deduped = new Map();
11
84
  for (const finding of findings) {
@@ -32,6 +105,11 @@ function dedupeFindings(findings) {
32
105
  }
33
106
  export function runStaticEngine(input) {
34
107
  const findings = [];
108
+ const rulePackRules = loadRulePacks({
109
+ rule_pack_paths: input.config.rulePackPaths ?? [],
110
+ allowed_rules: input.config.allowedRules ?? [],
111
+ skip_rules: input.config.skipRules ?? [],
112
+ });
35
113
  for (const file of input.files) {
36
114
  findings.push(...detectEnvOverrides({
37
115
  filePath: file.filePath,
@@ -69,6 +147,11 @@ export function runStaticEngine(input) {
69
147
  trustedApiDomains: input.config.trustedApiDomains,
70
148
  blockedCommands: input.config.blockedCommands,
71
149
  }));
150
+ findings.push(...detectAdvisoryIntelligence({
151
+ filePath: file.filePath,
152
+ parsed: file.parsed,
153
+ textContent: file.textContent,
154
+ }));
72
155
  if (file.format === "text" || file.format === "markdown") {
73
156
  findings.push(...detectRuleFileIssues({
74
157
  filePath: file.filePath,
@@ -76,6 +159,20 @@ export function runStaticEngine(input) {
76
159
  unicodeAnalysis: input.config.unicodeAnalysis,
77
160
  }));
78
161
  }
162
+ for (const rule of rulePackRules) {
163
+ if (!evaluateRule(rule, {
164
+ filePath: file.filePath,
165
+ format: file.format,
166
+ parsed: file.parsed,
167
+ textContent: file.textContent,
168
+ })) {
169
+ continue;
170
+ }
171
+ const candidate = findingFromRulePackMatch(file, rule);
172
+ if (!hasEquivalentFinding(findings, candidate)) {
173
+ findings.push(candidate);
174
+ }
175
+ }
79
176
  }
80
177
  findings.push(...detectSymlinkEscapes({ symlinkEscapes: input.symlinkEscapes }));
81
178
  findings.push(...detectGitHookIssues({ hooks: input.hooks, knownSafeHooks: input.config.knownSafeHooks }));
@@ -21,4 +21,4 @@ export interface RuleEvaluationInput {
21
21
  textContent: string;
22
22
  }
23
23
  export declare function evaluateRule(rule: DetectionRule, input: RuleEvaluationInput): boolean;
24
- export declare function loadRulePacks(baseDir?: string): DetectionRule[];
24
+ export { loadRulePacks } from "./rule-pack-loader.js";
@@ -1,7 +1,3 @@
1
- import { readdirSync, readFileSync } from "node:fs";
2
- import { dirname, extname, join, resolve } from "node:path";
3
- import { fileURLToPath } from "node:url";
4
- const rulesDir = resolve(dirname(fileURLToPath(import.meta.url)), "rules");
5
1
  function escapeRegex(value) {
6
2
  return value.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
7
3
  }
@@ -127,12 +123,4 @@ export function evaluateRule(rule, input) {
127
123
  }
128
124
  return false;
129
125
  }
130
- export function loadRulePacks(baseDir = rulesDir) {
131
- const files = readdirSync(baseDir)
132
- .filter((file) => extname(file) === ".json")
133
- .sort();
134
- return files.flatMap((file) => {
135
- const raw = readFileSync(join(baseDir, file), "utf8");
136
- return JSON.parse(raw);
137
- });
138
- }
126
+ export { loadRulePacks } from "./rule-pack-loader.js";
@@ -0,0 +1,10 @@
1
+ import type { DetectionRule } from "./rule-engine.js";
2
+ export interface RulePackLoaderOptions {
3
+ baseDir?: string;
4
+ rule_pack_paths?: string[];
5
+ allowed_rules?: string[];
6
+ skip_rules?: string[];
7
+ }
8
+ export declare function loadRulePacks(): DetectionRule[];
9
+ export declare function loadRulePacks(baseDir: string): DetectionRule[];
10
+ export declare function loadRulePacks(options: RulePackLoaderOptions): DetectionRule[];
@@ -0,0 +1,187 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { createRequire } from "node:module";
3
+ import { dirname, extname, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ const defaultRulesDir = resolve(dirname(fileURLToPath(import.meta.url)), "rules");
6
+ const require = createRequire(import.meta.url);
7
+ const Ajv = require("ajv");
8
+ const RULE_SCHEMA = {
9
+ type: "object",
10
+ additionalProperties: true,
11
+ required: [
12
+ "id",
13
+ "severity",
14
+ "category",
15
+ "description",
16
+ "tool",
17
+ "file_pattern",
18
+ "query_type",
19
+ "query",
20
+ "condition",
21
+ "owasp",
22
+ "cwe",
23
+ ],
24
+ properties: {
25
+ id: { type: "string", minLength: 1 },
26
+ severity: { type: "string", minLength: 1 },
27
+ category: { type: "string", minLength: 1 },
28
+ description: { type: "string", minLength: 1 },
29
+ tool: { type: "string", minLength: 1 },
30
+ file_pattern: { type: "string", minLength: 1 },
31
+ query_type: {
32
+ type: "string",
33
+ enum: ["json_path", "toml_path", "env_key", "text_pattern"],
34
+ },
35
+ query: { type: "string" },
36
+ condition: {
37
+ type: "string",
38
+ enum: [
39
+ "equals_true",
40
+ "equals_false",
41
+ "exists",
42
+ "not_empty",
43
+ "matches_regex",
44
+ "not_in_allowlist",
45
+ "regex_match",
46
+ "contains",
47
+ "line_length_exceeds",
48
+ ],
49
+ },
50
+ cve: { type: "string" },
51
+ owasp: {
52
+ type: "array",
53
+ items: { type: "string" },
54
+ },
55
+ cwe: { type: "string", minLength: 1 },
56
+ },
57
+ };
58
+ const ruleValidator = new Ajv({ allErrors: true, strict: false }).compile(RULE_SCHEMA);
59
+ function normalizeRuleIds(values) {
60
+ const seen = new Set();
61
+ const normalized = [];
62
+ for (const value of values ?? []) {
63
+ const trimmed = value.trim();
64
+ if (trimmed.length === 0 || seen.has(trimmed)) {
65
+ continue;
66
+ }
67
+ seen.add(trimmed);
68
+ normalized.push(trimmed);
69
+ }
70
+ return normalized;
71
+ }
72
+ function toErrorMessage(errors) {
73
+ if (!errors || errors.length === 0) {
74
+ return "validation error";
75
+ }
76
+ return errors
77
+ .map((error) => {
78
+ const location = error.instancePath === "" ? "<root>" : error.instancePath;
79
+ return `${location}: ${error.message ?? "validation error"}`;
80
+ })
81
+ .join("; ");
82
+ }
83
+ function isPackDirectory(path) {
84
+ try {
85
+ return statSync(path).isDirectory();
86
+ }
87
+ catch {
88
+ return false;
89
+ }
90
+ }
91
+ function isPackFile(path) {
92
+ try {
93
+ return statSync(path).isFile();
94
+ }
95
+ catch {
96
+ return false;
97
+ }
98
+ }
99
+ function resolvePackPaths(path) {
100
+ const absolutePath = resolve(path);
101
+ if (!existsSync(absolutePath)) {
102
+ throw new Error(`Rule pack path does not exist: ${absolutePath}`);
103
+ }
104
+ if (isPackFile(absolutePath)) {
105
+ return [absolutePath];
106
+ }
107
+ if (!isPackDirectory(absolutePath)) {
108
+ throw new Error(`Rule pack path is not a file or directory: ${absolutePath}`);
109
+ }
110
+ return readdirSync(absolutePath)
111
+ .filter((file) => extname(file) === ".json")
112
+ .filter((file) => file !== "schema.json")
113
+ .sort()
114
+ .map((file) => join(absolutePath, file));
115
+ }
116
+ function loadRulesFromFile(path) {
117
+ let parsed;
118
+ try {
119
+ parsed = JSON.parse(readFileSync(path, "utf8"));
120
+ }
121
+ catch (error) {
122
+ const reason = error instanceof Error ? error.message : String(error);
123
+ throw new Error(`Failed to parse rule pack ${path}: ${reason}`, { cause: error });
124
+ }
125
+ if (!Array.isArray(parsed)) {
126
+ throw new Error(`Invalid rule pack ${path}: expected a JSON array of rule objects`);
127
+ }
128
+ return parsed.map((candidate, index) => {
129
+ if (!ruleValidator(candidate)) {
130
+ const reasons = toErrorMessage(ruleValidator.errors);
131
+ throw new Error(`Invalid rule pack ${path} [${index}]: ${reasons}`);
132
+ }
133
+ return candidate;
134
+ });
135
+ }
136
+ function collectRulesFromPaths(paths) {
137
+ const collected = [];
138
+ for (const path of paths) {
139
+ for (const packPath of resolvePackPaths(path)) {
140
+ collected.push(...loadRulesFromFile(packPath));
141
+ }
142
+ }
143
+ return collected;
144
+ }
145
+ function dedupeByRuleId(rules) {
146
+ const deduped = new Map();
147
+ for (const rule of rules) {
148
+ deduped.set(rule.id, rule);
149
+ }
150
+ return Array.from(deduped.values());
151
+ }
152
+ function filterRules(rules, allowedRules, skipRules) {
153
+ const allowed = new Set(allowedRules);
154
+ const skipped = new Set(skipRules);
155
+ return rules.filter((rule) => {
156
+ if (skipped.has(rule.id)) {
157
+ return false;
158
+ }
159
+ if (allowed.size > 0 && !allowed.has(rule.id)) {
160
+ return false;
161
+ }
162
+ return true;
163
+ });
164
+ }
165
+ function normalizeOptions(arg) {
166
+ if (typeof arg === "string") {
167
+ return {
168
+ baseDir: arg,
169
+ rulePackPaths: [],
170
+ allowedRules: [],
171
+ skipRules: [],
172
+ };
173
+ }
174
+ const options = arg ?? {};
175
+ return {
176
+ baseDir: options.baseDir ?? defaultRulesDir,
177
+ rulePackPaths: options.rule_pack_paths ?? [],
178
+ allowedRules: normalizeRuleIds(options.allowed_rules),
179
+ skipRules: normalizeRuleIds(options.skip_rules),
180
+ };
181
+ }
182
+ export function loadRulePacks(arg) {
183
+ const options = normalizeOptions(arg);
184
+ const bundledRules = collectRulesFromPaths([options.baseDir]);
185
+ const externalRules = collectRulesFromPaths(options.rulePackPaths);
186
+ return filterRules(dedupeByRuleId([...bundledRules, ...externalRules]), options.allowedRules, options.skipRules);
187
+ }
@@ -42,6 +42,12 @@ function makeFinding(input, sourceTool, sensitiveTool, sinkTool) {
42
42
  confidence: "HIGH",
43
43
  fixable: false,
44
44
  remediation_actions: [],
45
+ metadata: {
46
+ sources: [sourceTool],
47
+ sinks: [sinkTool],
48
+ risk_tags: ["toxic-flow"],
49
+ origin: "toxic-flow",
50
+ },
45
51
  suppressed: false,
46
52
  };
47
53
  }
package/dist/pipeline.js CHANGED
@@ -3,6 +3,7 @@ import { createEmptyReport } from "./types/report.js";
3
3
  import { scanToolDescriptions, } from "./layer3-dynamic/tool-description-scanner.js";
4
4
  import { detectToxicFlows } from "./layer3-dynamic/toxic-flow.js";
5
5
  import { applyReportSummary } from "./report-summary.js";
6
+ import { withFindingFingerprint } from "./report/finding-fingerprint.js";
6
7
  export function runStaticPipeline(input) {
7
8
  const findings = runStaticEngine({
8
9
  projectRoot: input.projectRoot,
@@ -10,7 +11,7 @@ export function runStaticPipeline(input) {
10
11
  symlinkEscapes: input.symlinkEscapes,
11
12
  hooks: input.hooks,
12
13
  config: input.config,
13
- });
14
+ }).map(withFindingFingerprint);
14
15
  const report = createEmptyReport({
15
16
  version: input.version,
16
17
  kbVersion: input.kbVersion,
@@ -63,7 +64,7 @@ function parseLayer3Response(resourceId, metadata) {
63
64
  .filter((item) => typeof item === "object" && item !== null)
64
65
  .map((item, index) => {
65
66
  const findingId = item.id ?? `L3-${resourceId}-${index}`;
66
- return {
67
+ return withFindingFingerprint({
67
68
  rule_id: item.id ?? "layer3-analysis-finding",
68
69
  finding_id: findingId,
69
70
  severity: parseSeverity(item.severity),
@@ -82,7 +83,7 @@ function parseLayer3Response(resourceId, metadata) {
82
83
  remediation_actions: item.remediation_actions ?? [],
83
84
  source_config: item.source_config ?? null,
84
85
  suppressed: false,
85
- };
86
+ });
86
87
  });
87
88
  }
88
89
  function asRecord(value) {
@@ -172,17 +173,17 @@ function deriveLayer3ToolFindings(resourceId, metadata, options = {}) {
172
173
  serverId: resourceId,
173
174
  tools: toolDescriptions,
174
175
  unicodeAnalysis: options.unicodeAnalysis,
175
- }),
176
+ }).map(withFindingFingerprint),
176
177
  ...detectToxicFlows({
177
178
  scopeId: resourceId,
178
179
  tools: toolDescriptions,
179
180
  knownClassifications,
180
- }),
181
+ }).map(withFindingFingerprint),
181
182
  ];
182
183
  }
183
184
  function layer3ErrorFinding(resourceId, status, description) {
184
185
  const severity = status === "timeout" ? "MEDIUM" : status === "skipped_without_consent" ? "INFO" : "LOW";
185
- return {
186
+ return withFindingFingerprint({
186
187
  rule_id: `layer3-${status}`,
187
188
  finding_id: `L3-${status}-${resourceId}`,
188
189
  severity,
@@ -199,7 +200,7 @@ function layer3ErrorFinding(resourceId, status, description) {
199
200
  fixable: false,
200
201
  remediation_actions: [],
201
202
  suppressed: false,
202
- };
203
+ });
203
204
  }
204
205
  function isRegistryMetadataResource(resourceId) {
205
206
  return (resourceId.startsWith("npm:") || resourceId.startsWith("pypi:") || resourceId.startsWith("git:"));
@@ -232,7 +233,7 @@ export function layer3OutcomesToFindings(outcomes, options = {}) {
232
233
  export function mergeLayer3Findings(baseReport, layer3Findings) {
233
234
  return applyReportSummary({
234
235
  ...baseReport,
235
- findings: [...baseReport.findings, ...layer3Findings],
236
+ findings: [...baseReport.findings, ...layer3Findings].map(withFindingFingerprint),
236
237
  });
237
238
  }
238
239
  export async function runDeepScanWithConsent(resources, requestConsent, execute) {
@@ -0,0 +1,5 @@
1
+ import type { Finding } from "../types/finding.js";
2
+ export declare function buildFindingFingerprint(finding: Finding): string;
3
+ export declare function withFindingFingerprint<T extends Finding>(finding: T): T & {
4
+ fingerprint: string;
5
+ };