@dependably/npm-check 1.9.0 → 1.10.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 +47 -0
- package/bin/cli.js +93 -1
- package/package.json +20 -3
- package/src/audit-config.js +98 -68
- package/src/audit.js +198 -58
- package/src/exceptions.js +82 -66
- package/src/facts/collect.js +201 -0
- package/src/facts/document.js +151 -0
- package/src/facts/errors.js +19 -0
- package/src/facts/index.js +37 -0
- package/src/facts/lockfile-graph.js +467 -0
- package/src/facts/modulegraph.js +289 -0
- package/src/facts/resolve.js +493 -0
- package/src/facts/scan.js +755 -0
- package/src/facts/sourcescan.js +221 -0
- package/src/facts/specifier.js +89 -0
- package/src/facts/ts.js +47 -0
- package/src/facts/types.d.ts +579 -0
- package/src/facts/version.js +16 -0
- package/src/facts/workspace.js +278 -0
- package/src/npmrc-validator.js +2 -1
- package/src/pnpm-workspace-validator.js +8 -2
- package/src/report.js +12 -6
- package/src/schema.js +54 -0
package/src/audit.js
CHANGED
|
@@ -4,8 +4,8 @@ import path from 'path';
|
|
|
4
4
|
import { forEachPackageEntry, detectLockfileFlavor } from './format-library.js';
|
|
5
5
|
import { validatePackageLock } from './validator.js';
|
|
6
6
|
import { validatePackageJson } from './package-json-validator.js';
|
|
7
|
-
import { validateNpmrc, NPMRC_SECURITY_CODES } from './npmrc-validator.js';
|
|
8
|
-
import { validatePnpmWorkspace } from './pnpm-workspace-validator.js';
|
|
7
|
+
import { validateNpmrc, parseNpmrc, NPMRC_SECURITY_CODES } from './npmrc-validator.js';
|
|
8
|
+
import { validatePnpmWorkspace, parsePnpmWorkspace } from './pnpm-workspace-validator.js';
|
|
9
9
|
import { isPlaceholder } from './integrity.js';
|
|
10
10
|
import { classifyRange } from './pinner.js';
|
|
11
11
|
import { walkOverrides } from './overrides.js';
|
|
@@ -683,6 +683,31 @@ const validPnpmFieldRule = {
|
|
|
683
683
|
* Opt-in: with no `hosts` configured the rule is a no-op, so projects that
|
|
684
684
|
* genuinely install from a private registry are unaffected.
|
|
685
685
|
*/
|
|
686
|
+
// Trim/lowercase the configured pin list, dropping non-string and blank entries.
|
|
687
|
+
// An empty result means the rule is unconfigured, i.e. off.
|
|
688
|
+
function normalizePinnedHosts(hosts) {
|
|
689
|
+
if (!Array.isArray(hosts)) return [];
|
|
690
|
+
return hosts.filter((h) => typeof h === 'string' && h.trim()).map((h) => h.trim().toLowerCase());
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// Git/file/link/workspace entries resolve outside the registry by definition —
|
|
694
|
+
// no-git-deps / secure-resolved own those.
|
|
695
|
+
function resolvesOutsideRegistry({ isRoot, isWorkspaceSource, isLink, isGitDep, isFileDep }) {
|
|
696
|
+
return Boolean(isRoot || isWorkspaceSource || isLink || isGitDep || isFileDep);
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// Hostname of an entry's http(s) `resolved` URL, or null when it has none or the
|
|
700
|
+
// URL is unparseable (secure-resolved flags that; not this rule's job).
|
|
701
|
+
function resolvedHostname(entry) {
|
|
702
|
+
const resolved = entry && entry.resolved;
|
|
703
|
+
if (!resolved || !/^https?:/i.test(resolved)) return null;
|
|
704
|
+
try {
|
|
705
|
+
return new URL(resolved).hostname.toLowerCase();
|
|
706
|
+
} catch {
|
|
707
|
+
return null;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
686
711
|
const resolvedRegistryPinRule = {
|
|
687
712
|
id: 'resolved-registry-pin',
|
|
688
713
|
description: 'Resolved URLs must point only at the registry hosts this project pins to',
|
|
@@ -691,31 +716,16 @@ const resolvedRegistryPinRule = {
|
|
|
691
716
|
// so there is nothing to pin.
|
|
692
717
|
flavors: ['npm'],
|
|
693
718
|
check({ lockfile, options }) {
|
|
694
|
-
const { hosts = [] } = options;
|
|
695
719
|
const findings = [];
|
|
696
720
|
// Unconfigured == off. Pinning is a per-project decision, not a default.
|
|
697
|
-
|
|
698
|
-
if (!lockfile.packages) return findings;
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
forEachPackageEntry(lockfile, ({ key, entry, name, isRoot, isWorkspaceSource, isLink, isGitDep, isFileDep }) => {
|
|
706
|
-
// Git/file/link/workspace entries resolve outside the registry by
|
|
707
|
-
// definition — no-git-deps / secure-resolved own those.
|
|
708
|
-
if (isRoot || isWorkspaceSource || isLink || isGitDep || isFileDep) return;
|
|
709
|
-
const resolved = entry && entry.resolved;
|
|
710
|
-
if (!resolved || !/^https?:/i.test(resolved)) return;
|
|
711
|
-
let hostname;
|
|
712
|
-
try {
|
|
713
|
-
hostname = new URL(resolved).hostname.toLowerCase();
|
|
714
|
-
} catch {
|
|
715
|
-
// Unparseable URL — secure-resolved flags it; not this rule's job.
|
|
716
|
-
return;
|
|
717
|
-
}
|
|
718
|
-
if (pinned.includes(hostname)) return;
|
|
721
|
+
const pinned = normalizePinnedHosts(options.hosts);
|
|
722
|
+
if (pinned.length === 0 || !lockfile.packages) return findings;
|
|
723
|
+
|
|
724
|
+
forEachPackageEntry(lockfile, (packageEntry) => {
|
|
725
|
+
if (resolvesOutsideRegistry(packageEntry)) return;
|
|
726
|
+
const hostname = resolvedHostname(packageEntry.entry);
|
|
727
|
+
if (hostname === null || pinned.includes(hostname)) return;
|
|
728
|
+
const { key, name } = packageEntry;
|
|
719
729
|
findings.push({
|
|
720
730
|
packagePath: key,
|
|
721
731
|
message: `${name || key} resolves from "${hostname}", which is not a pinned registry host (${pinned.join(', ')}) — the lockfile will not install where that host is unreachable`
|
|
@@ -725,6 +735,118 @@ const resolvedRegistryPinRule = {
|
|
|
725
735
|
}
|
|
726
736
|
};
|
|
727
737
|
|
|
738
|
+
// Supply-chain "cooldown": refuse to install versions published less than N ago.
|
|
739
|
+
// A compromised maintainer account's malicious release is typically detected and
|
|
740
|
+
// unpublished within hours, so a cooldown means you simply never resolve it.
|
|
741
|
+
//
|
|
742
|
+
// The two package managers disagree on BOTH key name and unit, which is the whole
|
|
743
|
+
// reason this normalizes to days before comparing:
|
|
744
|
+
// npm >= 11.10 : `.npmrc` min-release-age (DAYS)
|
|
745
|
+
// pnpm >= 10.16 : `pnpm-workspace.yaml` minimumReleaseAge (MINUTES)
|
|
746
|
+
// pnpm reads only auth/registry settings from .npmrc, so its cooldown is never
|
|
747
|
+
// there; npm has no equivalent yaml, so its cooldown is never in the workspace file.
|
|
748
|
+
const MINUTES_PER_DAY = 1440;
|
|
749
|
+
|
|
750
|
+
// Configured cooldown in DAYS for an npm project, or null when unset/unparseable.
|
|
751
|
+
function npmCooldownDays(npmrcPath) {
|
|
752
|
+
let content;
|
|
753
|
+
try {
|
|
754
|
+
content = fs.readFileSync(npmrcPath, 'utf8');
|
|
755
|
+
} catch {
|
|
756
|
+
return null; // no committed .npmrc → no committed policy
|
|
757
|
+
}
|
|
758
|
+
// parseNpmrc yields an entry list, keys lowercased; last assignment wins (ini).
|
|
759
|
+
const entry = parseNpmrc(content).filter((e) => e.key === 'min-release-age').pop();
|
|
760
|
+
if (!entry) return null;
|
|
761
|
+
const days = Number(entry.value);
|
|
762
|
+
return Number.isFinite(days) ? days : null;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Configured cooldown in DAYS for a pnpm project, or null when unset/unparseable.
|
|
766
|
+
function pnpmCooldownDays(workspacePath) {
|
|
767
|
+
let content;
|
|
768
|
+
try {
|
|
769
|
+
content = fs.readFileSync(workspacePath, 'utf8');
|
|
770
|
+
} catch {
|
|
771
|
+
return null;
|
|
772
|
+
}
|
|
773
|
+
let doc;
|
|
774
|
+
try {
|
|
775
|
+
doc = parsePnpmWorkspace(content);
|
|
776
|
+
} catch {
|
|
777
|
+
return null; // malformed YAML — valid-pnpm-workspace owns that finding
|
|
778
|
+
}
|
|
779
|
+
const raw = doc && doc.minimumReleaseAge;
|
|
780
|
+
if (raw === undefined) return null;
|
|
781
|
+
const minutes = Number(raw);
|
|
782
|
+
return Number.isFinite(minutes) ? minutes / MINUTES_PER_DAY : null;
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
// A blanket exclusion silently voids the policy, so it is worth its own finding.
|
|
786
|
+
function blanketExclusions(workspacePath) {
|
|
787
|
+
let doc;
|
|
788
|
+
try {
|
|
789
|
+
doc = parsePnpmWorkspace(fs.readFileSync(workspacePath, 'utf8'));
|
|
790
|
+
} catch {
|
|
791
|
+
return [];
|
|
792
|
+
}
|
|
793
|
+
const list = doc && doc.minimumReleaseAgeExclude;
|
|
794
|
+
if (!Array.isArray(list)) return [];
|
|
795
|
+
return list.filter((p) => typeof p === 'string' && (p === '*' || p === '**'));
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
const minReleaseAgeRule = {
|
|
799
|
+
id: 'min-release-age',
|
|
800
|
+
description: 'A minimum release-age cooldown must be configured, so a freshly published (possibly compromised) version is never installed',
|
|
801
|
+
defaultSeverity: 'warn',
|
|
802
|
+
flavors: ['npm', 'pnpm'],
|
|
803
|
+
check({ filePath, options, flavor }) {
|
|
804
|
+
const { minDays = 3 } = options;
|
|
805
|
+
const dir = path.dirname(path.resolve(filePath));
|
|
806
|
+
const isPnpm = flavor === 'pnpm';
|
|
807
|
+
const configFile = isPnpm ? 'pnpm-workspace.yaml' : '.npmrc';
|
|
808
|
+
// `npmrcPath` lets a caller point at a .npmrc outside the lockfile's dir
|
|
809
|
+
// (same option valid-npmrc takes); it is meaningless on the pnpm path.
|
|
810
|
+
const npmrcPath = options.npmrcPath ? path.resolve(options.npmrcPath) : path.join(dir, '.npmrc');
|
|
811
|
+
const configPath = isPnpm ? path.join(dir, 'pnpm-workspace.yaml') : npmrcPath;
|
|
812
|
+
|
|
813
|
+
const configured = isPnpm ? pnpmCooldownDays(configPath) : npmCooldownDays(configPath);
|
|
814
|
+
const setting = isPnpm ? 'minimumReleaseAge' : 'min-release-age';
|
|
815
|
+
const findings = [];
|
|
816
|
+
|
|
817
|
+
if (configured === null) {
|
|
818
|
+
const unit = isPnpm ? `${minDays * MINUTES_PER_DAY} (minutes)` : `${minDays} (days)`;
|
|
819
|
+
findings.push({
|
|
820
|
+
packagePath: configFile,
|
|
821
|
+
message: `no release-age cooldown configured — set "${setting}" to at least ${unit} in ${configFile} so a version published moments ago is never installed`
|
|
822
|
+
});
|
|
823
|
+
} else if (configured < minDays) {
|
|
824
|
+
findings.push({
|
|
825
|
+
packagePath: configFile,
|
|
826
|
+
message: `release-age cooldown is ${formatDays(configured)}, below the required minimum of ${formatDays(minDays)} ("${setting}" in ${configFile})`
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
if (isPnpm) {
|
|
831
|
+
for (const pattern of blanketExclusions(configPath)) {
|
|
832
|
+
findings.push({
|
|
833
|
+
packagePath: configFile,
|
|
834
|
+
message: `"minimumReleaseAgeExclude" contains the blanket pattern "${pattern}", which exempts every package and voids the cooldown`
|
|
835
|
+
});
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
return findings;
|
|
839
|
+
}
|
|
840
|
+
};
|
|
841
|
+
|
|
842
|
+
// Render a day count the way a reader configures it: whole days, or minutes when
|
|
843
|
+
// the value is under a day (which is how a pnpm `minimumReleaseAge` will land here).
|
|
844
|
+
function formatDays(days) {
|
|
845
|
+
if (days >= 1) return `${Number.isInteger(days) ? days : days.toFixed(2)} day${days === 1 ? '' : 's'}`;
|
|
846
|
+
const minutes = Math.round(days * MINUTES_PER_DAY);
|
|
847
|
+
return `${minutes} minute${minutes === 1 ? '' : 's'}`;
|
|
848
|
+
}
|
|
849
|
+
|
|
728
850
|
export const rules = [
|
|
729
851
|
lockfileVersionRule,
|
|
730
852
|
validStructureRule,
|
|
@@ -742,7 +864,8 @@ export const rules = [
|
|
|
742
864
|
noFundRule,
|
|
743
865
|
validNpmrcRule,
|
|
744
866
|
validPnpmWorkspaceRule,
|
|
745
|
-
validPnpmFieldRule
|
|
867
|
+
validPnpmFieldRule,
|
|
868
|
+
minReleaseAgeRule
|
|
746
869
|
];
|
|
747
870
|
|
|
748
871
|
/**
|
|
@@ -870,30 +993,45 @@ export function runAudit(target, config = {}) {
|
|
|
870
993
|
* @param {object} options - { format: 'stylish' | 'json' }
|
|
871
994
|
* @returns {string}
|
|
872
995
|
*/
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
const
|
|
996
|
+
// Machine-readable half of formatAuditReport.
|
|
997
|
+
function formatAuditJson(report, suppressed) {
|
|
998
|
+
const meta = report.exceptionsMeta || {};
|
|
999
|
+
return JSON.stringify({
|
|
1000
|
+
filePath: report.filePath,
|
|
1001
|
+
pass: report.pass,
|
|
1002
|
+
summary: report.summary,
|
|
1003
|
+
findings: report.findings,
|
|
1004
|
+
suppressed,
|
|
1005
|
+
exceptionsMeta: {
|
|
1006
|
+
unused: (meta.unused || []).map((e) => e._raw),
|
|
1007
|
+
expired: (meta.expired || []).map((e) => e._raw)
|
|
1008
|
+
}
|
|
1009
|
+
}, null, 2);
|
|
1010
|
+
}
|
|
876
1011
|
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
expired: (report.exceptionsMeta && report.exceptionsMeta.expired || []).map((e) => e._raw)
|
|
887
|
-
}
|
|
888
|
-
}, null, 2);
|
|
889
|
-
}
|
|
1012
|
+
// One aligned `severity rule path message` line per finding.
|
|
1013
|
+
function findingLines(findings) {
|
|
1014
|
+
const ruleWidth = Math.max(...findings.map((f) => f.ruleId.length));
|
|
1015
|
+
return findings.map((finding) => {
|
|
1016
|
+
const sev = finding.severity === 'error' ? 'error' : 'warn ';
|
|
1017
|
+
const loc = finding.packagePath ? `${finding.packagePath} ` : '';
|
|
1018
|
+
return ` ${sev} ${finding.ruleId.padEnd(ruleWidth)} ${loc}${finding.message}`;
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
890
1021
|
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
}
|
|
1022
|
+
// The closing `N problems (E errors, W warnings)` tally.
|
|
1023
|
+
function totalsLine(summary, suppressedCount) {
|
|
1024
|
+
const { errors, warnings, total } = summary;
|
|
1025
|
+
const problemWord = total === 1 ? 'problem' : 'problems';
|
|
1026
|
+
const errorWord = errors === 1 ? 'error' : 'errors';
|
|
1027
|
+
const warningWord = warnings === 1 ? 'warning' : 'warnings';
|
|
1028
|
+
const suffix = suppressedCount > 0 ? ` — ${suppressedCount} suppressed by .dependably` : '';
|
|
1029
|
+
return `${total} ${problemWord} (${errors} ${errorWord}, ${warnings} ${warningWord})${suffix}`;
|
|
1030
|
+
}
|
|
894
1031
|
|
|
895
|
-
|
|
896
|
-
|
|
1032
|
+
// Human-readable (ESLint-like) half of formatAuditReport.
|
|
1033
|
+
function formatAuditStylish(report, suppressed, showSuppressed) {
|
|
1034
|
+
const lines = [report.filePath];
|
|
897
1035
|
|
|
898
1036
|
if (report.findings.length === 0) {
|
|
899
1037
|
lines.push(suppressed.length > 0 ? ` no problems found (${suppressed.length} suppressed by .dependably)` : ' no problems found');
|
|
@@ -901,22 +1039,24 @@ export function formatAuditReport(report, options = {}) {
|
|
|
901
1039
|
return lines.join('\n');
|
|
902
1040
|
}
|
|
903
1041
|
|
|
904
|
-
|
|
905
|
-
for (const finding of report.findings) {
|
|
906
|
-
const sev = finding.severity === 'error' ? 'error' : 'warn ';
|
|
907
|
-
const loc = finding.packagePath ? `${finding.packagePath} ` : '';
|
|
908
|
-
lines.push(` ${sev} ${finding.ruleId.padEnd(ruleWidth)} ${loc}${finding.message}`);
|
|
909
|
-
}
|
|
910
|
-
|
|
911
|
-
const { errors, warnings, total } = report.summary;
|
|
912
|
-
const problemWord = total === 1 ? 'problem' : 'problems';
|
|
913
|
-
const suffix = suppressed.length > 0 ? ` — ${suppressed.length} suppressed by .dependably` : '';
|
|
1042
|
+
lines.push(...findingLines(report.findings));
|
|
914
1043
|
lines.push('');
|
|
915
|
-
lines.push(
|
|
1044
|
+
lines.push(totalsLine(report.summary, suppressed.length));
|
|
916
1045
|
appendSuppressed(lines, suppressed, showSuppressed);
|
|
917
1046
|
return lines.join('\n');
|
|
918
1047
|
}
|
|
919
1048
|
|
|
1049
|
+
export function formatAuditReport(report, options = {}) {
|
|
1050
|
+
const { format = 'stylish', showSuppressed = false } = options;
|
|
1051
|
+
const suppressed = report.suppressed || [];
|
|
1052
|
+
|
|
1053
|
+
if (format === 'json') return formatAuditJson(report, suppressed);
|
|
1054
|
+
if (format !== 'stylish') {
|
|
1055
|
+
throw new AuditError(`Unknown report format: ${format}`, 'UNKNOWN_FORMAT');
|
|
1056
|
+
}
|
|
1057
|
+
return formatAuditStylish(report, suppressed, showSuppressed);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
920
1060
|
// Optionally list the suppressed findings (with their exception reason) below the
|
|
921
1061
|
// summary, so `--show-suppressed` keeps the audit trail visible.
|
|
922
1062
|
function appendSuppressed(lines, suppressed, showSuppressed) {
|
package/src/exceptions.js
CHANGED
|
@@ -110,89 +110,106 @@ function splitPackageSelector(pkg) {
|
|
|
110
110
|
* @param {string} [opts.configPath]
|
|
111
111
|
* @returns {Array<{rule, reason, expires, source, selectors, _raw}>}
|
|
112
112
|
*/
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
113
|
+
// An exception entry must be an object carrying non-empty `rule` and `reason`.
|
|
114
|
+
function validateExceptionShape(entry, index, at) {
|
|
115
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
116
|
+
throw new ExceptionConfigError(`exception #${index} must be an object`, 'INVALID_EXCEPTIONS', at);
|
|
117
|
+
}
|
|
118
|
+
if (typeof entry.rule !== 'string' || entry.rule.trim() === '') {
|
|
119
|
+
throw new ExceptionConfigError(`exception #${index} is missing "rule"`, 'EXCEPTION_MISSING_RULE', at);
|
|
120
|
+
}
|
|
121
|
+
if (typeof entry.reason !== 'string' || entry.reason.trim() === '') {
|
|
122
|
+
throw new ExceptionConfigError(
|
|
123
|
+
`exception for rule "${entry.rule}" is missing a non-empty "reason"`,
|
|
124
|
+
'EXCEPTION_MISSING_REASON',
|
|
125
|
+
at
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
117
129
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
130
|
+
// At least one selector, each a non-empty string. A selector this tool's findings
|
|
131
|
+
// never carry is an error in the tool's OWN section but is tolerated in `common`
|
|
132
|
+
// (it simply never matches).
|
|
133
|
+
function validateExceptionSelectors(entry, source, applicableSelectors, at) {
|
|
134
|
+
const present = SELECTORS.filter((s) => entry[s] !== undefined);
|
|
135
|
+
if (present.length === 0) {
|
|
136
|
+
throw new ExceptionConfigError(
|
|
137
|
+
`exception for rule "${entry.rule}" needs at least one selector (${SELECTORS.join(', ')})`,
|
|
138
|
+
'EXCEPTION_NO_SELECTOR',
|
|
139
|
+
at
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
for (const sel of present) {
|
|
143
|
+
if (typeof entry[sel] !== 'string' || entry[sel].trim() === '') {
|
|
127
144
|
throw new ExceptionConfigError(
|
|
128
|
-
`exception for rule "${entry.rule}"
|
|
129
|
-
'
|
|
145
|
+
`exception selector "${sel}" for rule "${entry.rule}" must be a non-empty string`,
|
|
146
|
+
'EXCEPTION_BAD_SELECTOR',
|
|
130
147
|
at
|
|
131
148
|
);
|
|
132
149
|
}
|
|
133
|
-
|
|
134
|
-
const present = SELECTORS.filter((s) => entry[s] !== undefined);
|
|
135
|
-
if (present.length === 0) {
|
|
150
|
+
if (source === 'own' && !applicableSelectors.includes(sel)) {
|
|
136
151
|
throw new ExceptionConfigError(
|
|
137
|
-
`exception
|
|
138
|
-
'
|
|
152
|
+
`exception selector "${sel}" is not applicable to this tool (applicable: ${applicableSelectors.join(', ')})`,
|
|
153
|
+
'EXCEPTION_BAD_SELECTOR',
|
|
139
154
|
at
|
|
140
155
|
);
|
|
141
156
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
throw new ExceptionConfigError(
|
|
145
|
-
`exception selector "${sel}" for rule "${entry.rule}" must be a non-empty string`,
|
|
146
|
-
'EXCEPTION_BAD_SELECTOR',
|
|
147
|
-
at
|
|
148
|
-
);
|
|
149
|
-
}
|
|
150
|
-
// A selector this tool's findings never carry is an error in the tool's
|
|
151
|
-
// OWN section but is tolerated in `common` (it simply never matches).
|
|
152
|
-
if (source === 'own' && !applicableSelectors.includes(sel)) {
|
|
153
|
-
throw new ExceptionConfigError(
|
|
154
|
-
`exception selector "${sel}" is not applicable to this tool (applicable: ${applicableSelectors.join(', ')})`,
|
|
155
|
-
'EXCEPTION_BAD_SELECTOR',
|
|
156
|
-
at
|
|
157
|
-
);
|
|
158
|
-
}
|
|
159
|
-
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
160
159
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
160
|
+
// `expires`, when present, must be a valid YYYY-MM-DD date.
|
|
161
|
+
function validateExceptionExpires(entry, at) {
|
|
162
|
+
if (entry.expires === undefined) return;
|
|
163
|
+
if (typeof entry.expires !== 'string' || !EXPIRES_RE.test(entry.expires) || Number.isNaN(Date.parse(entry.expires))) {
|
|
164
|
+
throw new ExceptionConfigError(
|
|
165
|
+
`exception "expires" for rule "${entry.rule}" must be a valid YYYY-MM-DD date`,
|
|
166
|
+
'EXCEPTION_BAD_EXPIRES',
|
|
167
|
+
at
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
170
171
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
)
|
|
177
|
-
|
|
172
|
+
// An unknown rule id is an error in the tool's own section (spec §8); in `common`
|
|
173
|
+
// it belongs to a sibling tool and is tolerated.
|
|
174
|
+
function validateExceptionRule(entry, source, knownRules, at) {
|
|
175
|
+
if (source === 'own' && knownRules && !knownRules.includes(entry.rule)) {
|
|
176
|
+
throw new ExceptionConfigError(
|
|
177
|
+
`Unknown rule "${entry.rule}" in exception (known rules: ${knownRules.join(', ')})`,
|
|
178
|
+
'UNKNOWN_RULE',
|
|
179
|
+
at
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function buildExceptionSelectors(entry) {
|
|
185
|
+
const selectors = { rule: entry.rule };
|
|
186
|
+
if (entry.package !== undefined) selectors.package = splitPackageSelector(entry.package);
|
|
187
|
+
if (entry.path !== undefined) selectors.path = entry.path;
|
|
188
|
+
if (entry.symbol !== undefined) selectors.symbol = entry.symbol;
|
|
189
|
+
if (entry.id !== undefined) selectors.id = entry.id;
|
|
190
|
+
return selectors;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function parseExceptions(raw, opts = {}) {
|
|
194
|
+
const { source = 'own', applicableSelectors = SELECTORS, knownRules = null, configPath = null } = opts;
|
|
195
|
+
const entries = ensureArray(raw, { configPath });
|
|
178
196
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
197
|
+
return entries.map((entry, index) => {
|
|
198
|
+
const at = { configPath, source, index };
|
|
199
|
+
validateExceptionShape(entry, index, at);
|
|
200
|
+
validateExceptionSelectors(entry, source, applicableSelectors, at);
|
|
201
|
+
validateExceptionExpires(entry, at);
|
|
202
|
+
validateExceptionRule(entry, source, knownRules, at);
|
|
184
203
|
|
|
185
|
-
|
|
204
|
+
return {
|
|
186
205
|
rule: entry.rule,
|
|
187
206
|
reason: entry.reason,
|
|
188
207
|
expires: entry.expires || null,
|
|
189
208
|
source,
|
|
190
|
-
selectors,
|
|
209
|
+
selectors: buildExceptionSelectors(entry),
|
|
191
210
|
_raw: entry
|
|
192
|
-
}
|
|
211
|
+
};
|
|
193
212
|
});
|
|
194
|
-
|
|
195
|
-
return out;
|
|
196
213
|
}
|
|
197
214
|
|
|
198
215
|
// --- matching ---
|
|
@@ -236,8 +253,7 @@ export function matchException(exception, finding) {
|
|
|
236
253
|
if (s.package !== undefined && !matchPackage(s.package, finding)) return false;
|
|
237
254
|
if (s.path !== undefined && !matchGlob(s.path, finding.path)) return false;
|
|
238
255
|
if (s.symbol !== undefined && !matchSymbol(s.symbol, finding.symbol)) return false;
|
|
239
|
-
|
|
240
|
-
return true;
|
|
256
|
+
return !(s.id !== undefined && s.id !== finding.id);
|
|
241
257
|
}
|
|
242
258
|
|
|
243
259
|
/**
|