@sikkuumiii/defuse 0.0.0-stage → 0.5.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/LICENSE +235 -0
- package/README.md +561 -2
- package/dist/src/cli.d.ts +20 -0
- package/dist/src/cli.js +619 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/core/analyze.d.ts +178 -0
- package/dist/src/core/analyze.js +834 -0
- package/dist/src/core/analyze.js.map +1 -0
- package/dist/src/core/coverage.d.ts +87 -0
- package/dist/src/core/coverage.js +181 -0
- package/dist/src/core/coverage.js.map +1 -0
- package/dist/src/core/finding.d.ts +196 -0
- package/dist/src/core/finding.js +178 -0
- package/dist/src/core/finding.js.map +1 -0
- package/dist/src/core/licence.d.ts +50 -0
- package/dist/src/core/licence.js +56 -0
- package/dist/src/core/licence.js.map +1 -0
- package/dist/src/core/paths.d.ts +21 -0
- package/dist/src/core/paths.js +58 -0
- package/dist/src/core/paths.js.map +1 -0
- package/dist/src/core/score.d.ts +137 -0
- package/dist/src/core/score.js +232 -0
- package/dist/src/core/score.js.map +1 -0
- package/dist/src/dast/targets.d.ts +30 -0
- package/dist/src/dast/targets.js +79 -0
- package/dist/src/dast/targets.js.map +1 -0
- package/dist/src/dast/zap.d.ts +42 -0
- package/dist/src/dast/zap.js +184 -0
- package/dist/src/dast/zap.js.map +1 -0
- package/dist/src/engine/scan.d.ts +80 -0
- package/dist/src/engine/scan.js +129 -0
- package/dist/src/engine/scan.js.map +1 -0
- package/dist/src/engine/shapes.d.ts +47 -0
- package/dist/src/engine/shapes.js +328 -0
- package/dist/src/engine/shapes.js.map +1 -0
- package/dist/src/engine/walk.d.ts +60 -0
- package/dist/src/engine/walk.js +156 -0
- package/dist/src/engine/walk.js.map +1 -0
- package/dist/src/parse/astDump.d.ts +31 -0
- package/dist/src/parse/astDump.js +66 -0
- package/dist/src/parse/astDump.js.map +1 -0
- package/dist/src/parse/languages.d.ts +70 -0
- package/dist/src/parse/languages.js +174 -0
- package/dist/src/parse/languages.js.map +1 -0
- package/dist/src/parse/location.d.ts +17 -0
- package/dist/src/parse/location.js +38 -0
- package/dist/src/parse/location.js.map +1 -0
- package/dist/src/parse/node-grammars.d.ts +12 -0
- package/dist/src/parse/node-grammars.js +35 -0
- package/dist/src/parse/node-grammars.js.map +1 -0
- package/dist/src/parse/parser.d.ts +126 -0
- package/dist/src/parse/parser.js +202 -0
- package/dist/src/parse/parser.js.map +1 -0
- package/dist/src/parse/tree-store.d.ts +139 -0
- package/dist/src/parse/tree-store.js +236 -0
- package/dist/src/parse/tree-store.js.map +1 -0
- package/dist/src/platform/dependency-rule.d.ts +27 -0
- package/dist/src/platform/dependency-rule.js +21 -0
- package/dist/src/platform/dependency-rule.js.map +1 -0
- package/dist/src/platform/deps.d.ts +86 -0
- package/dist/src/platform/deps.js +304 -0
- package/dist/src/platform/deps.js.map +1 -0
- package/dist/src/platform/php-facts.d.ts +49 -0
- package/dist/src/platform/php-facts.js +67 -0
- package/dist/src/platform/php-facts.js.map +1 -0
- package/dist/src/platform/runtime-version.d.ts +78 -0
- package/dist/src/platform/runtime-version.js +573 -0
- package/dist/src/platform/runtime-version.js.map +1 -0
- package/dist/src/report/colors.d.ts +50 -0
- package/dist/src/report/colors.js +97 -0
- package/dist/src/report/colors.js.map +1 -0
- package/dist/src/report/coverage-table.d.ts +90 -0
- package/dist/src/report/coverage-table.js +291 -0
- package/dist/src/report/coverage-table.js.map +1 -0
- package/dist/src/report/html-report.d.ts +66 -0
- package/dist/src/report/html-report.js +489 -0
- package/dist/src/report/html-report.js.map +1 -0
- package/dist/src/report/human.d.ts +44 -0
- package/dist/src/report/human.js +575 -0
- package/dist/src/report/human.js.map +1 -0
- package/dist/src/report/json.d.ts +24 -0
- package/dist/src/report/json.js +178 -0
- package/dist/src/report/json.js.map +1 -0
- package/dist/src/report/mark.d.ts +17 -0
- package/dist/src/report/mark.js +39 -0
- package/dist/src/report/mark.js.map +1 -0
- package/dist/src/report/pricing.d.ts +33 -0
- package/dist/src/report/pricing.js +343 -0
- package/dist/src/report/pricing.js.map +1 -0
- package/dist/src/report/sarif.d.ts +39 -0
- package/dist/src/report/sarif.js +219 -0
- package/dist/src/report/sarif.js.map +1 -0
- package/dist/src/report/site.d.ts +87 -0
- package/dist/src/report/site.js +742 -0
- package/dist/src/report/site.js.map +1 -0
- package/dist/src/rules/code-injection.d.ts +47 -0
- package/dist/src/rules/code-injection.js +326 -0
- package/dist/src/rules/code-injection.js.map +1 -0
- package/dist/src/rules/command-injection.d.ts +30 -0
- package/dist/src/rules/command-injection.js +286 -0
- package/dist/src/rules/command-injection.js.map +1 -0
- package/dist/src/rules/contract.d.ts +198 -0
- package/dist/src/rules/contract.js +39 -0
- package/dist/src/rules/contract.js.map +1 -0
- package/dist/src/rules/format-string.d.ts +46 -0
- package/dist/src/rules/format-string.js +146 -0
- package/dist/src/rules/format-string.js.map +1 -0
- package/dist/src/rules/hardcoded-secret.d.ts +38 -0
- package/dist/src/rules/hardcoded-secret.js +440 -0
- package/dist/src/rules/hardcoded-secret.js.map +1 -0
- package/dist/src/rules/lib/guards.d.ts +168 -0
- package/dist/src/rules/lib/guards.js +1179 -0
- package/dist/src/rules/lib/guards.js.map +1 -0
- package/dist/src/rules/lib/html-safety.d.ts +75 -0
- package/dist/src/rules/lib/html-safety.js +755 -0
- package/dist/src/rules/lib/html-safety.js.map +1 -0
- package/dist/src/rules/lib/local-build.d.ts +59 -0
- package/dist/src/rules/lib/local-build.js +320 -0
- package/dist/src/rules/lib/local-build.js.map +1 -0
- package/dist/src/rules/lib/strings.d.ts +208 -0
- package/dist/src/rules/lib/strings.js +1970 -0
- package/dist/src/rules/lib/strings.js.map +1 -0
- package/dist/src/rules/path-traversal.d.ts +33 -0
- package/dist/src/rules/path-traversal.js +96 -0
- package/dist/src/rules/path-traversal.js.map +1 -0
- package/dist/src/rules/registry.d.ts +19 -0
- package/dist/src/rules/registry.js +77 -0
- package/dist/src/rules/registry.js.map +1 -0
- package/dist/src/rules/sql-injection.d.ts +36 -0
- package/dist/src/rules/sql-injection.js +299 -0
- package/dist/src/rules/sql-injection.js.map +1 -0
- package/dist/src/rules/ssrf.d.ts +39 -0
- package/dist/src/rules/ssrf.js +111 -0
- package/dist/src/rules/ssrf.js.map +1 -0
- package/dist/src/rules/unbounded-copy.d.ts +37 -0
- package/dist/src/rules/unbounded-copy.js +147 -0
- package/dist/src/rules/unbounded-copy.js.map +1 -0
- package/dist/src/rules/unsafe-deserialization.d.ts +43 -0
- package/dist/src/rules/unsafe-deserialization.js +276 -0
- package/dist/src/rules/unsafe-deserialization.js.map +1 -0
- package/dist/src/rules/weak-hash.d.ts +37 -0
- package/dist/src/rules/weak-hash.js +197 -0
- package/dist/src/rules/weak-hash.js.map +1 -0
- package/dist/src/rules/xss.d.ts +38 -0
- package/dist/src/rules/xss.js +527 -0
- package/dist/src/rules/xss.js.map +1 -0
- package/dist/src/taint/content-type.d.ts +19 -0
- package/dist/src/taint/content-type.js +20 -0
- package/dist/src/taint/content-type.js.map +1 -0
- package/dist/src/taint/dictionaries.d.ts +23 -0
- package/dist/src/taint/dictionaries.js +1778 -0
- package/dist/src/taint/dictionaries.js.map +1 -0
- package/dist/src/taint/fold.d.ts +59 -0
- package/dist/src/taint/fold.js +1177 -0
- package/dist/src/taint/fold.js.map +1 -0
- package/dist/src/taint/java-scope.d.ts +61 -0
- package/dist/src/taint/java-scope.js +212 -0
- package/dist/src/taint/java-scope.js.map +1 -0
- package/dist/src/taint/local-map.d.ts +79 -0
- package/dist/src/taint/local-map.js +603 -0
- package/dist/src/taint/local-map.js.map +1 -0
- package/dist/src/taint/project.d.ts +172 -0
- package/dist/src/taint/project.js +617 -0
- package/dist/src/taint/project.js.map +1 -0
- package/dist/src/taint/tracer.d.ts +265 -0
- package/dist/src/taint/tracer.js +3817 -0
- package/dist/src/taint/tracer.js.map +1 -0
- package/dist/src/taint/types.d.ts +484 -0
- package/dist/src/taint/types.js +47 -0
- package/dist/src/taint/types.js.map +1 -0
- package/dist/src/taint/visibility.d.ts +25 -0
- package/dist/src/taint/visibility.js +96 -0
- package/dist/src/taint/visibility.js.map +1 -0
- package/docs/examples/defuse.json +20 -0
- package/docs/examples/dvwa-urls.json +14 -0
- package/docs/examples/github-code-scanning.yml +54 -0
- package/docs/examples/tiny.js +4 -0
- package/package.json +73 -4
- package/src/cli.ts +747 -0
- package/src/core/analyze.ts +1072 -0
- package/src/core/coverage.ts +269 -0
- package/src/core/finding.ts +311 -0
- package/src/core/licence.ts +61 -0
- package/src/core/paths.ts +59 -0
- package/src/core/score.ts +353 -0
- package/src/dast/targets.ts +121 -0
- package/src/dast/zap.ts +246 -0
- package/src/engine/scan.ts +200 -0
- package/src/engine/shapes.ts +348 -0
- package/src/engine/walk.ts +196 -0
- package/src/parse/astDump.ts +85 -0
- package/src/parse/languages.ts +232 -0
- package/src/parse/location.ts +44 -0
- package/src/parse/node-grammars.ts +41 -0
- package/src/parse/parser.ts +262 -0
- package/src/parse/tree-store.ts +301 -0
- package/src/platform/dependency-rule.ts +33 -0
- package/src/platform/deps.ts +374 -0
- package/src/platform/php-facts.ts +85 -0
- package/src/platform/runtime-version.ts +710 -0
- package/src/report/colors.ts +107 -0
- package/src/report/coverage-table.ts +359 -0
- package/src/report/html-report.ts +601 -0
- package/src/report/human.ts +811 -0
- package/src/report/json.ts +189 -0
- package/src/report/mark.ts +47 -0
- package/src/report/pricing.ts +374 -0
- package/src/report/sarif.ts +235 -0
- package/src/report/site.ts +760 -0
- package/src/rules/code-injection.ts +346 -0
- package/src/rules/command-injection.ts +296 -0
- package/src/rules/contract.ts +220 -0
- package/src/rules/format-string.ts +158 -0
- package/src/rules/hardcoded-secret.ts +485 -0
- package/src/rules/lib/guards.ts +1235 -0
- package/src/rules/lib/html-safety.ts +793 -0
- package/src/rules/lib/local-build.ts +329 -0
- package/src/rules/lib/strings.ts +2034 -0
- package/src/rules/path-traversal.ts +101 -0
- package/src/rules/registry.ts +90 -0
- package/src/rules/sql-injection.ts +311 -0
- package/src/rules/ssrf.ts +118 -0
- package/src/rules/unbounded-copy.ts +162 -0
- package/src/rules/unsafe-deserialization.ts +291 -0
- package/src/rules/weak-hash.ts +226 -0
- package/src/rules/xss.ts +572 -0
- package/src/taint/content-type.ts +20 -0
- package/src/taint/dictionaries.ts +1855 -0
- package/src/taint/fold.ts +1206 -0
- package/src/taint/java-scope.ts +200 -0
- package/src/taint/local-map.ts +594 -0
- package/src/taint/project.ts +774 -0
- package/src/taint/tracer.ts +4298 -0
- package/src/taint/types.ts +511 -0
- package/src/taint/visibility.ts +100 -0
- package/tsconfig.json +61 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* THE DEPENDENCY CHECK: DO THE LIBRARIES THIS PROJECT PINS HAVE KNOWN ADVISORIES?
|
|
3
|
+
* ==============================================================================
|
|
4
|
+
*
|
|
5
|
+
* Every other check reads the code a project wrote. Most of what a project
|
|
6
|
+
* runs, it did not write: it is in the lockfile. This reads the versions a
|
|
7
|
+
* lockfile pins and asks OSV (osv.dev, the open advisory database behind
|
|
8
|
+
* OSV-Scanner) which of them have published advisories.
|
|
9
|
+
*
|
|
10
|
+
* WHY IT IS A SEPARATE COMMAND. It is the one check that talks to the network.
|
|
11
|
+
* `defuse scan` promises nothing leaves the machine, and keeps that promise;
|
|
12
|
+
* `defuse deps` sends package names and versions - never code - to api.osv.dev,
|
|
13
|
+
* and says so before it does.
|
|
14
|
+
*
|
|
15
|
+
* WHAT IT CAN AND CANNOT CLAIM. A pinned version that matches an advisory says
|
|
16
|
+
* the vulnerable code is installed. It says nothing about whether this project
|
|
17
|
+
* ever calls it. Signature-grade by nature, and every result says so.
|
|
18
|
+
*
|
|
19
|
+
* Lockfiles it recognises but cannot read yet are listed by name, and lines it
|
|
20
|
+
* could not read are counted with the reason - never silently skipped.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { signatureFinding, type Severity, type SignatureFinding } from '../core/finding.js';
|
|
26
|
+
import { DEPENDENCY_RULE, type DependencyReport } from './dependency-rule.js';
|
|
27
|
+
import { globToRegExp } from '../engine/walk.js';
|
|
28
|
+
|
|
29
|
+
export { DEPENDENCY_RULE, type DependencyReport };
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
export type Ecosystem = 'npm' | 'PyPI' | 'Packagist' | 'Go';
|
|
34
|
+
|
|
35
|
+
export interface Pinned {
|
|
36
|
+
readonly name: string;
|
|
37
|
+
readonly version: string;
|
|
38
|
+
readonly ecosystem: Ecosystem;
|
|
39
|
+
/** The lockfile it was read from, as found. */
|
|
40
|
+
readonly file: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface LockfileRead {
|
|
44
|
+
readonly file: string;
|
|
45
|
+
readonly packages: readonly Pinned[];
|
|
46
|
+
/** What was in the file but not checked, each with the reason. */
|
|
47
|
+
readonly skipped: readonly string[];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface Advisory {
|
|
51
|
+
readonly id: string;
|
|
52
|
+
readonly summary: string;
|
|
53
|
+
readonly aliases: readonly string[];
|
|
54
|
+
/** OSV's own rating where the source gives one (critical/high/medium/low), else "unrated". */
|
|
55
|
+
readonly severity: string;
|
|
56
|
+
/** Versions the advisory says fix it, for this package. Empty when none is published. */
|
|
57
|
+
readonly fixed: readonly string[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface DependencyHit {
|
|
61
|
+
readonly pkg: Pinned;
|
|
62
|
+
readonly advisories: readonly Advisory[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
type Reader = (text: string, file: string) => { packages: Pinned[]; skipped: string[] };
|
|
66
|
+
|
|
67
|
+
/** A version that is a release, not a path, URL or branch. */
|
|
68
|
+
const RELEASE = /^v?\d/;
|
|
69
|
+
|
|
70
|
+
const READERS: Record<string, Reader> = {
|
|
71
|
+
'package-lock.json': (text, file) => {
|
|
72
|
+
const lock = JSON.parse(text) as {
|
|
73
|
+
packages?: Record<string, { name?: string; version?: string; link?: boolean }>;
|
|
74
|
+
dependencies?: Record<string, NpmV1Dep>;
|
|
75
|
+
};
|
|
76
|
+
const found: Array<[string, string]> = [];
|
|
77
|
+
if (lock.packages) {
|
|
78
|
+
for (const [key, p] of Object.entries(lock.packages)) {
|
|
79
|
+
if (key === '' || p.link || !p.version) continue; // the project itself, or a workspace link
|
|
80
|
+
found.push([p.name ?? key.slice(key.lastIndexOf('node_modules/') + 'node_modules/'.length), p.version]);
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
const walk = (deps: Record<string, NpmV1Dep> | undefined): void => {
|
|
84
|
+
for (const [name, d] of Object.entries(deps ?? {})) {
|
|
85
|
+
if (d.version) found.push([name, d.version]);
|
|
86
|
+
walk(d.dependencies);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
walk(lock.dependencies);
|
|
90
|
+
}
|
|
91
|
+
return split(found, 'npm', file);
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
'composer.lock': (text, file) => {
|
|
95
|
+
const lock = JSON.parse(text) as Record<string, Array<{ name: string; version: string }> | undefined>;
|
|
96
|
+
const all = [...(lock['packages'] ?? []), ...(lock['packages-dev'] ?? [])];
|
|
97
|
+
return split(all.map((p) => [p.name, p.version]), 'Packagist', file);
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
'requirements.txt': (text, file) => {
|
|
101
|
+
const packages: Pinned[] = [];
|
|
102
|
+
const skipped: string[] = [];
|
|
103
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
104
|
+
const line = raw.replace(/\s+#.*$/, '').trim();
|
|
105
|
+
if (!line || line.startsWith('#')) continue;
|
|
106
|
+
if (line.startsWith('-')) {
|
|
107
|
+
skipped.push(`${line} - an option line (-r, -e, --index-url...), not followed`);
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
const m = /^([A-Za-z0-9._-]+)(?:\[[^\]]*\])?\s*==\s*([^\s;,]+)$/.exec(line.split(';')[0]!.trim());
|
|
111
|
+
if (m) packages.push({ name: m[1]!, version: m[2]!, ecosystem: 'PyPI', file });
|
|
112
|
+
else skipped.push(`${line} - not pinned with ==, so the installed version is unknown`);
|
|
113
|
+
}
|
|
114
|
+
return { packages, skipped };
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
'go.mod': (text, file) => {
|
|
118
|
+
const found: Array<[string, string]> = [];
|
|
119
|
+
let inRequire = false;
|
|
120
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
121
|
+
const line = raw.replace(/\/\/.*$/, '').trim();
|
|
122
|
+
if (/^require\s*\($/.test(line)) inRequire = true;
|
|
123
|
+
else if (inRequire && line === ')') inRequire = false;
|
|
124
|
+
else {
|
|
125
|
+
const m = (inRequire ? /^(\S+)\s+(\S+)$/ : /^require\s+(\S+)\s+(\S+)$/).exec(line);
|
|
126
|
+
if (m) found.push([m[1]!, m[2]!]);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const result = split(found, 'Go', file);
|
|
130
|
+
if (/^replace\b/m.test(text)) {
|
|
131
|
+
result.skipped.push('replace directives - not applied, so a replaced module is checked at its required version');
|
|
132
|
+
}
|
|
133
|
+
return result;
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
interface NpmV1Dep {
|
|
138
|
+
version?: string;
|
|
139
|
+
dependencies?: Record<string, NpmV1Dep>;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function split(found: ReadonlyArray<readonly [string, string]>, ecosystem: Ecosystem, file: string) {
|
|
143
|
+
const packages: Pinned[] = [];
|
|
144
|
+
const skipped: string[] = [];
|
|
145
|
+
for (const [name, version] of found) {
|
|
146
|
+
if (RELEASE.test(version)) packages.push({ name, version, ecosystem, file });
|
|
147
|
+
else skipped.push(`${name} ${version} - not a release version (a path, URL or branch)`);
|
|
148
|
+
}
|
|
149
|
+
return { packages, skipped };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Recognised by name, not read yet. Listed in the output, never ignored in silence. */
|
|
153
|
+
export const UNREAD_LOCKFILES: ReadonlySet<string> = new Set([
|
|
154
|
+
'yarn.lock', 'pnpm-lock.yaml', 'npm-shrinkwrap.json', 'Pipfile.lock', 'poetry.lock', 'uv.lock',
|
|
155
|
+
'Cargo.lock', 'Gemfile.lock', 'pom.xml', 'build.gradle', 'build.gradle.kts', 'gradle.lockfile',
|
|
156
|
+
'packages.lock.json',
|
|
157
|
+
]);
|
|
158
|
+
|
|
159
|
+
const SKIP_DIRS = new Set(['node_modules', 'vendor', '.git', '.venv', 'venv', '__pycache__']);
|
|
160
|
+
|
|
161
|
+
/** Parse one lockfile by its file name. Null when the name is not one this reads. */
|
|
162
|
+
export function readLockfile(file: string, text: string): LockfileRead | null {
|
|
163
|
+
const reader = READERS[path.basename(file)];
|
|
164
|
+
if (!reader) return null;
|
|
165
|
+
try {
|
|
166
|
+
return { file, ...reader(text, file) };
|
|
167
|
+
} catch (error) {
|
|
168
|
+
return { file, packages: [], skipped: [`the whole file - it did not parse: ${(error as Error).message}`] };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Every lockfile under `target`, read; plus the recognised ones that are not. `exclude` means what scan --exclude means. */
|
|
173
|
+
export function findLockfiles(target: string, exclude: readonly string[] = []): { read: LockfileRead[]; unread: string[] } {
|
|
174
|
+
const read: LockfileRead[] = [];
|
|
175
|
+
const unread: string[] = [];
|
|
176
|
+
const patterns = exclude.map(globToRegExp);
|
|
177
|
+
const visit = (p: string): void => {
|
|
178
|
+
const relative = path.relative(target, p).split(path.sep).join('/');
|
|
179
|
+
if (relative && patterns.some((re) => re.test(relative))) return;
|
|
180
|
+
if (statSync(p).isDirectory()) {
|
|
181
|
+
for (const entry of readdirSync(p)) if (!SKIP_DIRS.has(entry)) visit(path.join(p, entry));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const name = path.basename(p);
|
|
185
|
+
if (UNREAD_LOCKFILES.has(name)) unread.push(p);
|
|
186
|
+
else if (READERS[name]) read.push(readLockfile(p, readFileSync(p, 'utf8'))!);
|
|
187
|
+
};
|
|
188
|
+
visit(target);
|
|
189
|
+
return { read, unread };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const OSV = 'https://api.osv.dev/v1';
|
|
193
|
+
const BATCH = 1000; // OSV's own limit per querybatch call
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Ask OSV about every pinned package. Sends name, ecosystem and version only.
|
|
197
|
+
* `fetchImpl` is injectable so the tests can run without a network.
|
|
198
|
+
*/
|
|
199
|
+
export async function queryOsv(
|
|
200
|
+
pinned: readonly Pinned[],
|
|
201
|
+
fetchImpl: typeof fetch = fetch,
|
|
202
|
+
): Promise<{ hits: DependencyHit[]; checked: number; incomplete: string[] }> {
|
|
203
|
+
const unique = [...new Map(pinned.map((p) => [`${p.ecosystem}|${p.name}|${p.version}`, p])).values()];
|
|
204
|
+
const call = async (url: string, body?: unknown): Promise<unknown> => {
|
|
205
|
+
const signal = AbortSignal.timeout(30_000);
|
|
206
|
+
const response = await fetchImpl(
|
|
207
|
+
url,
|
|
208
|
+
body
|
|
209
|
+
? { method: 'POST', body: JSON.stringify(body), headers: { 'content-type': 'application/json' }, signal }
|
|
210
|
+
: { signal },
|
|
211
|
+
);
|
|
212
|
+
if (!response.ok) throw new Error(`OSV answered ${response.status} for ${url}`);
|
|
213
|
+
return response.json();
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const idsPer: string[][] = [];
|
|
217
|
+
const incomplete: string[] = [];
|
|
218
|
+
for (let i = 0; i < unique.length; i += BATCH) {
|
|
219
|
+
const chunk = unique.slice(i, i + BATCH);
|
|
220
|
+
const answer = (await call(`${OSV}/querybatch`, {
|
|
221
|
+
queries: chunk.map((p) => ({ package: { name: p.name, ecosystem: p.ecosystem }, version: p.version })),
|
|
222
|
+
})) as { results?: Array<{ vulns?: Array<{ id: string }>; next_page_token?: string }> };
|
|
223
|
+
if (!Array.isArray(answer.results) || answer.results.length !== chunk.length) {
|
|
224
|
+
throw new Error('OSV returned a batch answer of the wrong shape - nothing was checked');
|
|
225
|
+
}
|
|
226
|
+
answer.results.forEach((r, j) => {
|
|
227
|
+
idsPer.push((r.vulns ?? []).map((v) => v.id));
|
|
228
|
+
if (r.next_page_token) incomplete.push(`${chunk[j]!.name} ${chunk[j]!.version} - more advisories exist than one page`);
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ponytail: one GET per distinct advisory, all at once; add a concurrency cap if OSV starts refusing.
|
|
233
|
+
const details = new Map<string, OsvVuln>();
|
|
234
|
+
await Promise.all(
|
|
235
|
+
[...new Set(idsPer.flat())].map(async (id) => details.set(id, (await call(`${OSV}/vulns/${encodeURIComponent(id)}`)) as OsvVuln)),
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
const hits = unique.flatMap((pkg, i): DependencyHit[] => {
|
|
239
|
+
const ids = idsPer[i]!;
|
|
240
|
+
return ids.length ? [{ pkg, advisories: mergeSame(ids.map((id) => toAdvisory(details.get(id)!, pkg))) }] : [];
|
|
241
|
+
});
|
|
242
|
+
return { hits, checked: unique.length, incomplete };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
const RANK: Record<string, number> = { critical: 4, high: 3, medium: 2, low: 1 };
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The whole check for `scan --deps`: find lockfiles, ask OSV, and return one
|
|
250
|
+
* signature-based finding per affected package, located on its lockfile line.
|
|
251
|
+
* `display` turns an absolute path into the one the report shows.
|
|
252
|
+
*/
|
|
253
|
+
export async function checkDependencies(
|
|
254
|
+
target: string,
|
|
255
|
+
display: (file: string) => string,
|
|
256
|
+
exclude: readonly string[] = [],
|
|
257
|
+
fetchImpl: typeof fetch = fetch,
|
|
258
|
+
): Promise<{ findings: SignatureFinding[]; report: DependencyReport }> {
|
|
259
|
+
const { read, unread } = findLockfiles(target, exclude);
|
|
260
|
+
const pinned = read.flatMap((r) => r.packages);
|
|
261
|
+
const answer = pinned.length ? await queryOsv(pinned, fetchImpl) : { hits: [], checked: 0, incomplete: [] };
|
|
262
|
+
const texts = new Map<string, string[]>();
|
|
263
|
+
const linesOf = (file: string): string[] => {
|
|
264
|
+
if (!texts.has(file)) texts.set(file, readFileSync(file, 'utf8').split(/\r?\n/));
|
|
265
|
+
return texts.get(file)!;
|
|
266
|
+
};
|
|
267
|
+
return {
|
|
268
|
+
findings: answer.hits.map((h) => dependencyFinding(h, linesOf(h.pkg.file), display(h.pkg.file))),
|
|
269
|
+
report: {
|
|
270
|
+
source: 'api.osv.dev',
|
|
271
|
+
checkedAt: new Date().toISOString(),
|
|
272
|
+
read: read.map((r) => ({ file: display(r.file), packages: r.packages.length, skipped: r.skipped })),
|
|
273
|
+
notRead: unread.map(display),
|
|
274
|
+
checked: answer.checked,
|
|
275
|
+
incomplete: answer.incomplete,
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function dependencyFinding(hit: DependencyHit, lines: readonly string[], file: string): SignatureFinding {
|
|
281
|
+
const { pkg, advisories } = hit;
|
|
282
|
+
// ponytail: first line naming the package (with its version, if one does); a lockfile has no AST to point at.
|
|
283
|
+
const named = (needle: string) => lines.findIndex((l) => l.includes(needle));
|
|
284
|
+
const at = [`node_modules/${pkg.name}"`, `"${pkg.name}"`, pkg.name]
|
|
285
|
+
.map((n) => {
|
|
286
|
+
const withVersion = lines.findIndex((l) => l.includes(n) && l.includes(pkg.version));
|
|
287
|
+
return withVersion >= 0 ? withVersion : named(n);
|
|
288
|
+
})
|
|
289
|
+
.find((i) => i >= 0) ?? 0;
|
|
290
|
+
const text = (lines[at] ?? '').trim();
|
|
291
|
+
const rated = advisories.filter((a) => a.severity !== 'unrated');
|
|
292
|
+
const top = rated.reduce((best, a) => (RANK[a.severity]! > RANK[best]! ? a.severity : best), 'low');
|
|
293
|
+
const severity = (rated.length ? top : 'medium') as Severity;
|
|
294
|
+
const ids = advisories.map((a) => [a.id, ...a.aliases.filter((x) => x.startsWith('CVE-'))].join('/'));
|
|
295
|
+
const fixed = [...new Set(advisories.flatMap((a) => a.fixed))];
|
|
296
|
+
return signatureFinding({
|
|
297
|
+
ruleId: DEPENDENCY_RULE.id,
|
|
298
|
+
ruleName: DEPENDENCY_RULE.name,
|
|
299
|
+
cwe: 'CWE-1395',
|
|
300
|
+
owasp: DEPENDENCY_RULE.owasp,
|
|
301
|
+
severity,
|
|
302
|
+
message:
|
|
303
|
+
`${pkg.name} ${pkg.version} has ${advisories.length} published advisor${advisories.length === 1 ? 'y' : 'ies'}` +
|
|
304
|
+
(fixed.length ? ` - fixed versions: ${fixed.join(', ')}` : ' - no fixed version published'),
|
|
305
|
+
location: {
|
|
306
|
+
file,
|
|
307
|
+
startLine: at + 1,
|
|
308
|
+
startColumn: 1,
|
|
309
|
+
endLine: at + 1,
|
|
310
|
+
endColumn: (lines[at]?.length ?? 0) + 1,
|
|
311
|
+
astNodeType: 'lockfile-entry',
|
|
312
|
+
astNodePath: `${pkg.ecosystem} lockfile`,
|
|
313
|
+
snippet: text.length > 160 ? `${text.slice(0, 159)}…` : text,
|
|
314
|
+
},
|
|
315
|
+
reasoning:
|
|
316
|
+
`osv.dev lists ${ids.join(', ')} for ${pkg.ecosystem} ${pkg.name} ${pkg.version}: ` +
|
|
317
|
+
advisories.map((a) => `${a.summary} (${a.severity})`).join('; ') +
|
|
318
|
+
'.' +
|
|
319
|
+
(rated.length
|
|
320
|
+
? ''
|
|
321
|
+
: ' None of these advisories carries a severity rating, so medium is a placeholder, not an assessment.'),
|
|
322
|
+
limitations: DEPENDENCY_RULE.limitations,
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
interface OsvVuln {
|
|
327
|
+
id: string;
|
|
328
|
+
summary?: string;
|
|
329
|
+
details?: string;
|
|
330
|
+
aliases?: string[];
|
|
331
|
+
database_specific?: { severity?: string };
|
|
332
|
+
affected?: Array<{
|
|
333
|
+
package?: { name?: string; ecosystem?: string };
|
|
334
|
+
ranges?: Array<{ type?: string; events?: Array<{ fixed?: string }> }>;
|
|
335
|
+
}>;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* One flaw, one entry. OSV holds the same CVE once per database (GHSA-, GO-,
|
|
340
|
+
* PYSEC-...), so a project would otherwise see one bug listed two or three
|
|
341
|
+
* times. Entries naming each other, or sharing an alias, are folded into one;
|
|
342
|
+
* a rated entry wins over an unrated one, and every id is kept in `aliases`.
|
|
343
|
+
*/
|
|
344
|
+
export function mergeSame(advisories: readonly Advisory[]): Advisory[] {
|
|
345
|
+
const groups: Advisory[][] = [];
|
|
346
|
+
for (const a of advisories) {
|
|
347
|
+
const names = new Set([a.id, ...a.aliases]);
|
|
348
|
+
const group = groups.find((g) => g.some((b) => b.id === a.id || names.has(b.id) || b.aliases.some((x) => names.has(x))));
|
|
349
|
+
if (group) group.push(a);
|
|
350
|
+
else groups.push([a]);
|
|
351
|
+
}
|
|
352
|
+
return groups.map((g) => {
|
|
353
|
+
const lead = g.find((a) => a.severity !== 'unrated') ?? g[0]!;
|
|
354
|
+
const ids = new Set(g.flatMap((a) => [a.id, ...a.aliases]));
|
|
355
|
+
ids.delete(lead.id);
|
|
356
|
+
return { ...lead, aliases: [...ids], fixed: [...new Set(g.flatMap((a) => a.fixed))] };
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function toAdvisory(v: OsvVuln, pkg: Pinned): Advisory {
|
|
361
|
+
const fixed = (v.affected ?? [])
|
|
362
|
+
.filter((a) => a.package?.name === pkg.name && a.package?.ecosystem === pkg.ecosystem)
|
|
363
|
+
// GIT ranges name commits, not versions anyone can install.
|
|
364
|
+
.flatMap((a) => (a.ranges ?? []).filter((r) => r.type !== 'GIT'))
|
|
365
|
+
.flatMap((r) => (r.events ?? []).flatMap((e) => (e.fixed ? [e.fixed] : [])));
|
|
366
|
+
const rated = v.database_specific?.severity?.toLowerCase().replace('moderate', 'medium');
|
|
367
|
+
return {
|
|
368
|
+
id: v.id,
|
|
369
|
+
summary: v.summary ?? v.details?.split('\n')[0]?.slice(0, 160) ?? '(no summary published)',
|
|
370
|
+
aliases: v.aliases ?? [],
|
|
371
|
+
severity: rated && ['critical', 'high', 'medium', 'low'].includes(rated) ? rated : 'unrated',
|
|
372
|
+
fixed: [...new Set(fixed)],
|
|
373
|
+
};
|
|
374
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHAT DEFUSE KNOWS ABOUT PHP RELEASES - AND WHEN IT LAST CHECKED.
|
|
3
|
+
* ================================================================
|
|
4
|
+
*
|
|
5
|
+
* Every other check in Defuse reads the code it is given and needs nothing
|
|
6
|
+
* else. This one compares a version number with facts about the world, and
|
|
7
|
+
* facts about the world go stale: a release that is current today is missing a
|
|
8
|
+
* fix next month. So the table carries the date it was checked, and every
|
|
9
|
+
* finding and report built on it prints that date. A stale table is then a
|
|
10
|
+
* visible fact rather than a quiet wrong answer.
|
|
11
|
+
*
|
|
12
|
+
* Every entry was read from php.net on the date below, not from news reports:
|
|
13
|
+
* https://www.php.net/supported-versions.php support dates, 8.2 - 8.5
|
|
14
|
+
* https://www.php.net/eol.php end-of-life dates
|
|
15
|
+
* https://www.php.net/archive/2026 the 21 Oct 2026 security releases
|
|
16
|
+
*
|
|
17
|
+
* TO UPDATE: change the facts, change CHECKED, run `npm test`. The tests pin
|
|
18
|
+
* the table's shape, not today's numbers, so an update is a data change only.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** The day these facts were read from php.net. ISO date. */
|
|
22
|
+
export const PHP_FACTS_CHECKED = '2026-10-28';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Branches still receiving security fixes, and until when. A branch past its
|
|
26
|
+
* date on the day of the scan is treated as end of life - the scan date
|
|
27
|
+
* decides that, not the table date, so 8.2 turns out of support on 1 Jan 2027
|
|
28
|
+
* without anyone editing this file.
|
|
29
|
+
*/
|
|
30
|
+
export const PHP_SECURITY_UNTIL: Readonly<Record<string, string>> = {
|
|
31
|
+
'8.2': '2026-12-31',
|
|
32
|
+
'8.3': '2027-12-31',
|
|
33
|
+
'8.4': '2028-12-31',
|
|
34
|
+
'8.5': '2029-12-31',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Branches php.net lists as end of life, with the date support ended. */
|
|
38
|
+
export const PHP_END_OF_LIFE: Readonly<Record<string, string>> = {
|
|
39
|
+
'8.1': '2025-12-31',
|
|
40
|
+
'8.0': '2023-11-26',
|
|
41
|
+
'7.4': '2022-11-28',
|
|
42
|
+
'7.3': '2021-12-06',
|
|
43
|
+
'7.2': '2020-11-30',
|
|
44
|
+
'7.1': '2019-12-01',
|
|
45
|
+
'7.0': '2019-01-10',
|
|
46
|
+
'5.6': '2018-12-31',
|
|
47
|
+
'5.5': '2016-07-21',
|
|
48
|
+
'5.4': '2015-09-03',
|
|
49
|
+
'5.3': '2014-08-14',
|
|
50
|
+
'5.2': '2011-01-06',
|
|
51
|
+
'5.1': '2006-08-24',
|
|
52
|
+
'5.0': '2005-09-05',
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export interface PhpAdvisory {
|
|
56
|
+
/** The name it is known by, for a reader who has seen the news. */
|
|
57
|
+
readonly name: string;
|
|
58
|
+
readonly cves: readonly string[];
|
|
59
|
+
/** ISO date of the fixing releases. */
|
|
60
|
+
readonly published: string;
|
|
61
|
+
/** One plain sentence: what an attacker gets. */
|
|
62
|
+
readonly impact: string;
|
|
63
|
+
/** Per supported branch, the first release with the fix. */
|
|
64
|
+
readonly fixedIn: Readonly<Record<string, string>>;
|
|
65
|
+
/** The oldest branch reported affected. Older branches are end of life anyway. */
|
|
66
|
+
readonly oldestAffected: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Only advisories severe enough to change what a reader should do today. This
|
|
71
|
+
* is not a vulnerability database and does not pretend to be one - see the
|
|
72
|
+
* limitations text in runtime-version.ts. One entry is a deliberate start.
|
|
73
|
+
*/
|
|
74
|
+
export const PHP_ADVISORIES: readonly PhpAdvisory[] = [
|
|
75
|
+
{
|
|
76
|
+
name: 'PHP2Shell',
|
|
77
|
+
cves: ['CVE-2026-109618', 'CVE-2026-109619'],
|
|
78
|
+
published: '2026-10-21',
|
|
79
|
+
impact:
|
|
80
|
+
'two critical flaws in PHP itself that can let an attacker run code on the server ' +
|
|
81
|
+
'without logging in, reported exploited in the wild',
|
|
82
|
+
fixedIn: { '8.2': '8.2.35', '8.3': '8.3.36', '8.4': '8.4.27', '8.5': '8.5.12' },
|
|
83
|
+
oldestAffected: '5.4',
|
|
84
|
+
},
|
|
85
|
+
];
|